@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 +6 -5
- package/src/App.tsx +13 -2
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +228 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +28 -0
- package/src/intent-dispatch.test.ts +1 -0
- package/src/prototype-browser.test.ts +55 -0
- package/src/prototype-browser.ts +218 -0
- package/src/session-controller.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
30
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
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
|
-
{
|
|
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
|
`;
|
|
@@ -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;
|