@cueloop/client 0.1.0-alpha.45 → 0.1.0-alpha.47

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.47",
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.47",
30
+ "@cueloop/extension-api": "0.1.0-alpha.47",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.47",
32
+ "@cueloop/schema": "0.1.0-alpha.47",
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": {
@@ -0,0 +1,125 @@
1
+ /** Prototype review end to end: a click on the rendered page selects a DOM element, the marker actions bar opens, a typed comment saves through the controller, and the annotation shows in the rail. The headless browser is replaced with a fake so no Chrome or kitty output is needed. Char-frame assertions over the real App and a real in-process daemon. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import {
13
+ setPrototypeRendererFactory,
14
+ type PrototypeElement,
15
+ type PrototypeRenderer,
16
+ } from "./prototype-browser";
17
+ import {
18
+ isolateUserConfig,
19
+ press,
20
+ settle,
21
+ typeText,
22
+ waitForState,
23
+ waitForText,
24
+ } from "./test-support";
25
+
26
+ const HTML = "<main><div class='card'><h2>Pro</h2><p>$24/mo</p></div></main>";
27
+
28
+ const FAKE_ELEMENT: PrototypeElement = {
29
+ selector: "main > div.card",
30
+ quote: "Pro $24/mo",
31
+ // mid-page, so the popover anchors well away from the click point below
32
+ box: { x: 440, y: 320, width: 320, height: 220 },
33
+ };
34
+
35
+ let rendered = false;
36
+ const fakeRenderer: PrototypeRenderer = {
37
+ viewport: { width: 1280, height: 800 },
38
+ screenshot: async () => {
39
+ rendered = true;
40
+ return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
41
+ },
42
+ elementAt: async () => FAKE_ELEMENT,
43
+ highlight: async () => undefined,
44
+ close: async () => undefined,
45
+ };
46
+
47
+ let home: string;
48
+ let server: DaemonServer;
49
+ let session: ReviewSession;
50
+ let restoreUserConfig: () => void;
51
+
52
+ beforeEach(() => {
53
+ home = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
54
+ restoreUserConfig = isolateUserConfig(home);
55
+ server = new DaemonServer({ home, idleExitMs: 0 });
56
+ server.start();
57
+ session = server.core.sessionCreate({
58
+ workspace: { repoRoot: "/repo", branch: "main" },
59
+ artifact: {
60
+ type: "prototype",
61
+ content: HTML,
62
+ meta: { title: "Pricing cards", prototypePath: "/repo/cards.html" },
63
+ },
64
+ });
65
+ rendered = false;
66
+ setPrototypeRendererFactory(async () => fakeRenderer);
67
+ });
68
+ afterEach(() => {
69
+ setPrototypeRendererFactory(null);
70
+ restoreUserConfig();
71
+ server.stop();
72
+ rmSync(home, { recursive: true, force: true });
73
+ });
74
+
75
+ async function renderApp() {
76
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
77
+ width: 120,
78
+ height: 32,
79
+ });
80
+ await waitForText(setup, "cueloop");
81
+ return setup;
82
+ }
83
+
84
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
85
+
86
+ async function clickText(setup: Setup, needle: string): Promise<void> {
87
+ const lines = setup.captureCharFrame().split("\n");
88
+ const row = lines.findIndex((line) => line.includes(needle));
89
+ await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
90
+ await setup.renderOnce();
91
+ }
92
+
93
+ describe("prototype review", () => {
94
+ test("click an element, comment on it, and it lands in the rail", async () => {
95
+ // Arrange - wait until the fake browser produced its first screenshot, so
96
+ // the sheet is interactive before the click
97
+ const setup = await renderApp();
98
+ await waitForState(setup, () => rendered);
99
+ await settle(setup);
100
+
101
+ // Act
102
+ await setup.mockMouse.click(6, 6);
103
+ await waitForText(setup, "comment");
104
+ await clickText(setup, "comment");
105
+ await waitForText(setup, "write a note");
106
+ // the keymap-suppression flag must propagate before typing so keys reach the
107
+ // textarea, not the global keymap
108
+ await settle(setup);
109
+ await typeText(setup, "tighten the padding");
110
+ await press(setup, "enter");
111
+
112
+ // Assert - the annotation is stored against the element's selector
113
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
114
+ const stored = server.core.sessionGet(session.id).annotations[0]!;
115
+ expect(stored.kind).toBe("comment");
116
+ expect(stored.body).toBe("tighten the padding");
117
+ expect(stored.anchor.selector).toBe("main > div.card");
118
+
119
+ // and it renders in the rail
120
+ await settle(setup);
121
+ const frame = setup.captureCharFrame();
122
+ expect(frame).toContain("tighten the padding");
123
+ expect(frame).toContain("COMMENT");
124
+ });
125
+ });
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,8 @@ 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";
256
+ const [prototypeComposing, setPrototypeComposing] = useState(false);
254
257
  // sort position per annotation so the rail interleaves annotation and removal
255
258
  // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
256
259
  // annotation resolves to the display index it marked
@@ -587,6 +590,9 @@ export function App({
587
590
  };
588
591
 
589
592
  useKeyboard((key) => {
593
+ // The prototype compose textarea owns the keyboard while open: let it receive
594
+ // the typed note instead of the global keymap acting on each letter.
595
+ if (prototypeComposing) return;
590
596
  // A running in-tab agent terminal owns the keyboard: forward every key to it,
591
597
  // with ctrl+] as the detach chord back to the review.
592
598
  if (agentTerminal) {
@@ -1025,7 +1031,17 @@ export function App({
1025
1031
  <box style={{ width: railFootprint }} />
1026
1032
  </box>
1027
1033
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
1028
- {isDiff ? (
1034
+ {isPrototype ? (
1035
+ <PrototypeSheet
1036
+ prototypePath={activeSession.artifact.meta.prototypePath ?? ""}
1037
+ quickActions={quickActions}
1038
+ canComment={isOwner && !resolved}
1039
+ onCommentElement={(element, body) =>
1040
+ controller.annotatePrototype(element.selector, element.quote, body)
1041
+ }
1042
+ onComposingChange={setPrototypeComposing}
1043
+ />
1044
+ ) : isDiff ? (
1029
1045
  // the sheet dims to reading-quiet colors while the wizard has focus
1030
1046
  <DiffSheet
1031
1047
  rows={rows}
@@ -1061,7 +1077,7 @@ export function App({
1061
1077
  session: activeSession,
1062
1078
  authorNames,
1063
1079
  selectedId: focusedAnnotationId,
1064
- resolvedIds: isDiff ? null : resolvedIds,
1080
+ resolvedIds: isDiff || isPrototype ? null : resolvedIds,
1065
1081
  curationItems,
1066
1082
  selectedCurationId,
1067
1083
  annotationPositions,
@@ -25,6 +25,12 @@ export interface AnnotationDraft {
25
25
  onInput: (text: string) => void;
26
26
  onSave: () => void;
27
27
  onCancel: () => void;
28
+ /**
29
+ * When set, ⏎ in the textarea saves through this handler. The plan and diff
30
+ * composers leave it unset because the app keymap owns ⏎ there; the prototype
31
+ * composer sets it because the app keymap is suppressed while it is open.
32
+ */
33
+ onSubmit?: () => void;
28
34
  }
29
35
 
30
36
  export interface AnnotationSaved {
@@ -119,6 +125,7 @@ function DraftEditor({
119
125
  initialValue={draft.text}
120
126
  placeholder="write a note..."
121
127
  keyBindings={COMPOSE_KEY_BINDINGS}
128
+ {...(draft.onSubmit ? { onSubmit: draft.onSubmit } : {})}
122
129
  onContentChange={() => {
123
130
  const editor = editorRef.current;
124
131
  if (!editor) return;
@@ -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,323 @@
1
+ /**
2
+ * Prototype review surface: renders the HTML screenshot into a reserved cell
3
+ * region via the kitty graphics protocol, and turns a click into a DOM-element
4
+ * selection, so the shared marker actions bar and compose card annotate an
5
+ * element rather than a text span. The image is painted directly (not through
6
+ * OpenTUI's ImageRenderable, which does not display in every terminal): the
7
+ * region's cells are never repainted, and the picture is re-placed after each
8
+ * frame so it survives OpenTUI's own draws.
9
+ */
10
+
11
+ import React, { useEffect, useRef, useState } from "react";
12
+ import type { BoxRenderable } from "@opentui/core";
13
+ import { useRenderer } from "@opentui/react";
14
+ import type { Theme } from "../theme";
15
+ import { quickActionBody, type QuickAction } from "../config";
16
+ import { useComponentTheme } from "./theme-context";
17
+ import { FRAME_BORDER_STYLE } from "./primitives/frame";
18
+ import { MarkerPopover } from "./MarkerPopover";
19
+ import { AnnotationCard } from "./AnnotationCard";
20
+ import {
21
+ cssBoxToCell,
22
+ imageCellToCss,
23
+ prototypeRendererFactory,
24
+ type PrototypeElement,
25
+ type PrototypeRenderer,
26
+ type PrototypeViewport,
27
+ } from "../prototype-browser";
28
+ import {
29
+ deleteKittyImage,
30
+ placeKittyImage,
31
+ transmitKittyImage,
32
+ type CellRegion,
33
+ } from "../kitty-image";
34
+
35
+ export interface PrototypeSheetProps {
36
+ prototypePath: string;
37
+ quickActions: QuickAction[];
38
+ canComment: boolean;
39
+ onCommentElement: (element: PrototypeElement, body: string) => void;
40
+ /** Signals when the inline compose owns the keyboard, so the app suppresses
41
+ * its global keymap and the compose textarea receives the typed note. */
42
+ onComposingChange?: (active: boolean) => void;
43
+ theme?: Theme;
44
+ }
45
+
46
+ type SheetStatus = "loading" | "ready" | "unsupported" | "error";
47
+
48
+ const POPOVER_ROWS = 3;
49
+ const PROTOTYPE_IMAGE_ID = 811;
50
+ // The capture viewport width in CSS pixels; its height matches the region's
51
+ // cell aspect so the rendered image fills the box instead of letterboxing. The
52
+ // aspect comes from the terminal's reported cell pixels, falling back to a
53
+ // typical monospace ratio when the terminal never reports one.
54
+ const CAPTURE_WIDTH = 1280;
55
+ const FALLBACK_CELL_HEIGHT_OVER_WIDTH = 2.18;
56
+
57
+ function cellHeightOverWidth(renderer: {
58
+ resolution: { width: number; height: number } | null;
59
+ terminalWidth: number;
60
+ terminalHeight: number;
61
+ }): number {
62
+ const resolution = renderer.resolution;
63
+ if (!resolution || renderer.terminalWidth < 1 || renderer.terminalHeight < 1) {
64
+ return FALLBACK_CELL_HEIGHT_OVER_WIDTH;
65
+ }
66
+ return resolution.height / renderer.terminalHeight / (resolution.width / renderer.terminalWidth);
67
+ }
68
+
69
+ function regionOf(box: BoxRenderable | null): CellRegion | null {
70
+ if (!box || box.width < 1 || box.height < 1) return null;
71
+ return { column: box.x, row: box.y, columns: box.width, rows: box.height };
72
+ }
73
+
74
+ export function PrototypeSheet({
75
+ prototypePath,
76
+ quickActions,
77
+ canComment,
78
+ onCommentElement,
79
+ onComposingChange,
80
+ theme,
81
+ }: PrototypeSheetProps): React.ReactNode {
82
+ const tokens = useComponentTheme(theme);
83
+ const renderer = useRenderer();
84
+ const regionRef = useRef<BoxRenderable | null>(null);
85
+ const browserRef = useRef<PrototypeRenderer | null>(null);
86
+ const launchedRef = useRef(false);
87
+ const viewportRef = useRef<PrototypeViewport>({ width: CAPTURE_WIDTH, height: 800 });
88
+ const pngRef = useRef<Uint8Array | null>(null);
89
+ const transmittedRef = useRef<Uint8Array | null>(null);
90
+ const paintRef = useRef<() => void>(() => undefined);
91
+ const launchRef = useRef<() => void>(() => undefined);
92
+
93
+ const [status, setStatus] = useState<SheetStatus>("loading");
94
+ const [errorMessage, setErrorMessage] = useState<string>("");
95
+ const [selected, setSelected] = useState<PrototypeElement | null>(null);
96
+ const [overlayCell, setOverlayCell] = useState<{ left: number; top: number } | null>(null);
97
+ const [actionsOpen, setActionsOpen] = useState(false);
98
+ const [composing, setComposing] = useState(false);
99
+ const [draftText, setDraftText] = useState("");
100
+
101
+ const setPng = (png: Uint8Array): void => {
102
+ pngRef.current = png;
103
+ transmittedRef.current = null;
104
+ paintRef.current();
105
+ };
106
+
107
+ // Every frame: start the browser once layout is known, then keep the picture
108
+ // asserted over its cells. The kitty emission (transmit once, cheap re-place
109
+ // after) is skipped when the renderer has no raw writeOut channel (the test
110
+ // harness), but the launch trigger still runs.
111
+ useEffect(() => {
112
+ if (!renderer) return;
113
+ // writeOut is the renderer's raw output channel, ordered against its frame
114
+ // bytes; it is private, so reach it through one narrow cast
115
+ const rawWrite = (renderer as unknown as { writeOut?: (data: string) => void }).writeOut;
116
+ const write =
117
+ typeof rawWrite === "function" ? (chunk: string) => rawWrite.call(renderer, chunk) : null;
118
+ const paint = (): void => {
119
+ if (!write) return;
120
+ const region = regionOf(regionRef.current);
121
+ const png = pngRef.current;
122
+ if (!region || !png) return;
123
+ if (transmittedRef.current !== png) {
124
+ transmitKittyImage(write, png, region, PROTOTYPE_IMAGE_ID);
125
+ transmittedRef.current = png;
126
+ } else {
127
+ placeKittyImage(write, region, PROTOTYPE_IMAGE_ID);
128
+ }
129
+ };
130
+ paintRef.current = paint;
131
+ const onFrame = (): void => {
132
+ launchRef.current();
133
+ paint();
134
+ };
135
+ renderer.on("frame", onFrame);
136
+ return () => {
137
+ renderer.off("frame", onFrame);
138
+ if (write) deleteKittyImage(write, PROTOTYPE_IMAGE_ID);
139
+ };
140
+ }, [renderer]);
141
+
142
+ useEffect(() => {
143
+ onComposingChange?.(composing);
144
+ }, [composing, onComposingChange]);
145
+
146
+ const unmountedRef = useRef(false);
147
+ useEffect(() => {
148
+ return () => {
149
+ unmountedRef.current = true;
150
+ void browserRef.current?.close();
151
+ };
152
+ }, []);
153
+
154
+ const launch = (): void => {
155
+ const region = regionOf(regionRef.current);
156
+ const caps = renderer?.capabilities;
157
+ if (launchedRef.current || !region) return;
158
+ if (caps && caps.kitty_graphics === false) {
159
+ setStatus("unsupported");
160
+ return;
161
+ }
162
+ launchedRef.current = true;
163
+ const viewport = {
164
+ width: CAPTURE_WIDTH,
165
+ height: Math.round(
166
+ (CAPTURE_WIDTH * region.rows * cellHeightOverWidth(renderer!)) / region.columns,
167
+ ),
168
+ };
169
+ viewportRef.current = viewport;
170
+ void (async () => {
171
+ try {
172
+ const browser = await prototypeRendererFactory()({ filePath: prototypePath, viewport });
173
+ // the sheet may have unmounted while Chromium was launching; close the
174
+ // late arrival instead of leaking the process
175
+ if (unmountedRef.current) {
176
+ void browser.close();
177
+ return;
178
+ }
179
+ browserRef.current = browser;
180
+ setPng(await browser.screenshot());
181
+ setStatus("ready");
182
+ } catch (error) {
183
+ setErrorMessage(error instanceof Error ? error.message : String(error));
184
+ setStatus("error");
185
+ }
186
+ })();
187
+ };
188
+ useEffect(() => {
189
+ launchRef.current = launch;
190
+ });
191
+
192
+ const refresh = async (): Promise<void> => {
193
+ const browser = browserRef.current;
194
+ if (browser) setPng(await browser.screenshot());
195
+ };
196
+
197
+ const clearSelection = (): void => {
198
+ setSelected(null);
199
+ setOverlayCell(null);
200
+ setActionsOpen(false);
201
+ setComposing(false);
202
+ setDraftText("");
203
+ void browserRef.current
204
+ ?.highlight(null)
205
+ .then(refresh)
206
+ .catch(() => undefined);
207
+ };
208
+
209
+ const onRegionMouseDown = (event: { x: number; y: number }): void => {
210
+ const browser = browserRef.current;
211
+ const region = regionOf(regionRef.current);
212
+ if (status !== "ready" || !browser || !region) return;
213
+ const geometry = {
214
+ x: region.column,
215
+ y: region.row,
216
+ width: region.columns,
217
+ height: region.rows,
218
+ };
219
+ const cssPoint = imageCellToCss(event, geometry, viewportRef.current);
220
+ if (!cssPoint) return;
221
+ void (async () => {
222
+ const element = await browser.elementAt(cssPoint.x, cssPoint.y);
223
+ if (!element) return;
224
+ const cell = cssBoxToCell(element.box, geometry, viewportRef.current);
225
+ const topRaw = cell.row - geometry.y;
226
+ setSelected(element);
227
+ setOverlayCell({
228
+ left: Math.max(0, cell.column - geometry.x),
229
+ top: Math.max(0, topRaw < POPOVER_ROWS ? topRaw + 1 : topRaw - POPOVER_ROWS),
230
+ });
231
+ setActionsOpen(false);
232
+ setComposing(false);
233
+ await browser.highlight(element.selector);
234
+ await refresh();
235
+ })().catch((error) => {
236
+ setErrorMessage(error instanceof Error ? error.message : String(error));
237
+ setStatus("error");
238
+ });
239
+ };
240
+
241
+ const commit = (body: string): void => {
242
+ if (!canComment) return;
243
+ if (selected && body.trim()) onCommentElement(selected, body.trim());
244
+ clearSelection();
245
+ };
246
+
247
+ const overlayStyle = {
248
+ position: "absolute" as const,
249
+ left: overlayCell?.left ?? 0,
250
+ top: Math.max(0, overlayCell?.top ?? 0),
251
+ flexDirection: "column" as const,
252
+ };
253
+
254
+ return (
255
+ <box
256
+ style={{
257
+ flexGrow: 1,
258
+ flexDirection: "column",
259
+ border: true,
260
+ borderStyle: FRAME_BORDER_STYLE,
261
+ borderColor: tokens.text,
262
+ }}
263
+ >
264
+ <box
265
+ ref={(instance: BoxRenderable | null) => {
266
+ regionRef.current = instance;
267
+ }}
268
+ style={{ flexGrow: 1 }}
269
+ onSizeChange={launch}
270
+ onMouseDown={onRegionMouseDown}
271
+ />
272
+ {status !== "ready" ? (
273
+ <box style={{ position: "absolute", left: 2, top: 1 }}>
274
+ <text fg={tokens.textDim}>{statusLine(status, errorMessage)}</text>
275
+ </box>
276
+ ) : null}
277
+ {selected && !composing ? (
278
+ <box style={overlayStyle}>
279
+ <MarkerPopover
280
+ view={actionsOpen ? "actions" : "toolbar"}
281
+ actions={quickActions}
282
+ actionIndex={0}
283
+ canCut={false}
284
+ onComment={() => {
285
+ if (!canComment) return;
286
+ onComposingChange?.(true);
287
+ setComposing(true);
288
+ }}
289
+ onCut={() => undefined}
290
+ onOpenActions={() => setActionsOpen(true)}
291
+ onClose={clearSelection}
292
+ onPickAction={(index) => commit(quickActionBody(quickActions[index]!))}
293
+ onBack={() => setActionsOpen(false)}
294
+ theme={theme}
295
+ />
296
+ </box>
297
+ ) : null}
298
+ {selected && composing ? (
299
+ <box style={overlayStyle}>
300
+ <AnnotationCard
301
+ kind="comment"
302
+ quote={selected.quote}
303
+ draft={{
304
+ text: draftText,
305
+ onInput: setDraftText,
306
+ onSave: () => commit(draftText),
307
+ onSubmit: () => commit(draftText),
308
+ onCancel: clearSelection,
309
+ }}
310
+ theme={theme}
311
+ />
312
+ </box>
313
+ ) : null}
314
+ </box>
315
+ );
316
+ }
317
+
318
+ function statusLine(status: SheetStatus, errorMessage: string): string {
319
+ if (status === "loading") return "rendering prototype…";
320
+ if (status === "unsupported")
321
+ return "prototype preview needs a graphics terminal (kitty or ghostty)";
322
+ return `prototype preview failed: ${errorMessage}`;
323
+ }
@@ -817,6 +817,34 @@ exports[`stories catalog PromptDialog/RenameAuthor renders, snapshots, and carri
817
817
 
818
818
 
819
819
 
820
+ "
821
+ `;
822
+
823
+ exports[`stories catalog PrototypeSheet/Unsupported renders, snapshots, and carries its colors: PrototypeSheet/Unsupported 1`] = `
824
+ "╭──────────────────────────────────────────────────────────────────────────────╮
825
+ │ │
826
+ │ rendering prototype… │
827
+ │ │
828
+ │ │
829
+ │ │
830
+ │ │
831
+ │ │
832
+ │ │
833
+ │ │
834
+ │ │
835
+ │ │
836
+ │ │
837
+ │ │
838
+ │ │
839
+ │ │
840
+ │ │
841
+ │ │
842
+ │ │
843
+ │ │
844
+ │ │
845
+ │ │
846
+ │ │
847
+ ╰──────────────────────────────────────────────────────────────────────────────╯
820
848
  "
821
849
  `;
822
850
 
@@ -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,74 @@
1
+ /**
2
+ * Direct kitty graphics protocol emission for a cell rectangle. OpenTUI's
3
+ * ImageRenderable does not display in every terminal, so prototype review paints
4
+ * the screenshot itself: transmit the PNG once, then re-place it after each
5
+ * frame over its reserved (never-repainted) cells, cell-addressed so it needs no
6
+ * pixel-resolution report.
7
+ */
8
+
9
+ export interface CellRegion {
10
+ /** 0-based screen cell of the region's top-left corner. */
11
+ column: number;
12
+ row: number;
13
+ columns: number;
14
+ rows: number;
15
+ }
16
+
17
+ /** Writes a raw sequence ordered against the renderer's own frame output. */
18
+ export type OrderedWrite = (chunk: string) => void;
19
+
20
+ const ESCAPE_STRING_TERMINATOR = "\x1b\\";
21
+ const CHUNK_BYTES = 4096;
22
+
23
+ /** Save the cursor, run the placement at the region's corner, restore it. */
24
+ function atRegion(region: CellRegion, body: string): string {
25
+ const moveToCorner = `\x1b[${region.row + 1};${region.column + 1}H`;
26
+ return `\x1b7${moveToCorner}${body}\x1b8`;
27
+ }
28
+
29
+ function graphicsCommand(keys: string, payload: string): string {
30
+ return `\x1b_G${keys};${payload}${ESCAPE_STRING_TERMINATOR}`;
31
+ }
32
+
33
+ /**
34
+ * Transmit a PNG and display it filling the region's cells (a=T). f=100 is PNG,
35
+ * C=1 keeps the cursor put so a full-region image cannot scroll the screen, and
36
+ * q=2 suppresses the terminal's replies. Data over 4096 bytes is chunked.
37
+ */
38
+ export function transmitKittyImage(
39
+ write: OrderedWrite,
40
+ png: Uint8Array,
41
+ region: CellRegion,
42
+ imageId: number,
43
+ ): void {
44
+ if (region.columns < 1 || region.rows < 1) return;
45
+ const base64 = Buffer.from(png).toString("base64");
46
+ // z<0 places the image beneath the text layer, so the marker popover and
47
+ // compose card (ordinary cells) still paint over it
48
+ const controlKeys = `a=T,f=100,i=${imageId},q=2,C=1,z=-1,c=${region.columns},r=${region.rows}`;
49
+ let sequence = "";
50
+ for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
51
+ const chunk = base64.slice(offset, offset + CHUNK_BYTES);
52
+ const isMore = offset + CHUNK_BYTES < base64.length ? 1 : 0;
53
+ sequence +=
54
+ offset === 0
55
+ ? graphicsCommand(`${controlKeys},m=${isMore}`, chunk)
56
+ : graphicsCommand(`m=${isMore}`, chunk);
57
+ }
58
+ write(atRegion(region, sequence));
59
+ }
60
+
61
+ /** Re-place an already-transmitted image over its region, cheaply (a=p, no data). */
62
+ export function placeKittyImage(write: OrderedWrite, region: CellRegion, imageId: number): void {
63
+ if (region.columns < 1 || region.rows < 1) return;
64
+ const command = graphicsCommand(
65
+ `a=p,i=${imageId},q=2,C=1,z=-1,c=${region.columns},r=${region.rows}`,
66
+ "",
67
+ );
68
+ write(atRegion(region, command));
69
+ }
70
+
71
+ /** Remove the image so it does not linger after the sheet closes (a=d). */
72
+ export function deleteKittyImage(write: OrderedWrite, imageId: number): void {
73
+ write(graphicsCommand(`a=d,d=i,i=${imageId},q=2`, ""));
74
+ }
@@ -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,232 @@
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 type PrototypeRendererFactory = (options: LaunchOptions) => Promise<PrototypeRenderer>;
83
+
84
+ let rendererFactory: PrototypeRendererFactory | null = null;
85
+
86
+ /** Override the renderer factory so tests can inject a browserless fake. */
87
+ export function setPrototypeRendererFactory(factory: PrototypeRendererFactory | null): void {
88
+ rendererFactory = factory;
89
+ }
90
+
91
+ /** The active factory - the injected fake in tests, else real headless Chromium. */
92
+ export function prototypeRendererFactory(): PrototypeRendererFactory {
93
+ return rendererFactory ?? launchPrototypeRenderer;
94
+ }
95
+
96
+ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
97
+ const puppeteer = (await import("puppeteer-core")).default;
98
+ const { pathToFileURL } = await import("node:url");
99
+ const browser = await puppeteer.launch({
100
+ executablePath: options.executablePath ?? chromeExecutable(),
101
+ headless: true,
102
+ args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
103
+ defaultViewport: {
104
+ width: options.viewport.width,
105
+ height: options.viewport.height,
106
+ deviceScaleFactor: 2,
107
+ },
108
+ });
109
+ // close the browser if page setup fails, so a launch error never leaks Chromium
110
+ const page = await browser.newPage().catch(async (error) => {
111
+ await browser.close();
112
+ throw error;
113
+ });
114
+ await page
115
+ .goto(pathToFileURL(options.filePath).href, { waitUntil: "networkidle0" })
116
+ .catch(async (error) => {
117
+ await browser.close();
118
+ throw error;
119
+ });
120
+
121
+ return {
122
+ viewport: options.viewport,
123
+ async screenshot() {
124
+ const buffer = await page.screenshot({ type: "png", encoding: "binary" });
125
+ return new Uint8Array(buffer as Buffer);
126
+ },
127
+ async elementAt(cssX, cssY) {
128
+ return (await page.evaluate(elementAtScript, cssX, cssY)) as PrototypeElement | null;
129
+ },
130
+ async highlight(selector) {
131
+ await page.evaluate(highlightScript, selector);
132
+ },
133
+ async close() {
134
+ await browser.close();
135
+ },
136
+ };
137
+ }
138
+
139
+ /** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
140
+ function elementAtScript(x: number, y: number): unknown {
141
+ const SEMANTIC = new Set([
142
+ "SECTION",
143
+ "ARTICLE",
144
+ "LI",
145
+ "NAV",
146
+ "HEADER",
147
+ "FOOTER",
148
+ "ASIDE",
149
+ "FORM",
150
+ ]);
151
+ const isNamed = (element: Element): boolean =>
152
+ element.classList.length > 0 || SEMANTIC.has(element.tagName);
153
+ // a card wraps several children: climb to the nearest named container holding
154
+ // more than one child, else the nearest named ancestor, else the clicked leaf
155
+ const componentRoot = (start: Element): Element => {
156
+ let namedFallback: Element | null = null;
157
+ let current: Element | null = start;
158
+ while (current && current !== document.body) {
159
+ if (isNamed(current)) {
160
+ if (current.childElementCount > 1) return current;
161
+ namedFallback = namedFallback ?? current;
162
+ }
163
+ current = current.parentElement;
164
+ }
165
+ return namedFallback ?? start;
166
+ };
167
+ const selectorFor = (start: Element): string => {
168
+ const parts: string[] = [];
169
+ let current: Element | null = start;
170
+ while (current && current.nodeType === 1 && current !== document.documentElement) {
171
+ let part = current.tagName.toLowerCase();
172
+ if (current.id) {
173
+ parts.unshift(part + "#" + CSS.escape(current.id));
174
+ break;
175
+ }
176
+ const parent: Element | null = current.parentElement;
177
+ if (parent) {
178
+ const twins = [...parent.children].filter((child) => child.tagName === current!.tagName);
179
+ if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
180
+ }
181
+ parts.unshift(part);
182
+ current = current.parentElement;
183
+ }
184
+ return parts.join(" > ");
185
+ };
186
+ const hit = document.elementFromPoint(x, y);
187
+ if (!(hit instanceof Element)) return null;
188
+ const node = componentRoot(hit);
189
+ const selector = selectorFor(node);
190
+ if (!selector) return null;
191
+ const text = (node.textContent || "").replace(/\s+/g, " ").trim();
192
+ const tag = node.tagName.toLowerCase();
193
+ const quote = text
194
+ ? text.slice(0, 80)
195
+ : node.id
196
+ ? tag + "#" + node.id
197
+ : node.classList.length
198
+ ? tag + "." + node.classList[0]
199
+ : tag;
200
+ const rect = node.getBoundingClientRect();
201
+ return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
202
+ }
203
+
204
+ function highlightScript(selector: string | null): void {
205
+ const ATTRIBUTE = "data-cueloop-selected";
206
+ const STYLE_ID = "cueloop-highlight-style";
207
+ document
208
+ .querySelectorAll("[" + ATTRIBUTE + "]")
209
+ .forEach((node) => node.removeAttribute(ATTRIBUTE));
210
+ if (!document.getElementById(STYLE_ID)) {
211
+ const style = document.createElement("style");
212
+ style.id = STYLE_ID;
213
+ style.textContent =
214
+ "[" + ATTRIBUTE + "]{outline:2px solid #f38ba8 !important;outline-offset:2px;}";
215
+ document.head.appendChild(style);
216
+ }
217
+ if (selector) {
218
+ const node = document.querySelector(selector);
219
+ if (node) node.setAttribute(ATTRIBUTE, "");
220
+ }
221
+ }
222
+
223
+ /** Standard Chrome install locations by platform; puppeteer-core ships no browser. */
224
+ function chromeExecutable(): string {
225
+ if (process.platform === "darwin") {
226
+ return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
227
+ }
228
+ if (process.platform === "win32") {
229
+ return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
230
+ }
231
+ return "/usr/bin/google-chrome";
232
+ }
@@ -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;