authorgravatar for 51714798+juliusmarminge@users.noreply.github.comJulius Marminge <51714798+juliusmarminge@users.noreply.github.com> 2026-06-12 17:17:00-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
log3d9e8dea7d0d7d775bfc617929d7bb2c5d3e651e
tree01889d8955f8f648d8adef5cab9fb3ccfbb67a3c
parentf7c422d5815e7be03415a87b30558c7bcf2cf8e9

Refactor desktop preview IPC onto shared manager

- Move preview session and IPC wiring into the new preview module - Tighten IPC validation with schema-based handlers - Update preview asset paths and tests for the browser preview port

45 files changed, 5275 insertions(+), 4502 deletions(-)

apps/desktop/scripts/build-preview-annotation-css.mjs+3-3
......@@ -7,9 +7,9 @@ import { compile } from "tailwindcss";
77
88const directory = dirname(fileURLToPath(import.meta.url));
99const appRoot = join(directory, "..");
10const sourcePath = join(appRoot, "src", "preview-annotation.css");
11const preloadPath = join(appRoot, "src", "preview-pick-preload.ts");
12const outputPath = join(appRoot, "src", "preview-annotation-styles.generated.ts");
10const sourcePath = join(appRoot, "src", "preview", "Annotation.css");
11const preloadPath = join(appRoot, "src", "preview", "PickPreload.ts");
12const outputPath = join(appRoot, "src", "preview", "AnnotationStyles.generated.ts");
1313const require = createRequire(import.meta.url);
1414const tailwindRoot = dirname(require.resolve("tailwindcss/package.json"));
1515
apps/desktop/src/app/DesktopApp.ts+1-1
......@@ -176,7 +176,7 @@ const bootstrap = Effect.gen(function* () {
176176 );
177177 }
178178
179 yield* installDesktopIpcHandlers;
179 yield* installDesktopIpcHandlers();
180180 yield* logBootstrapInfo("bootstrap ipc handlers registered");
181181
182182 if (!(yield* Ref.get(state.quitting))) {
apps/desktop/src/ipc/DesktopIpcHandlers.ts+5-8
......@@ -1,7 +1,5 @@
11import * as Effect from "effect/Effect";
22
3import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
4import { previewViewManager } from "../preview-view-manager.ts";
53import * as DesktopIpc from "./DesktopIpc.ts";
64import {
75 clearCloudAuthToken,
......@@ -50,12 +48,11 @@ import {
5048 setTheme,
5149 showContextMenu,
5250} from "./methods/window.ts";
53import { previewMethods } from "./methods/preview.ts";
51import * as PreviewIpc from "./methods/preview.ts";
5452
55export const installDesktopIpcHandlers = Effect.gen(function* () {
53export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
5654 const ipc = yield* DesktopIpc.DesktopIpc;
57 const environment = yield* DesktopEnvironment.DesktopEnvironment;
58 previewViewManager.configureArtifactDirectory(environment.browserArtifactsDir);
55 yield* PreviewIpc.installPreviewEventForwarding();
5956
6057 yield* ipc.handleSync(getAppBranding);
6158 yield* ipc.handleSync(getLocalEnvironmentBootstrap);
......@@ -97,7 +94,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () {
9794 yield* ipc.handle(downloadUpdate);
9895 yield* ipc.handle(installUpdate);
9996 yield* ipc.handle(checkForUpdate);
100 for (const previewMethod of previewMethods) {
97 for (const previewMethod of PreviewIpc.methods) {
10198 yield* ipc.handle(previewMethod);
10299 }
103}).pipe(Effect.withSpan("desktop.ipc.installHandlers"));
100});
apps/desktop/src/ipc/methods/preview.test.ts+29-3
......@@ -1,8 +1,19 @@
1import { it as effectIt } from "@effect/vitest";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
17import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
28
3const fromPartition = vi.fn(() => {
4 throw new Error("Session can only be received when app is ready");
5});
9import * as PreviewManager from "../../preview/Manager.ts";
10import * as PreviewIpc from "./preview.ts";
11
12const { fromPartition } = vi.hoisted(() => ({
13 fromPartition: vi.fn(() => {
14 throw new Error("Session can only be received when app is ready");
15 }),
16}));
617
718vi.mock("electron", () => ({
819 BrowserWindow: {
......@@ -25,4 +36,19 @@ describe("preview IPC methods", () => {
2536 await expect(import("./preview.ts")).resolves.toBeDefined();
2637 expect(fromPartition).not.toHaveBeenCalled();
2738 });
39
40 effectIt.effect("rejects invalid webContents ids before resolving the preview service", () =>
41 Effect.map(
42 PreviewIpc.registerWebview
43 .handler({ tabId: "tab-1", webContentsId: 0 })
44 .pipe(Effect.provideService(PreviewManager.PreviewManager, null as never), Effect.exit),
45 (exit) => {
46 expect(Exit.isFailure(exit)).toBe(true);
47 if (Exit.isSuccess(exit)) return;
48 const error = Cause.findErrorOption(exit.cause);
49 expect(Option.isSome(error) && Schema.isSchemaError(error.value)).toBe(true);
50 expect(fromPartition).not.toHaveBeenCalled();
51 },
52 ),
53 );
2854});
apps/desktop/src/ipc/methods/preview.ts+349-222
......@@ -1,251 +1,378 @@
11// @effect-diagnostics nodeBuiltinImport:off
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import type { DesktopPreviewAnnotationTheme } from "@t3tools/contracts";
2import {
3 DesktopPreviewAnnotationThemeInputSchema,
4 DesktopPreviewArtifactInputSchema,
5 DesktopPreviewAutomationClickInputSchema,
6 DesktopPreviewAutomationEvaluateInputSchema,
7 DesktopPreviewAutomationPressInputSchema,
8 DesktopPreviewAutomationScrollInputSchema,
9 DesktopPreviewAutomationTypeInputSchema,
10 DesktopPreviewAutomationWaitForInputSchema,
11 DesktopPreviewConfigInputSchema,
12 DesktopPreviewNavigateInputSchema,
13 DesktopPreviewRecordingArtifactSchema,
14 DesktopPreviewRecordingSaveInputSchema,
15 DesktopPreviewRegisterWebviewInputSchema,
16 DesktopPreviewScreenshotArtifactSchema,
17 DesktopPreviewTabInputSchema,
18 DesktopPreviewWebviewConfigSchema,
19 PreviewAnnotationPayloadSchema,
20 PreviewAutomationSnapshot,
21 PreviewAutomationStatus,
22} from "@t3tools/contracts";
523import { BrowserWindow } from "electron";
24import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";
626import { pathToFileURL } from "node:url";
727
8import { previewViewManager } from "../../preview-view-manager.ts";
9import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview-webview-preferences.ts";
28import * as PreviewManager from "../../preview/Manager.ts";
29import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";
1030import * as IpcChannels from "../channels.ts";
11import type { DesktopIpcMethod } from "../DesktopIpc.ts";
31import { makeIpcMethod } from "../DesktopIpc.ts";
1232
13previewViewManager.onStateChange((tabId, state) => {
33const broadcast = (channel: string, ...args: ReadonlyArray<unknown>): void => {
1434 for (const window of BrowserWindow.getAllWindows()) {
1535 if (!window.isDestroyed()) {
16 window.webContents.send(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state);
36 window.webContents.send(channel, ...args);
1737 }
1838 }
19});
39};
2040
21previewViewManager.onRecordingFrame((frame) => {
22 for (const window of BrowserWindow.getAllWindows()) {
23 if (!window.isDestroyed()) {
24 window.webContents.send(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame);
25 }
26 }
41export const installPreviewEventForwarding = Effect.fn(
42 "desktop.ipc.preview.installEventForwarding",
43)(function* () {
44 const manager = yield* PreviewManager.PreviewManager;
45 yield* manager.subscribeStateChanges((tabId, state) => {
46 broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state);
47 });
48 yield* manager.subscribeRecordingFrames((frame) => {
49 broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame);
50 });
51 yield* manager.subscribePointerEvents((event) => {
52 broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event);
53 });
2754});
2855
29previewViewManager.onPointerEvent((event) => {
30 for (const window of BrowserWindow.getAllWindows()) {
31 if (!window.isDestroyed()) {
32 window.webContents.send(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event);
33 }
34 }
56export const createTab = makeIpcMethod({
57 channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,
58 payload: DesktopPreviewTabInputSchema,
59 result: Schema.Void,
60 handler: Effect.fn("desktop.ipc.preview.createTab")(function* ({ tabId }) {
61 const manager = yield* PreviewManager.PreviewManager;
62 yield* manager.createTab(tabId);
63 }),
3564});
3665
37const tabIdFrom = (raw: unknown): string => {
38 if (typeof raw !== "object" || raw === null || !("tabId" in raw)) {
39 throw new Error("preview tab id is required");
40 }
41 const tabId = raw.tabId;
42 if (typeof tabId !== "string" || tabId.trim().length === 0) {
43 throw new Error("preview tab id must be a non-empty string");
44 }
45 return tabId;
46};
47
48const inputFrom = (raw: unknown): unknown => {
49 if (typeof raw !== "object" || raw === null || !("input" in raw)) {
50 throw new Error("preview automation input is required");
51 }
52 return raw.input;
53};
66export const closeTab = makeIpcMethod({
67 channel: IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL,
68 payload: DesktopPreviewTabInputSchema,
69 result: Schema.Void,
70 handler: Effect.fn("desktop.ipc.preview.closeTab")(function* ({ tabId }) {
71 const manager = yield* PreviewManager.PreviewManager;
72 yield* manager.closeTab(tabId);
73 }),
74});
5475
55const annotationThemeFrom = (raw: unknown): DesktopPreviewAnnotationTheme => {
56 if (typeof raw !== "object" || raw === null || !("theme" in raw)) {
57 throw new Error("preview annotation theme is required");
58 }
59 const theme = raw.theme;
60 if (typeof theme !== "object" || theme === null) {
61 throw new Error("preview annotation theme must be an object");
62 }
63 const record = theme as Record<string, unknown>;
64 const stringKeys = [
65 "radius",
66 "background",
67 "foreground",
68 "popover",
69 "popoverForeground",
70 "primary",
71 "primaryForeground",
72 "muted",
73 "mutedForeground",
74 "accent",
75 "accentForeground",
76 "border",
77 "input",
78 "ring",
79 "fontSans",
80 "fontMono",
81 ] as const;
82 for (const key of stringKeys) {
83 if (typeof record[key] !== "string" || record[key].length === 0) {
84 throw new Error(`preview annotation theme ${key} must be a non-empty string`);
85 }
86 }
87 if (record["colorScheme"] !== "light" && record["colorScheme"] !== "dark") {
88 throw new Error("preview annotation theme colorScheme must be light or dark");
89 }
90 return record as unknown as DesktopPreviewAnnotationTheme;
91};
76export const registerWebview = makeIpcMethod({
77 channel: IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL,
78 payload: DesktopPreviewRegisterWebviewInputSchema,
79 result: Schema.Void,
80 handler: Effect.fn("desktop.ipc.preview.registerWebview")(function* ({ tabId, webContentsId }) {
81 const manager = yield* PreviewManager.PreviewManager;
82 yield* manager.registerWebview(tabId, webContentsId);
83 }),
84});
9285
93class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{
94 readonly cause: unknown;
95}> {}
86export const navigate = makeIpcMethod({
87 channel: IpcChannels.PREVIEW_NAVIGATE_CHANNEL,
88 payload: DesktopPreviewNavigateInputSchema,
89 result: Schema.Void,
90 handler: Effect.fn("desktop.ipc.preview.navigate")(function* ({ tabId, url }) {
91 const manager = yield* PreviewManager.PreviewManager;
92 yield* manager.navigate(tabId, url);
93 }),
94});
9695
97const method = (
96const tabMethod = (
9897 channel: string,
99 handler: (raw: unknown) => unknown | Promise<unknown>,
100): DesktopIpcMethod<PreviewIpcError, never> => ({
101 channel,
102 handler: (raw) =>
103 Effect.tryPromise({
104 try: () => Promise.resolve(handler(raw)),
105 catch: (cause) => new PreviewIpcError({ cause }),
98 name: string,
99 invoke: (
100 manager: PreviewManager.PreviewManagerShape,
101 tabId: string,
102 ) => Effect.Effect<void, PreviewManager.PreviewManagerError>,
103) =>
104 makeIpcMethod({
105 channel,
106 payload: DesktopPreviewTabInputSchema,
107 result: Schema.Void,
108 handler: Effect.fn(name)(function* ({ tabId }) {
109 const manager = yield* PreviewManager.PreviewManager;
110 yield* invoke(manager, tabId);
106111 }),
112 });
113
114export const goBack = tabMethod(
115 IpcChannels.PREVIEW_GO_BACK_CHANNEL,
116 "desktop.ipc.preview.goBack",
117 (manager, tabId) => manager.goBack(tabId),
118);
119export const goForward = tabMethod(
120 IpcChannels.PREVIEW_GO_FORWARD_CHANNEL,
121 "desktop.ipc.preview.goForward",
122 (manager, tabId) => manager.goForward(tabId),
123);
124export const refresh = tabMethod(
125 IpcChannels.PREVIEW_REFRESH_CHANNEL,
126 "desktop.ipc.preview.refresh",
127 (manager, tabId) => manager.refresh(tabId),
128);
129export const zoomIn = tabMethod(
130 IpcChannels.PREVIEW_ZOOM_IN_CHANNEL,
131 "desktop.ipc.preview.zoomIn",
132 (manager, tabId) => manager.zoomIn(tabId),
133);
134export const zoomOut = tabMethod(
135 IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL,
136 "desktop.ipc.preview.zoomOut",
137 (manager, tabId) => manager.zoomOut(tabId),
138);
139export const resetZoom = tabMethod(
140 IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL,
141 "desktop.ipc.preview.resetZoom",
142 (manager, tabId) => manager.resetZoom(tabId),
143);
144export const hardReload = tabMethod(
145 IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL,
146 "desktop.ipc.preview.hardReload",
147 (manager, tabId) => manager.hardReload(tabId),
148);
149export const openDevTools = tabMethod(
150 IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL,
151 "desktop.ipc.preview.openDevTools",
152 (manager, tabId) => manager.openDevTools(tabId),
153);
154export const cancelPickElement = tabMethod(
155 IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL,
156 "desktop.ipc.preview.cancelPickElement",
157 (manager, tabId) => manager.cancelPickElement(tabId),
158);
159export const startRecording = tabMethod(
160 IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
161 "desktop.ipc.preview.startRecording",
162 (manager, tabId) => manager.startRecording(tabId),
163);
164export const stopRecording = tabMethod(
165 IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL,
166 "desktop.ipc.preview.stopRecording",
167 (manager, tabId) => manager.stopRecording(tabId),
168);
169
170export const clearCookies = makeIpcMethod({
171 channel: IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL,
172 payload: Schema.Void,
173 result: Schema.Void,
174 handler: Effect.fn("desktop.ipc.preview.clearCookies")(function* () {
175 const manager = yield* PreviewManager.PreviewManager;
176 yield* manager.clearCookies();
177 }),
107178});
108179
109export const previewMethods = [
110 method(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, (raw) =>
111 previewViewManager.createTab(tabIdFrom(raw)),
112 ),
113 method(IpcChannels.PREVIEW_CLOSE_TAB_CHANNEL, (raw) =>
114 previewViewManager.closeTab(tabIdFrom(raw)),
115 ),
116 method(IpcChannels.PREVIEW_REGISTER_WEBVIEW_CHANNEL, (raw) => {
117 const tabId = tabIdFrom(raw);
118 const webContentsId =
119 typeof raw === "object" && raw !== null && "webContentsId" in raw ? raw.webContentsId : null;
120 if (
121 typeof webContentsId !== "number" ||
122 !Number.isInteger(webContentsId) ||
123 webContentsId <= 0
124 ) {
125 throw new Error("preview webContentsId must be a positive integer");
126 }
127 return previewViewManager.registerWebview(tabId, webContentsId);
128 }),
129 method(IpcChannels.PREVIEW_NAVIGATE_CHANNEL, (raw) => {
130 const tabId = tabIdFrom(raw);
131 const url = typeof raw === "object" && raw !== null && "url" in raw ? raw.url : null;
132 if (typeof url !== "string") throw new Error("preview url must be a string");
133 return previewViewManager.navigate(tabId, url);
134 }),
135 method(IpcChannels.PREVIEW_GO_BACK_CHANNEL, (raw) => previewViewManager.goBack(tabIdFrom(raw))),
136 method(IpcChannels.PREVIEW_GO_FORWARD_CHANNEL, (raw) =>
137 previewViewManager.goForward(tabIdFrom(raw)),
138 ),
139 method(IpcChannels.PREVIEW_REFRESH_CHANNEL, (raw) => previewViewManager.refresh(tabIdFrom(raw))),
140 method(IpcChannels.PREVIEW_ZOOM_IN_CHANNEL, (raw) => previewViewManager.zoomIn(tabIdFrom(raw))),
141 method(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, (raw) => previewViewManager.zoomOut(tabIdFrom(raw))),
142 method(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, (raw) =>
143 previewViewManager.resetZoom(tabIdFrom(raw)),
144 ),
145 method(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, (raw) =>
146 previewViewManager.hardReload(tabIdFrom(raw)),
147 ),
148 method(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, (raw) =>
149 previewViewManager.openDevTools(tabIdFrom(raw)),
150 ),
151 method(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL, () => previewViewManager.clearCookies()),
152 method(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, () => previewViewManager.clearCache()),
153 method(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, (raw) => {
154 const environmentId =
155 typeof raw === "object" && raw !== null && "environmentId" in raw ? raw.environmentId : null;
156 if (typeof environmentId !== "string" || environmentId.length === 0) {
157 throw new Error("preview environment id is required");
158 }
159 previewViewManager.getBrowserSession(environmentId);
160 const preloadPath = `${__dirname}/preview-pick-preload.cjs`;
180export const clearCache = makeIpcMethod({
181 channel: IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL,
182 payload: Schema.Void,
183 result: Schema.Void,
184 handler: Effect.fn("desktop.ipc.preview.clearCache")(function* () {
185 const manager = yield* PreviewManager.PreviewManager;
186 yield* manager.clearCache();
187 }),
188});
189
190export const getPreviewConfig = makeIpcMethod({
191 channel: IpcChannels.PREVIEW_GET_CONFIG_CHANNEL,
192 payload: DesktopPreviewConfigInputSchema,
193 result: DesktopPreviewWebviewConfigSchema,
194 handler: Effect.fn("desktop.ipc.preview.getConfig")(function* ({ environmentId }) {
195 const manager = yield* PreviewManager.PreviewManager;
196 yield* manager.getBrowserSession(environmentId);
161197 return {
162 partition: previewViewManager.getBrowserPartition(environmentId),
198 partition: manager.getBrowserPartition(environmentId),
163199 webPreferences: PREVIEW_WEBVIEW_PREFERENCES,
164 preloadUrl: pathToFileURL(preloadPath).href,
200 preloadUrl: pathToFileURL(`${__dirname}/preview-pick-preload.cjs`).href,
165201 };
166202 }),
167 method(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, (raw) =>
168 previewViewManager.setAnnotationTheme(annotationThemeFrom(raw)),
169 ),
170 method(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, (raw) =>
171 previewViewManager.pickElement(tabIdFrom(raw)),
172 ),
173 method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) =>
174 previewViewManager.cancelPickElement(tabIdFrom(raw)),
175 ),
176 method(IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL, (raw) =>
177 previewViewManager.captureScreenshot(tabIdFrom(raw)),
178 ),
179 method(IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL, (raw) => {
180 const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null;
181 if (typeof path !== "string" || path.trim().length === 0) {
182 throw new Error("preview artifact path is required");
183 }
184 return previewViewManager.revealArtifact(path);
203});
204
205export const setAnnotationTheme = makeIpcMethod({
206 channel: IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL,
207 payload: DesktopPreviewAnnotationThemeInputSchema,
208 result: Schema.Void,
209 handler: Effect.fn("desktop.ipc.preview.setAnnotationTheme")(function* ({ theme }) {
210 const manager = yield* PreviewManager.PreviewManager;
211 yield* manager.setAnnotationTheme(theme);
185212 }),
186 method(IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL, (raw) => {
187 const path = typeof raw === "object" && raw !== null && "path" in raw ? raw.path : null;
188 if (typeof path !== "string" || path.trim().length === 0) {
189 throw new Error("preview artifact path is required");
190 }
191 return previewViewManager.copyArtifactToClipboard(path);
192 }),
193 method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) =>
194 previewViewManager.automationStatus(tabIdFrom(raw)),
195 ),
196 method(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, (raw) =>
197 previewViewManager.automationSnapshot(tabIdFrom(raw)),
198 ),
199 method(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, (raw) =>
200 previewViewManager.automationClick(
201 tabIdFrom(raw),
202 inputFrom(raw) as Parameters<typeof previewViewManager.automationClick>[1],
203 ),
204 ),
205 method(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, (raw) =>
206 previewViewManager.automationType(
207 tabIdFrom(raw),
208 inputFrom(raw) as Parameters<typeof previewViewManager.automationType>[1],
209 ),
210 ),
211 method(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, (raw) =>
212 previewViewManager.automationPress(
213 tabIdFrom(raw),
214 inputFrom(raw) as Parameters<typeof previewViewManager.automationPress>[1],
215 ),
216 ),
217 method(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, (raw) =>
218 previewViewManager.automationScroll(
219 tabIdFrom(raw),
220 inputFrom(raw) as Parameters<typeof previewViewManager.automationScroll>[1],
221 ),
222 ),
223 method(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, (raw) =>
224 previewViewManager.automationEvaluate(
225 tabIdFrom(raw),
226 inputFrom(raw) as Parameters<typeof previewViewManager.automationEvaluate>[1],
227 ),
228 ),
229 method(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, (raw) =>
230 previewViewManager.automationWaitFor(
231 tabIdFrom(raw),
232 inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1],
233 ),
234 ),
235 method(IpcChannels.PREVIEW_RECORDING_START_CHANNEL, (raw) =>
236 previewViewManager.startRecording(tabIdFrom(raw)),
237 ),
238 method(IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL, (raw) =>
239 previewViewManager.stopRecording(tabIdFrom(raw)),
240 ),
241 method(IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL, (raw) => {
242 const tabId = tabIdFrom(raw);
243 if (typeof raw !== "object" || raw === null) throw new Error("recording payload is required");
244 const mimeType = "mimeType" in raw ? raw.mimeType : null;
245 const data = "data" in raw ? raw.data : null;
246 if (typeof mimeType !== "string" || !(data instanceof Uint8Array)) {
247 throw new Error("recording mimeType and bytes are required");
248 }
249 return previewViewManager.saveRecording(tabId, mimeType, data);
213});
214
215export const pickElement = makeIpcMethod({
216 channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL,
217 payload: DesktopPreviewTabInputSchema,
218 result: Schema.NullOr(PreviewAnnotationPayloadSchema),
219 handler: Effect.fn("desktop.ipc.preview.pickElement")(function* ({ tabId }) {
220 const manager = yield* PreviewManager.PreviewManager;
221 return yield* manager.pickElement(tabId);
222 }),
223});
224
225export const captureScreenshot = makeIpcMethod({
226 channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL,
227 payload: DesktopPreviewTabInputSchema,
228 result: DesktopPreviewScreenshotArtifactSchema,
229 handler: Effect.fn("desktop.ipc.preview.captureScreenshot")(function* ({ tabId }) {
230 const manager = yield* PreviewManager.PreviewManager;
231 return yield* manager.captureScreenshot(tabId);
232 }),
233});
234
235export const revealArtifact = makeIpcMethod({
236 channel: IpcChannels.PREVIEW_REVEAL_ARTIFACT_CHANNEL,
237 payload: DesktopPreviewArtifactInputSchema,
238 result: Schema.Void,
239 handler: Effect.fn("desktop.ipc.preview.revealArtifact")(function* ({ path }) {
240 const manager = yield* PreviewManager.PreviewManager;
241 yield* manager.revealArtifact(path);
242 }),
243});
244
245export const copyArtifactToClipboard = makeIpcMethod({
246 channel: IpcChannels.PREVIEW_COPY_ARTIFACT_CHANNEL,
247 payload: DesktopPreviewArtifactInputSchema,
248 result: Schema.Void,
249 handler: Effect.fn("desktop.ipc.preview.copyArtifactToClipboard")(function* ({ path }) {
250 const manager = yield* PreviewManager.PreviewManager;
251 yield* manager.copyArtifactToClipboard(path);
252 }),
253});
254
255export const automationStatus = makeIpcMethod({
256 channel: IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL,
257 payload: DesktopPreviewTabInputSchema,
258 result: PreviewAutomationStatus,
259 handler: Effect.fn("desktop.ipc.preview.automationStatus")(function* ({ tabId }) {
260 const manager = yield* PreviewManager.PreviewManager;
261 return yield* manager.automationStatus(tabId);
262 }),
263});
264
265export const automationSnapshot = makeIpcMethod({
266 channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL,
267 payload: DesktopPreviewTabInputSchema,
268 result: PreviewAutomationSnapshot,
269 handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) {
270 const manager = yield* PreviewManager.PreviewManager;
271 return yield* manager.automationSnapshot(tabId);
272 }),
273});
274
275export const automationClick = makeIpcMethod({
276 channel: IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL,
277 payload: DesktopPreviewAutomationClickInputSchema,
278 result: Schema.Void,
279 handler: Effect.fn("desktop.ipc.preview.automationClick")(function* ({ tabId, input }) {
280 const manager = yield* PreviewManager.PreviewManager;
281 yield* manager.automationClick(tabId, input);
282 }),
283});
284
285export const automationType = makeIpcMethod({
286 channel: IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL,
287 payload: DesktopPreviewAutomationTypeInputSchema,
288 result: Schema.Void,
289 handler: Effect.fn("desktop.ipc.preview.automationType")(function* ({ tabId, input }) {
290 const manager = yield* PreviewManager.PreviewManager;
291 yield* manager.automationType(tabId, input);
250292 }),
293});
294
295export const automationPress = makeIpcMethod({
296 channel: IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL,
297 payload: DesktopPreviewAutomationPressInputSchema,
298 result: Schema.Void,
299 handler: Effect.fn("desktop.ipc.preview.automationPress")(function* ({ tabId, input }) {
300 const manager = yield* PreviewManager.PreviewManager;
301 yield* manager.automationPress(tabId, input);
302 }),
303});
304
305export const automationScroll = makeIpcMethod({
306 channel: IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL,
307 payload: DesktopPreviewAutomationScrollInputSchema,
308 result: Schema.Void,
309 handler: Effect.fn("desktop.ipc.preview.automationScroll")(function* ({ tabId, input }) {
310 const manager = yield* PreviewManager.PreviewManager;
311 yield* manager.automationScroll(tabId, input);
312 }),
313});
314
315export const automationEvaluate = makeIpcMethod({
316 channel: IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL,
317 payload: DesktopPreviewAutomationEvaluateInputSchema,
318 result: Schema.Unknown,
319 handler: Effect.fn("desktop.ipc.preview.automationEvaluate")(function* ({ tabId, input }) {
320 const manager = yield* PreviewManager.PreviewManager;
321 return yield* manager.automationEvaluate(tabId, input);
322 }),
323});
324
325export const automationWaitFor = makeIpcMethod({
326 channel: IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL,
327 payload: DesktopPreviewAutomationWaitForInputSchema,
328 result: Schema.Void,
329 handler: Effect.fn("desktop.ipc.preview.automationWaitFor")(function* ({ tabId, input }) {
330 const manager = yield* PreviewManager.PreviewManager;
331 yield* manager.automationWaitFor(tabId, input);
332 }),
333});
334
335export const saveRecording = makeIpcMethod({
336 channel: IpcChannels.PREVIEW_RECORDING_SAVE_CHANNEL,
337 payload: DesktopPreviewRecordingSaveInputSchema,
338 result: DesktopPreviewRecordingArtifactSchema,
339 handler: Effect.fn("desktop.ipc.preview.saveRecording")(function* ({ tabId, mimeType, data }) {
340 const manager = yield* PreviewManager.PreviewManager;
341 return yield* manager.saveRecording(tabId, mimeType, data);
342 }),
343});
344
345export const methods = [
346 createTab,
347 closeTab,
348 registerWebview,
349 navigate,
350 goBack,
351 goForward,
352 refresh,
353 zoomIn,
354 zoomOut,
355 resetZoom,
356 hardReload,
357 openDevTools,
358 clearCookies,
359 clearCache,
360 getPreviewConfig,
361 setAnnotationTheme,
362 pickElement,
363 cancelPickElement,
364 captureScreenshot,
365 revealArtifact,
366 copyArtifactToClipboard,
367 automationStatus,
368 automationSnapshot,
369 automationClick,
370 automationType,
371 automationPress,
372 automationScroll,
373 automationEvaluate,
374 automationWaitFor,
375 startRecording,
376 stopRecording,
377 saveRecording,
251378] as const;
apps/desktop/src/main.ts+11-1
......@@ -44,6 +44,8 @@ import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts";
4444import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts";
4545import * as DesktopState from "./app/DesktopState.ts";
4646import * as DesktopUpdates from "./updates/DesktopUpdates.ts";
47import * as PreviewBrowserSession from "./preview/BrowserSession.ts";
48import * as PreviewManager from "./preview/Manager.ts";
4749import * as DesktopWindow from "./window/DesktopWindow.ts";
4850
4951const desktopEnvironmentLayer = Layer.unwrap(
......@@ -127,7 +129,15 @@ const desktopServerExposureLayer = DesktopServerExposure.layer.pipe(
127129 Layer.provideMerge(desktopFoundationLayer),
128130);
129131
130const desktopWindowLayer = DesktopWindow.layer.pipe(Layer.provideMerge(desktopServerExposureLayer));
132const desktopPreviewLayer = PreviewManager.layer.pipe(
133 Layer.provideMerge(PreviewBrowserSession.layer),
134 Layer.provideMerge(desktopFoundationLayer),
135);
136
137const desktopWindowLayer = DesktopWindow.layer.pipe(
138 Layer.provideMerge(desktopServerExposureLayer),
139 Layer.provideMerge(desktopPreviewLayer),
140);
131141
132142const desktopBackendLayer = DesktopBackendManager.layer.pipe(
133143 Layer.provideMerge(DesktopAppIdentity.layer),
apps/desktop/src/picked-element-payload.test.ts deleted-201
......@@ -1,201 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isPickedElementPayload, isPreviewAnnotationPayload } from "./picked-element-payload.ts";
4
5function validPayload(overrides?: Record<string, unknown>): Record<string, unknown> {
6 return {
7 pageUrl: "https://example.com/",
8 pageTitle: "Example",
9 tagName: "button",
10 selector: "button.submit",
11 htmlPreview: "<button>Save</button>",
12 componentName: "SubmitButton",
13 source: {
14 functionName: "SubmitButton",
15 fileName: "/repo/src/Button.tsx",
16 lineNumber: 12,
17 columnNumber: 5,
18 },
19 stack: [
20 {
21 functionName: "SubmitButton",
22 fileName: "/repo/src/Button.tsx",
23 lineNumber: 12,
24 columnNumber: 5,
25 },
26 ],
27 styles: ".submit { color: white; }",
28 pickedAt: "2026-05-03T18:00:00.000Z",
29 ...overrides,
30 };
31}
32
33describe("isPickedElementPayload", () => {
34 it("accepts a complete, well-typed payload", () => {
35 expect(isPickedElementPayload(validPayload())).toBe(true);
36 });
37
38 it("accepts nullable string fields when null", () => {
39 expect(
40 isPickedElementPayload(
41 validPayload({ pageTitle: null, selector: null, componentName: null, source: null }),
42 ),
43 ).toBe(true);
44 });
45
46 it("accepts an empty stack array", () => {
47 expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true);
48 });
49
50 it("accepts stack frames with null fields", () => {
51 expect(
52 isPickedElementPayload(
53 validPayload({
54 stack: [
55 {
56 functionName: null,
57 fileName: null,
58 lineNumber: null,
59 columnNumber: null,
60 },
61 ],
62 }),
63 ),
64 ).toBe(true);
65 });
66
67 it("rejects null and primitive inputs", () => {
68 expect(isPickedElementPayload(null)).toBe(false);
69 expect(isPickedElementPayload(undefined)).toBe(false);
70 expect(isPickedElementPayload("string")).toBe(false);
71 expect(isPickedElementPayload(42)).toBe(false);
72 expect(isPickedElementPayload([])).toBe(false);
73 });
74
75 it.each<[string, Record<string, unknown>]>([
76 ["missing pageUrl", validPayload({ pageUrl: undefined })],
77 ["wrong-type pageUrl", validPayload({ pageUrl: 123 })],
78 ["missing tagName", validPayload({ tagName: undefined })],
79 ["missing htmlPreview", validPayload({ htmlPreview: undefined })],
80 ["missing styles", validPayload({ styles: undefined })],
81 ["missing pickedAt", validPayload({ pickedAt: undefined })],
82 ["wrong-type pageTitle", validPayload({ pageTitle: 99 })],
83 ["wrong-type selector", validPayload({ selector: 99 })],
84 ["wrong-type componentName", validPayload({ componentName: 99 })],
85 ])("rejects payloads with %s", (_label, value) => {
86 expect(isPickedElementPayload(value)).toBe(false);
87 });
88
89 it("rejects malformed source frames", () => {
90 expect(
91 isPickedElementPayload(
92 validPayload({
93 source: {
94 functionName: 0,
95 fileName: null,
96 lineNumber: null,
97 columnNumber: null,
98 },
99 }),
100 ),
101 ).toBe(false);
102 });
103
104 it("rejects non-finite numeric line/column numbers", () => {
105 expect(
106 isPickedElementPayload(
107 validPayload({
108 source: {
109 functionName: null,
110 fileName: null,
111 lineNumber: Number.POSITIVE_INFINITY,
112 columnNumber: null,
113 },
114 }),
115 ),
116 ).toBe(false);
117 expect(
118 isPickedElementPayload(
119 validPayload({
120 source: {
121 functionName: null,
122 fileName: null,
123 lineNumber: Number.NaN,
124 columnNumber: null,
125 },
126 }),
127 ),
128 ).toBe(false);
129 });
130
131 it("rejects malformed stack arrays", () => {
132 expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false);
133 expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false);
134 });
135});
136
137function validAnnotation(overrides?: Record<string, unknown>): Record<string, unknown> {
138 return {
139 id: "annotation_1",
140 pageUrl: "https://example.com/",
141 pageTitle: "Example",
142 comment: "Make this clearer",
143 elements: [
144 {
145 id: "element_1",
146 element: validPayload(),
147 rect: { x: 10, y: 20, width: 100, height: 40 },
148 },
149 ],
150 regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }],
151 strokes: [
152 {
153 id: "stroke_1",
154 color: "#7c3aed",
155 width: 4,
156 points: [
157 { x: 10, y: 10 },
158 { x: 20, y: 20 },
159 ],
160 bounds: { x: 6, y: 6, width: 18, height: 18 },
161 },
162 ],
163 styleChanges: [
164 {
165 targetId: "element_1",
166 selector: "button.submit",
167 property: "opacity",
168 previousValue: "1",
169 value: "0.5",
170 },
171 ],
172 screenshot: null,
173 createdAt: "2026-06-11T00:00:00.000Z",
174 ...overrides,
175 };
176}
177
178describe("isPreviewAnnotationPayload", () => {
179 it("accepts a structured annotation draft before screenshot capture", () => {
180 expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true);
181 });
182
183 it("rejects screenshots supplied by the guest preload", () => {
184 expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe(
185 false,
186 );
187 });
188
189 it("rejects malformed geometry and nested element payloads", () => {
190 expect(
191 isPreviewAnnotationPayload(
192 validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }),
193 ),
194 ).toBe(false);
195 expect(
196 isPreviewAnnotationPayload(
197 validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }),
198 ),
199 ).toBe(false);
200 });
201});
apps/desktop/src/picked-element-payload.ts deleted-146
......@@ -1,146 +0,0 @@
1/**
2 * Strict structural validator for `PickedElementPayload` messages received
3 * from the in-page picker preload (`apps/desktop/src/preview-pick-preload.ts`)
4 * via `wc.ipc`. Lives in its own electron-free module so the validator is
5 * trivially unit-testable.
6 *
7 * Validation must be tight: downstream `normalizeElementContextSelection`
8 * calls `.trim()` on incoming strings, so a malformed payload (preload bug,
9 * future schema mismatch, malicious page that intercepts the preload's IPC
10 * channel via prototype pollution) would otherwise throw deep in the
11 * renderer and the chip silently never appears.
12 */
13import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts";
14
15function isStringOrNull(value: unknown): value is string | null {
16 return value === null || typeof value === "string";
17}
18
19function isFiniteNumberOrNull(value: unknown): value is number | null {
20 return value === null || (typeof value === "number" && Number.isFinite(value));
21}
22
23function isPickedStackFrame(value: unknown): boolean {
24 if (typeof value !== "object" || value === null) return false;
25 const frame = value as Record<string, unknown>;
26 return (
27 isStringOrNull(frame["functionName"]) &&
28 isStringOrNull(frame["fileName"]) &&
29 isFiniteNumberOrNull(frame["lineNumber"]) &&
30 isFiniteNumberOrNull(frame["columnNumber"])
31 );
32}
33
34export function isPickedElementPayload(value: unknown): value is PickedElementPayload {
35 if (typeof value !== "object" || value === null) return false;
36 const c = value as Record<string, unknown>;
37 if (typeof c["pageUrl"] !== "string") return false;
38 if (typeof c["tagName"] !== "string") return false;
39 if (typeof c["htmlPreview"] !== "string") return false;
40 if (typeof c["styles"] !== "string") return false;
41 if (typeof c["pickedAt"] !== "string") return false;
42 if (!isStringOrNull(c["pageTitle"])) return false;
43 if (!isStringOrNull(c["selector"])) return false;
44 if (!isStringOrNull(c["componentName"])) return false;
45 if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false;
46 if (!Array.isArray(c["stack"])) return false;
47 if (!c["stack"].every(isPickedStackFrame)) return false;
48 return true;
49}
50
51function isRect(value: unknown): boolean {
52 if (typeof value !== "object" || value === null) return false;
53 const rect = value as Record<string, unknown>;
54 return ["x", "y", "width", "height"].every(
55 (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]),
56 );
57}
58
59function isPoint(value: unknown): boolean {
60 if (typeof value !== "object" || value === null) return false;
61 const point = value as Record<string, unknown>;
62 return (
63 typeof point["x"] === "number" &&
64 Number.isFinite(point["x"]) &&
65 typeof point["y"] === "number" &&
66 Number.isFinite(point["y"])
67 );
68}
69
70export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload {
71 if (typeof value !== "object" || value === null) return false;
72 const annotation = value as Record<string, unknown>;
73 if (typeof annotation["id"] !== "string") return false;
74 if (typeof annotation["pageUrl"] !== "string") return false;
75 if (!isStringOrNull(annotation["pageTitle"])) return false;
76 if (typeof annotation["comment"] !== "string") return false;
77 if (typeof annotation["createdAt"] !== "string") return false;
78 if (annotation["screenshot"] !== null) return false;
79
80 const elements = annotation["elements"];
81 if (!Array.isArray(elements)) return false;
82 if (
83 !elements.every((entry) => {
84 if (typeof entry !== "object" || entry === null) return false;
85 const target = entry as Record<string, unknown>;
86 return (
87 typeof target["id"] === "string" &&
88 isPickedElementPayload(target["element"]) &&
89 isRect(target["rect"])
90 );
91 })
92 ) {
93 return false;
94 }
95
96 const regions = annotation["regions"];
97 if (!Array.isArray(regions)) return false;
98 if (
99 !regions.every((entry) => {
100 if (typeof entry !== "object" || entry === null) return false;
101 const target = entry as Record<string, unknown>;
102 return typeof target["id"] === "string" && isRect(target["rect"]);
103 })
104 ) {
105 return false;
106 }
107
108 const strokes = annotation["strokes"];
109 if (!Array.isArray(strokes)) return false;
110 if (
111 !strokes.every((entry) => {
112 if (typeof entry !== "object" || entry === null) return false;
113 const target = entry as Record<string, unknown>;
114 return (
115 typeof target["id"] === "string" &&
116 typeof target["color"] === "string" &&
117 typeof target["width"] === "number" &&
118 Number.isFinite(target["width"]) &&
119 Array.isArray(target["points"]) &&
120 target["points"].every(isPoint) &&
121 isRect(target["bounds"])
122 );
123 })
124 ) {
125 return false;
126 }
127
128 const styleChanges = annotation["styleChanges"];
129 if (!Array.isArray(styleChanges)) return false;
130 if (
131 !styleChanges.every((entry) => {
132 if (typeof entry !== "object" || entry === null) return false;
133 const change = entry as Record<string, unknown>;
134 return (
135 typeof change["targetId"] === "string" &&
136 isStringOrNull(change["selector"]) &&
137 typeof change["property"] === "string" &&
138 typeof change["previousValue"] === "string" &&
139 typeof change["value"] === "string"
140 );
141 })
142 ) {
143 return false;
144 }
145 return true;
146}
apps/desktop/src/playwright-injected-runtime.test.ts deleted-20
......@@ -1,20 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 playwrightInjectedRuntimeInstallExpression,
5 playwrightInjectedRuntimeSource,
6} from "./playwright-injected-runtime.ts";
7
8describe("playwright injected runtime", () => {
9 it("extracts the pinned runtime from playwright-core", async () => {
10 const source = await playwrightInjectedRuntimeSource();
11 expect(source.length).toBeGreaterThan(100_000);
12 expect(source).toContain("InjectedScript");
13 });
14
15 it("builds an idempotent install expression", async () => {
16 const expression = await playwrightInjectedRuntimeInstallExpression();
17 expect(expression).toContain("__t3PlaywrightInjected");
18 expect(expression).toContain('testIdAttributeName":"data-testid');
19 });
20});
apps/desktop/src/playwright-injected-runtime.ts deleted-49
......@@ -1,49 +0,0 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import { createRequire } from "node:module";
3import { dirname, join } from "node:path";
4import { readFile } from "node:fs/promises";
5import { runInNewContext } from "node:vm";
6
7const require = createRequire(import.meta.url);
8let sourcePromise: Promise<string> | null = null;
9
10export const playwrightInjectedRuntimeSource = (): Promise<string> => {
11 sourcePromise ??= (async () => {
12 const packageJsonPath = require.resolve("playwright-core/package.json");
13 const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8");
14 const marker = "source3 = ";
15 const start = coreBundle.indexOf(marker);
16 if (start < 0) throw new Error("Playwright injected runtime marker was not found.");
17 const literalStart = start + marker.length;
18 const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart);
19 if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found.");
20 const literal = coreBundle.slice(literalStart, literalEnd);
21 const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 });
22 if (typeof source !== "string" || source.length < 100_000) {
23 throw new Error("Playwright injected runtime extraction returned invalid source.");
24 }
25 return source;
26 })();
27 return sourcePromise;
28};
29
30export const playwrightInjectedRuntimeInstallExpression = async (): Promise<string> => {
31 const source = await playwrightInjectedRuntimeSource();
32 const options = {
33 isUnderTest: false,
34 sdkLanguage: "javascript",
35 testIdAttributeName: "data-testid",
36 stableRafCount: 1,
37 browserName: "chromium",
38 shouldPrependErrorPrefix: false,
39 isUtilityWorld: false,
40 customEngines: [],
41 };
42 return `(() => {
43 if (globalThis.__t3PlaywrightInjected) return true;
44 const module = { exports: {} };
45 ${source}
46 globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)});
47 return true;
48 })()`;
49};
apps/desktop/src/preview-annotation-styles.generated.ts deleted-3
......@@ -1,3 +0,0 @@
1// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.
2export const previewAnnotationStyles =
3 '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "<length>";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n';
apps/desktop/src/preview-annotation.css deleted-68
......@@ -1,68 +0,0 @@
1@import "tailwindcss";
2
3@theme inline {
4 --font-sans: var(--t3-font-sans);
5 --font-mono: var(--t3-font-mono);
6 --color-background: var(--t3-background);
7 --color-foreground: var(--t3-foreground);
8 --color-popover: var(--t3-popover);
9 --color-popover-foreground: var(--t3-popover-foreground);
10 --color-primary: var(--t3-primary);
11 --color-primary-foreground: var(--t3-primary-foreground);
12 --color-muted: var(--t3-muted);
13 --color-muted-foreground: var(--t3-muted-foreground);
14 --color-accent: var(--t3-accent);
15 --color-accent-foreground: var(--t3-accent-foreground);
16 --color-border: var(--t3-border);
17 --color-input: var(--t3-input);
18 --color-ring: var(--t3-ring);
19 --radius-sm: calc(var(--t3-radius) - 4px);
20 --radius-md: calc(var(--t3-radius) - 2px);
21 --radius-lg: var(--t3-radius);
22 --radius-xl: calc(var(--t3-radius) + 4px);
23 --radius-2xl: calc(var(--t3-radius) + 8px);
24}
25
26:host {
27 --t3-font-sans:
28 "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
29 sans-serif;
30 --t3-font-mono:
31 "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;
32 --t3-radius: 0.625rem;
33 --t3-background: white;
34 --t3-foreground: oklch(0.269 0 0);
35 --t3-popover: white;
36 --t3-popover-foreground: oklch(0.269 0 0);
37 --t3-primary: oklch(0.488 0.217 264);
38 --t3-primary-foreground: white;
39 --t3-muted: rgb(0 0 0 / 4%);
40 --t3-muted-foreground: oklch(0.556 0 0);
41 --t3-accent: rgb(0 0 0 / 4%);
42 --t3-accent-foreground: oklch(0.269 0 0);
43 --t3-border: rgb(0 0 0 / 8%);
44 --t3-input: rgb(0 0 0 / 10%);
45 --t3-ring: oklch(0.488 0.217 264);
46 color: var(--t3-foreground);
47 font-family: var(--t3-font-sans);
48}
49
50* {
51 box-sizing: border-box;
52 border-color: var(--t3-border);
53}
54
55button,
56input,
57select,
58textarea {
59 font: inherit;
60}
61
62button:focus-visible,
63input:focus-visible,
64select:focus-visible,
65textarea:focus-visible {
66 outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);
67 outline-offset: 1px;
68}
apps/desktop/src/preview-pick-label-position.ts deleted-46
......@@ -1,46 +0,0 @@
1/**
2 * Pure clamp/flip math for the floating label that follows the cursor while
3 * the user is picking an element in the in-app browser. Lives in its own
4 * electron-free module so the geometry can be unit-tested without spinning
5 * up an Electron preload context (`preview-pick-preload.ts` itself imports
6 * `electron` and `react-grab/primitives`, which can't load under vitest).
7 *
8 * - Horizontally pins the label to `targetLeft`, clamped into
9 * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`.
10 * - Vertically prefers above the target. If the label would overflow the
11 * top, flips below; if THAT also overflows the bottom, pins to the
12 * bottom margin (better to overlap the highlight than disappear).
13 */
14
15/** Distance in CSS pixels between the highlight and the floating label. */
16export const LABEL_GAP = 4;
17/** Minimum padding the label keeps from any viewport edge. */
18export const VIEWPORT_MARGIN = 4;
19
20export function computeLabelPosition(input: {
21 targetLeft: number;
22 targetTop: number;
23 targetBottom: number;
24 labelWidth: number;
25 labelHeight: number;
26 viewportWidth: number;
27 viewportHeight: number;
28}): { x: number; y: number } {
29 const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input;
30 const { viewportWidth, viewportHeight } = input;
31
32 let x = targetLeft;
33 const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN;
34 if (x > maxX) x = maxX;
35 if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN;
36
37 let y = targetTop - labelHeight - LABEL_GAP;
38 if (y < VIEWPORT_MARGIN) {
39 y = targetBottom + LABEL_GAP;
40 if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) {
41 y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN);
42 }
43 }
44
45 return { x, y };
46}
apps/desktop/src/preview-pick-preload.test.ts deleted-86
......@@ -1,86 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { computeLabelPosition } from "./preview-pick-label-position.ts";
4
5const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 };
6
7describe("computeLabelPosition", () => {
8 it("anchors to the element's top-left when there's room above and to the right", () => {
9 const { x, y } = computeLabelPosition({
10 ...VIEWPORT,
11 targetLeft: 200,
12 targetTop: 200,
13 targetBottom: 240,
14 labelWidth: 120,
15 labelHeight: 18,
16 });
17 expect(x).toBe(200);
18 // 200 (top) - 18 (height) - 4 (gap)
19 expect(y).toBe(200 - 18 - 4);
20 });
21
22 it("clamps left edge so the label stays inside the viewport", () => {
23 const { x } = computeLabelPosition({
24 ...VIEWPORT,
25 targetLeft: -50,
26 targetTop: 200,
27 targetBottom: 240,
28 labelWidth: 120,
29 labelHeight: 18,
30 });
31 expect(x).toBe(4);
32 });
33
34 it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => {
35 const { x } = computeLabelPosition({
36 ...VIEWPORT,
37 targetLeft: 1240,
38 targetTop: 200,
39 targetBottom: 240,
40 labelWidth: 200,
41 labelHeight: 18,
42 });
43 // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076
44 expect(x).toBe(1076);
45 });
46
47 it("flips the label below the element when there's no room above", () => {
48 const { y } = computeLabelPosition({
49 ...VIEWPORT,
50 targetLeft: 200,
51 targetTop: 4,
52 targetBottom: 44,
53 labelWidth: 120,
54 labelHeight: 18,
55 });
56 // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48
57 expect(y).toBe(48);
58 });
59
60 it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => {
61 const { y } = computeLabelPosition({
62 ...VIEWPORT,
63 targetLeft: 200,
64 targetTop: 0,
65 targetBottom: 800,
66 labelWidth: 120,
67 labelHeight: 18,
68 });
69 // Above overflows top → flip below = 800 + 4 = 804 → also overflows
70 // bottom → pin to viewportHeight - labelHeight - margin = 778.
71 expect(y).toBe(800 - 18 - 4);
72 });
73
74 it("never returns a negative coordinate", () => {
75 const { x, y } = computeLabelPosition({
76 ...VIEWPORT,
77 targetLeft: -1000,
78 targetTop: -1000,
79 targetBottom: -900,
80 labelWidth: 5000,
81 labelHeight: 5000,
82 });
83 expect(x).toBeGreaterThanOrEqual(0);
84 expect(y).toBeGreaterThanOrEqual(0);
85 });
86});
apps/desktop/src/preview-pick-preload.ts+1-1262
......@@ -1,1262 +1 @@
1// @effect-diagnostics globalDate:off
2import { ipcRenderer } from "electron";
3import { getElementContext } from "react-grab/primitives";
4import type {
5 DesktopPreviewAnnotationTheme,
6 PickedElementPayload,
7 PickedElementStackFrame,
8 PreviewAnnotationPayload,
9 PreviewAnnotationPoint,
10 PreviewAnnotationRect,
11 PreviewAnnotationRegionTarget,
12 PreviewAnnotationStrokeTarget,
13 PreviewAnnotationStyleChange,
14} from "@t3tools/contracts";
15
16import { previewAnnotationStyles } from "./preview-annotation-styles.generated.ts";
17
18const START_PICK_CHANNEL = "preview:start-pick";
19const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
20const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
21const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
22const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
23const HUMAN_INPUT_CHANNEL = "preview:human-input";
24const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
25const Z_INDEX_OVERLAY = 2147483646;
26const PRIMARY = "var(--t3-primary)";
27const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)";
28const MAX_MARQUEE_ELEMENTS = 20;
29const CONTENT_LAYER_Z_INDEX = 1;
30const CHROME_LAYER_Z_INDEX = 10;
31
32type AnnotationTool = "select" | "marquee" | "draw" | "erase";
33
34interface SelectedElement {
35 id: string;
36 element: Element;
37 outline: HTMLDivElement;
38 label: HTMLDivElement;
39 baselineStyles: Map<string, string>;
40}
41
42interface AnnotationSession {
43 teardown: (notifyMain: boolean) => void;
44 applyTheme: (theme: DesktopPreviewAnnotationTheme) => void;
45}
46
47let activeSession: AnnotationSession | null = null;
48let idSequence = 0;
49let annotationTheme: DesktopPreviewAnnotationTheme | null = null;
50
51const applyAnnotationTheme = (
52 host: HTMLElement,
53 theme: DesktopPreviewAnnotationTheme | null,
54): void => {
55 if (!theme) return;
56 host.style.colorScheme = theme.colorScheme;
57 const variables = {
58 "--t3-radius": theme.radius,
59 "--t3-background": theme.background,
60 "--t3-foreground": theme.foreground,
61 "--t3-popover": theme.popover,
62 "--t3-popover-foreground": theme.popoverForeground,
63 "--t3-primary": theme.primary,
64 "--t3-primary-foreground": theme.primaryForeground,
65 "--t3-muted": theme.muted,
66 "--t3-muted-foreground": theme.mutedForeground,
67 "--t3-accent": theme.accent,
68 "--t3-accent-foreground": theme.accentForeground,
69 "--t3-border": theme.border,
70 "--t3-input": theme.input,
71 "--t3-ring": theme.ring,
72 "--t3-font-sans": theme.fontSans,
73 "--t3-font-mono": theme.fontMono,
74 };
75 for (const [name, value] of Object.entries(variables)) {
76 host.style.setProperty(name, value);
77 }
78};
79
80const reportHumanPointerInput = (event: PointerEvent): void => {
81 if (!event.isTrusted) return;
82 ipcRenderer.send(HUMAN_INPUT_CHANNEL, {
83 kind: "pointer",
84 x: event.clientX,
85 y: event.clientY,
86 button: event.button,
87 });
88};
89
90const reportHumanKeyInput = (event: KeyboardEvent): void => {
91 if (!event.isTrusted) return;
92 ipcRenderer.send(HUMAN_INPUT_CHANNEL, {
93 kind: "key",
94 key: event.key,
95 code: event.code,
96 });
97};
98
99window.addEventListener("pointerdown", reportHumanPointerInput, true);
100window.addEventListener("keydown", reportHumanKeyInput, true);
101
102const nextId = (prefix: string): string => {
103 idSequence += 1;
104 return `${prefix}_${idSequence.toString(36)}`;
105};
106
107const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({
108 x: rect.left,
109 y: rect.top,
110 width: rect.width,
111 height: rect.height,
112});
113
114const normalizeRect = (
115 startX: number,
116 startY: number,
117 endX: number,
118 endY: number,
119): PreviewAnnotationRect => ({
120 x: Math.min(startX, endX),
121 y: Math.min(startY, endY),
122 width: Math.abs(endX - startX),
123 height: Math.abs(endY - startY),
124});
125
126const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3;
127
128function unionRects(
129 rects: ReadonlyArray<PreviewAnnotationRect>,
130 padding = 20,
131): PreviewAnnotationRect | null {
132 if (rects.length === 0) return null;
133 const left = Math.min(...rects.map((rect) => rect.x));
134 const top = Math.min(...rects.map((rect) => rect.y));
135 const right = Math.max(...rects.map((rect) => rect.x + rect.width));
136 const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
137 const x = Math.max(0, left - padding);
138 const y = Math.max(0, top - padding);
139 const maxWidth = Math.max(1, window.innerWidth - x);
140 const maxHeight = Math.max(1, window.innerHeight - y);
141 return {
142 x,
143 y,
144 width: Math.min(maxWidth, right - left + padding * 2),
145 height: Math.min(maxHeight, bottom - top + padding * 2),
146 };
147}
148
149function isAnnotationNode(element: Element): boolean {
150 return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null;
151}
152
153function pickFromPoint(clientX: number, clientY: number): Element | null {
154 for (const candidate of document.elementsFromPoint(clientX, clientY)) {
155 if (!(candidate instanceof Element)) continue;
156 if (isAnnotationNode(candidate)) continue;
157 if (candidate === document.documentElement || candidate === document.body) continue;
158 return candidate;
159 }
160 return null;
161}
162
163function describeRawElement(element: Element): string {
164 const tag = element.tagName.toLowerCase();
165 const id = element.id ? `#${element.id}` : "";
166 const classes =
167 element instanceof HTMLElement && typeof element.className === "string"
168 ? element.className
169 .trim()
170 .split(/\s+/)
171 .filter(Boolean)
172 .slice(0, 2)
173 .map((name) => `.${name}`)
174 .join("")
175 : "";
176 return `${tag}${id}${classes}`;
177}
178
179function createBox(color: string, fill: string): HTMLDivElement {
180 const node = document.createElement("div");
181 node.setAttribute(OVERLAY_ATTRIBUTE, "");
182 node.style.cssText = [
183 "position:fixed",
184 "pointer-events:none",
185 `border:2px solid ${color}`,
186 `background:${fill}`,
187 "border-radius:3px",
188 "box-sizing:border-box",
189 "display:none",
190 `z-index:${CONTENT_LAYER_Z_INDEX}`,
191 ].join(";");
192 return node;
193}
194
195function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void {
196 node.style.display = "block";
197 node.style.transform = `translate(${rect.x}px, ${rect.y}px)`;
198 node.style.width = `${rect.width}px`;
199 node.style.height = `${rect.height}px`;
200}
201
202function createLabel(): HTMLDivElement {
203 const label = document.createElement("div");
204 label.setAttribute(OVERLAY_ATTRIBUTE, "");
205 label.className =
206 "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md";
207 label.style.cssText = [
208 "position:fixed",
209 "pointer-events:none",
210 "white-space:nowrap",
211 "text-overflow:ellipsis",
212 `z-index:${CONTENT_LAYER_Z_INDEX}`,
213 ].join(";");
214 return label;
215}
216
217function updateSelectedVisual(target: SelectedElement): void {
218 if (!target.element.isConnected) {
219 target.outline.style.display = "none";
220 target.label.style.display = "none";
221 return;
222 }
223 const rect = target.element.getBoundingClientRect();
224 positionBox(target.outline, rectFromDomRect(rect));
225 target.label.textContent = describeRawElement(target.element);
226 target.label.style.display = "block";
227 target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`;
228}
229
230function toStackFrame(frame: {
231 functionName?: string;
232 fileName?: string;
233 lineNumber?: number;
234 columnNumber?: number;
235}): PickedElementStackFrame {
236 return {
237 functionName: frame.functionName ?? null,
238 fileName: frame.fileName ?? null,
239 lineNumber: frame.lineNumber ?? null,
240 columnNumber: frame.columnNumber ?? null,
241 };
242}
243
244async function captureElement(element: Element): Promise<PickedElementPayload | null> {
245 try {
246 const context = await getElementContext(element);
247 const stack = (context.stack ?? []).map(toStackFrame);
248 return {
249 pageUrl: location.href,
250 pageTitle: document.title?.trim() || null,
251 tagName: element.tagName.toLowerCase(),
252 selector: context.selector,
253 htmlPreview: context.htmlPreview ?? "",
254 componentName: context.componentName,
255 source: stack[0] ?? null,
256 stack,
257 styles: context.styles ?? "",
258 pickedAt: new Date().toISOString(),
259 };
260 } catch {
261 return null;
262 }
263}
264
265function createButton(label: string, title: string): HTMLButtonElement {
266 const button = document.createElement("button");
267 button.type = "button";
268 button.textContent = label;
269 button.title = title;
270 button.className =
271 "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60";
272 return button;
273}
274
275function styleControl(input: HTMLInputElement | HTMLSelectElement): void {
276 input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value");
277 input.className =
278 "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none";
279}
280
281function createUnitControl(input: HTMLInputElement): HTMLElement {
282 const wrapper = document.createElement("div");
283 wrapper.style.cssText = "position:relative;min-width:0";
284 const unit = document.createElement("span");
285 unit.textContent = input.dataset.unit ?? "";
286 unit.className =
287 "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground";
288 wrapper.append(input, unit);
289 return wrapper;
290}
291
292function createField(
293 labelText: string,
294 input: HTMLInputElement | HTMLSelectElement,
295): HTMLLabelElement {
296 const label = document.createElement("label");
297 label.className =
298 "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground";
299 const text = document.createElement("span");
300 text.textContent = labelText;
301 styleControl(input);
302 label.append(
303 text,
304 input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input,
305 );
306 return label;
307}
308
309function createStyleSection(): HTMLElement {
310 const section = document.createElement("section");
311 section.className = "grid gap-1 border-t border-border py-2";
312 return section;
313}
314
315function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement {
316 const input = document.createElement("input");
317 input.type = "number";
318 input.placeholder = placeholder;
319 input.style.paddingRight = "30px";
320 input.dataset.unit = unit;
321 return input;
322}
323
324function pathFromPoints(points: ReadonlyArray<PreviewAnnotationPoint>): string {
325 if (points.length === 0) return "";
326 if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`;
327 let path = `M ${points[0]!.x} ${points[0]!.y}`;
328 for (let index = 1; index < points.length - 1; index += 1) {
329 const current = points[index]!;
330 const next = points[index + 1]!;
331 path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`;
332 }
333 const last = points[points.length - 1]!;
334 path += ` L ${last.x} ${last.y}`;
335 return path;
336}
337
338function strokeBounds(
339 points: ReadonlyArray<PreviewAnnotationPoint>,
340 width: number,
341): PreviewAnnotationRect {
342 const xs = points.map((point) => point.x);
343 const ys = points.map((point) => point.y);
344 const padding = width + 3;
345 const left = Math.min(...xs) - padding;
346 const top = Math.min(...ys) - padding;
347 const right = Math.max(...xs) + padding;
348 const bottom = Math.max(...ys) + padding;
349 return { x: left, y: top, width: right - left, height: bottom - top };
350}
351
352function startAnnotation(): void {
353 activeSession?.teardown(false);
354 let finished = false;
355 const host = document.createElement("div");
356 host.setAttribute(OVERLAY_ATTRIBUTE, "");
357 host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`;
358 applyAnnotationTheme(host, annotationTheme);
359 const shadowRoot = host.attachShadow({ mode: "closed" });
360 const themeStyle = document.createElement("style");
361 themeStyle.textContent = previewAnnotationStyles;
362 shadowRoot.appendChild(themeStyle);
363
364 const root = document.createElement("div");
365 root.setAttribute(OVERLAY_ATTRIBUTE, "");
366 root.className = "fixed inset-0 font-sans text-foreground";
367 root.style.cssText = "pointer-events:none";
368 const cursorStyle = document.createElement("style");
369 cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, "");
370 cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`;
371 document.documentElement.appendChild(cursorStyle);
372 shadowRoot.appendChild(root);
373
374 const hoverOutline = createBox(PRIMARY, PRIMARY_FILL);
375 const marqueeBox = createBox(PRIMARY, PRIMARY_FILL);
376 root.append(hoverOutline, marqueeBox);
377
378 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
379 svg.setAttribute(OVERLAY_ATTRIBUTE, "");
380 svg.setAttribute("width", "100%");
381 svg.setAttribute("height", "100%");
382 svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
383 svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none";
384 svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX);
385 root.appendChild(svg);
386
387 const toolbar = document.createElement("div");
388 toolbar.setAttribute(OVERLAY_ATTRIBUTE, "");
389 toolbar.className =
390 "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl";
391 toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX);
392 root.appendChild(toolbar);
393
394 const editor = document.createElement("div");
395 editor.setAttribute(OVERLAY_ATTRIBUTE, "");
396 editor.className =
397 "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl";
398 editor.style.zIndex = String(CHROME_LAYER_Z_INDEX);
399 root.appendChild(editor);
400
401 const composerRow = document.createElement("div");
402 composerRow.className = "flex items-start gap-2 p-2";
403
404 const adjust = createButton("", "Expand annotation editor");
405 adjust.setAttribute("aria-label", "Expand annotation editor");
406 adjust.setAttribute("aria-expanded", "false");
407 adjust.className +=
408 " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground";
409 adjust.innerHTML =
410 '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12M7 3v4M13 8v4M9 13v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
411 composerRow.appendChild(adjust);
412
413 const comment = document.createElement("textarea");
414 comment.placeholder = "Describe the change…";
415 comment.rows = 1;
416 comment.className =
417 "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0";
418 composerRow.appendChild(comment);
419
420 const dragHandle = document.createElement("button");
421 dragHandle.type = "button";
422 dragHandle.textContent = "â ¿";
423 dragHandle.title = "Drag annotation editor";
424 dragHandle.className =
425 "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground";
426 composerRow.appendChild(dragHandle);
427
428 const submit = createButton("Attach", "Attach annotation and screenshot");
429 submit.className +=
430 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";
431 composerRow.appendChild(submit);
432 editor.appendChild(composerRow);
433
434 const stylePanel = document.createElement("div");
435 stylePanel.className =
436 "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3";
437 editor.appendChild(stylePanel);
438
439 const selected = new Map<Element, SelectedElement>();
440 const regions: PreviewAnnotationRegionTarget[] = [];
441 const strokes: PreviewAnnotationStrokeTarget[] = [];
442 const styleChanges = new Map<string, PreviewAnnotationStyleChange>();
443 const toolButtons = new Map<AnnotationTool, HTMLButtonElement>();
444 let tool: AnnotationTool = "select";
445 let dragStart: PreviewAnnotationPoint | null = null;
446 let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null;
447 let pendingCapture = false;
448 let editorExpanded = false;
449 let editorWasShown = false;
450 let editorPosition: { left: number; top: number } | null = null;
451 let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null;
452 let editorLayoutFrame: number | null = null;
453
454 const resizeComment = (): void => {
455 const maxHeight = 96;
456 comment.style.height = "auto";
457 const nextHeight = Math.min(comment.scrollHeight, maxHeight);
458 comment.style.height = `${nextHeight}px`;
459 comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden";
460 queueEditorLayout();
461 };
462 comment.addEventListener("input", resizeComment);
463
464 const updateStatus = (): void => {
465 const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0;
466 editor.style.display = hasTargets ? "flex" : "none";
467 submit.disabled = !hasTargets;
468 submit.style.opacity = hasTargets ? "1" : "0.45";
469 adjust.disabled = !hasTargets;
470 stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none";
471 queueEditorLayout();
472 if (hasTargets && !editorWasShown) {
473 editorWasShown = true;
474 window.setTimeout(() => comment.focus({ preventScroll: true }), 0);
475 }
476 };
477
478 const refreshToolButtons = (): void => {
479 for (const [candidate, button] of toolButtons) {
480 const active = candidate === tool;
481 button.classList.toggle("bg-primary/10", active);
482 button.classList.toggle("text-primary", active);
483 button.classList.toggle("text-foreground", !active);
484 }
485 if (tool !== "select") hoverOutline.style.display = "none";
486 if (tool !== "marquee") marqueeBox.style.display = "none";
487 document.documentElement.setAttribute("data-t3code-annotation-tool", tool);
488 };
489
490 const removeSelected = (target: SelectedElement): void => {
491 if (target.element instanceof HTMLElement || target.element instanceof SVGElement) {
492 for (const [property, baseline] of target.baselineStyles) {
493 if (baseline) target.element.style.setProperty(property, baseline);
494 else target.element.style.removeProperty(property);
495 }
496 }
497 selected.delete(target.element);
498 target.outline.remove();
499 target.label.remove();
500 for (const [key, change] of styleChanges) {
501 if (change.targetId === target.id) styleChanges.delete(key);
502 }
503 updateStatus();
504 };
505
506 const addSelected = (element: Element): void => {
507 if (selected.has(element)) return;
508 const target: SelectedElement = {
509 id: nextId("element"),
510 element,
511 outline: createBox(PRIMARY, PRIMARY_FILL),
512 label: createLabel(),
513 baselineStyles: new Map(),
514 };
515 selected.set(element, target);
516 root.append(target.outline, target.label);
517 updateSelectedVisual(target);
518 updateStatus();
519 if (editorExpanded) {
520 stylePanel.style.display = "grid";
521 syncStyleControls();
522 }
523 };
524
525 const toggleSelected = (element: Element, additive: boolean): void => {
526 const existing = selected.get(element);
527 if (existing) {
528 removeSelected(existing);
529 return;
530 }
531 if (!additive) {
532 for (const target of Array.from(selected.values())) removeSelected(target);
533 }
534 addSelected(element);
535 };
536
537 const setStyleForSelected = (property: string, value: string): void => {
538 for (const target of selected.values()) {
539 if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement))
540 continue;
541 if (!target.baselineStyles.has(property)) {
542 target.baselineStyles.set(property, target.element.style.getPropertyValue(property));
543 }
544 const key = `${target.id}:${property}`;
545 const previousValue =
546 styleChanges.get(key)?.previousValue ??
547 getComputedStyle(target.element).getPropertyValue(property).trim();
548 target.element.style.setProperty(property, value, "important");
549 styleChanges.set(key, {
550 targetId: target.id,
551 selector: null,
552 property,
553 previousValue,
554 value,
555 });
556 updateSelectedVisual(target);
557 }
558 };
559
560 const textSection = createStyleSection();
561 const colorsSection = createStyleSection();
562 const bordersSection = createStyleSection();
563 const sizingSection = createStyleSection();
564 stylePanel.append(textSection, colorsSection, bordersSection, sizingSection);
565
566 const fontFamily = document.createElement("select");
567 for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) {
568 const option = document.createElement("option");
569 option.value = value;
570 option.textContent = value;
571 fontFamily.appendChild(option);
572 }
573 fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value));
574 textSection.appendChild(createField("Font", fontFamily));
575
576 const fontSize = createUnitInput("px", "16");
577 fontSize.min = "1";
578 fontSize.max = "300";
579 fontSize.addEventListener("input", () => {
580 if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`);
581 });
582 textSection.appendChild(createField("Font size", fontSize));
583
584 const fontWeight = document.createElement("select");
585 for (const value of ["300", "400", "500", "600", "700", "800", "900"]) {
586 const option = document.createElement("option");
587 option.value = value;
588 option.textContent = value;
589 fontWeight.appendChild(option);
590 }
591 fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value));
592 textSection.appendChild(createField("Font weight", fontWeight));
593
594 const lineHeight = document.createElement("input");
595 lineHeight.type = "text";
596 lineHeight.placeholder = "normal / 1.4";
597 lineHeight.addEventListener("change", () => {
598 if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim());
599 });
600 textSection.appendChild(createField("Line height", lineHeight));
601
602 const createColorRow = (
603 labelText: string,
604 property: string,
605 section: HTMLElement,
606 ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => {
607 const row = document.createElement("label");
608 row.className =
609 "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground";
610 const label = document.createElement("span");
611 label.textContent = labelText;
612 const control = document.createElement("div");
613 control.className =
614 "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs";
615 const color = document.createElement("input");
616 color.type = "color";
617 color.setAttribute("aria-label", labelText);
618 color.style.cssText =
619 "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer";
620 const text = document.createElement("input");
621 text.type = "text";
622 text.setAttribute("aria-label", `${labelText} value`);
623 text.className =
624 "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none";
625 color.addEventListener("input", () => {
626 text.value = color.value;
627 setStyleForSelected(property, color.value);
628 });
629 text.addEventListener("change", () => {
630 const value = text.value.trim();
631 if (!value) return;
632 setStyleForSelected(property, value);
633 if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value;
634 });
635 control.append(color, text);
636 row.append(label, control);
637 section.appendChild(row);
638 return { row, color, text };
639 };
640
641 const textColor = createColorRow("Text color", "color", colorsSection);
642 const backgroundColor = createColorRow("Background", "background-color", colorsSection);
643
644 const opacity = document.createElement("input");
645 opacity.type = "range";
646 opacity.min = "0";
647 opacity.max = "1";
648 opacity.step = "0.05";
649 opacity.value = "1";
650 opacity.style.accentColor = PRIMARY;
651 opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value));
652 colorsSection.appendChild(createField("Opacity", opacity));
653
654 const radius = createUnitInput("px", "0");
655 radius.min = "0";
656 radius.max = "300";
657 radius.addEventListener("input", () => {
658 if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`);
659 });
660 bordersSection.appendChild(createField("Radius", radius));
661
662 const borderColor = createColorRow("Border color", "border-color", bordersSection);
663
664 const borderWidth = createUnitInput("px", "0");
665 borderWidth.min = "0";
666 borderWidth.max = "100";
667 borderWidth.addEventListener("input", () => {
668 if (borderWidth.value) {
669 setStyleForSelected("border-style", "solid");
670 setStyleForSelected("border-width", `${borderWidth.value}px`);
671 }
672 });
673 bordersSection.appendChild(createField("Border width", borderWidth));
674
675 const dimensions = document.createElement("div");
676 dimensions.style.cssText =
677 "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center";
678 const dimensionLabel = document.createElement("div");
679 dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground";
680 dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>";
681 const dimensionControls = document.createElement("div");
682 dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px";
683 const widthInput = createUnitInput("px", "auto");
684 const heightInput = createUnitInput("px", "auto");
685 styleControl(widthInput);
686 styleControl(heightInput);
687 const aspectLock = createButton("", "Lock aspect ratio");
688 aspectLock.setAttribute("aria-pressed", "true");
689 aspectLock.style.cssText +=
690 ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0";
691 aspectLock.className += " bg-primary/10 text-primary";
692 dimensionControls.append(
693 createUnitControl(widthInput),
694 createUnitControl(heightInput),
695 aspectLock,
696 );
697 dimensions.append(dimensionLabel, dimensionControls);
698 sizingSection.appendChild(dimensions);
699
700 let aspectLocked = true;
701 let aspectRatio = 1;
702 const refreshAspectButton = (): void => {
703 aspectLock.innerHTML = aspectLocked
704 ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'
705 : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
706 aspectLock.setAttribute("aria-pressed", String(aspectLocked));
707 aspectLock.classList.toggle("bg-primary/10", aspectLocked);
708 aspectLock.classList.toggle("text-primary", aspectLocked);
709 aspectLock.classList.toggle("bg-muted", !aspectLocked);
710 aspectLock.classList.toggle("text-muted-foreground", !aspectLocked);
711 };
712 aspectLock.addEventListener("click", () => {
713 aspectLocked = !aspectLocked;
714 refreshAspectButton();
715 });
716 widthInput.addEventListener("input", () => {
717 const width = Number(widthInput.value);
718 if (!Number.isFinite(width) || width <= 0) return;
719 setStyleForSelected("width", `${width}px`);
720 if (aspectLocked && aspectRatio > 0) {
721 const height = Math.max(1, Math.round(width / aspectRatio));
722 heightInput.value = String(height);
723 setStyleForSelected("height", `${height}px`);
724 }
725 });
726 heightInput.addEventListener("input", () => {
727 const height = Number(heightInput.value);
728 if (!Number.isFinite(height) || height <= 0) return;
729 setStyleForSelected("height", `${height}px`);
730 if (aspectLocked && aspectRatio > 0) {
731 const width = Math.max(1, Math.round(height * aspectRatio));
732 widthInput.value = String(width);
733 setStyleForSelected("width", `${width}px`);
734 }
735 });
736 refreshAspectButton();
737
738 const addSpacingField = (
739 label: string,
740 property: string,
741 placeholder: string,
742 ): HTMLInputElement => {
743 const input = document.createElement("input");
744 input.type = "text";
745 input.placeholder = placeholder;
746 input.addEventListener("change", () => {
747 if (input.value.trim()) setStyleForSelected(property, input.value.trim());
748 });
749 sizingSection.appendChild(createField(label, input));
750 return input;
751 };
752 const padding = addSpacingField("Padding", "padding", "0 0 0 0");
753 const margin = addSpacingField("Margin", "margin", "0 0 0 0");
754 const gap = addSpacingField("Gap", "gap", "0px");
755
756 const syncStyleControls = (): void => {
757 const first = selected.values().next().value as SelectedElement | undefined;
758 if (!first) return;
759 const computed = getComputedStyle(first.element);
760 const rect = first.element.getBoundingClientRect();
761 aspectRatio = rect.height > 0 ? rect.width / rect.height : 1;
762 widthInput.value = String(Math.round(rect.width));
763 heightInput.value = String(Math.round(rect.height));
764 fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16));
765 fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400";
766 lineHeight.value = computed.lineHeight;
767 fontFamily.value = Array.from(fontFamily.options).some(
768 (option) => option.value === computed.fontFamily,
769 )
770 ? computed.fontFamily
771 : "inherit";
772 textColor.text.value = computed.color;
773 backgroundColor.text.value = computed.backgroundColor;
774 borderColor.text.value = computed.borderColor;
775 opacity.value = computed.opacity;
776 radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0));
777 borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0));
778 padding.value = computed.padding;
779 margin.value = computed.margin;
780 gap.value = computed.gap === "normal" ? "0px" : computed.gap;
781 };
782
783 const tools: ReadonlyArray<[AnnotationTool, string, string]> = [
784 ["select", "Select", "Select elements (V)"],
785 ["marquee", "Region", "Draw a region or marquee-select elements (R)"],
786 ["draw", "Draw", "Draw freehand (D)"],
787 ["erase", "Erase", "Remove an annotation target (E)"],
788 ];
789 for (const [candidate, label, title] of tools) {
790 const button = createButton(label, title);
791 button.className += " h-8 px-2.5 text-sm";
792 button.addEventListener("click", () => {
793 tool = candidate;
794 refreshToolButtons();
795 });
796 toolButtons.set(candidate, button);
797 toolbar.appendChild(button);
798 }
799
800 const clampEditorPosition = (left: number, top: number): { left: number; top: number } => {
801 const margin = 8;
802 const rect = editor.getBoundingClientRect();
803 return {
804 left: Math.min(
805 Math.max(margin, left),
806 Math.max(margin, window.innerWidth - rect.width - margin),
807 ),
808 top: Math.min(
809 Math.max(margin, top),
810 Math.max(margin, window.innerHeight - rect.height - margin),
811 ),
812 };
813 };
814
815 const applyEditorPosition = (position: { left: number; top: number }): void => {
816 const clamped = clampEditorPosition(position.left, position.top);
817 editor.style.left = `${clamped.left}px`;
818 editor.style.top = `${clamped.top}px`;
819 editor.style.right = "auto";
820 editor.style.bottom = "auto";
821 if (editorExpanded) editorPosition = clamped;
822 };
823
824 const getAnnotationBounds = (): PreviewAnnotationRect | null =>
825 unionRects(
826 [
827 ...Array.from(selected.values(), (target) =>
828 rectFromDomRect(target.element.getBoundingClientRect()),
829 ),
830 ...regions.map((region) => region.rect),
831 ...strokes.map((stroke) => stroke.bounds),
832 ],
833 0,
834 );
835
836 const positionCompactEditor = (): void => {
837 const bounds = getAnnotationBounds();
838 if (!bounds) return;
839 const editorRect = editor.getBoundingClientRect();
840 const gap = 8;
841 const candidates = [
842 { left: bounds.x + bounds.width + gap, top: bounds.y },
843 { left: bounds.x - editorRect.width - gap, top: bounds.y },
844 {
845 left: bounds.x + bounds.width - editorRect.width,
846 top: bounds.y + bounds.height + gap,
847 },
848 {
849 left: bounds.x + bounds.width - editorRect.width,
850 top: bounds.y - editorRect.height - gap,
851 },
852 ];
853 const overflow = (position: { left: number; top: number }): number =>
854 Math.max(0, -position.left) +
855 Math.max(0, -position.top) +
856 Math.max(0, position.left + editorRect.width - window.innerWidth) +
857 Math.max(0, position.top + editorRect.height - window.innerHeight);
858 const best = candidates.reduce((current, candidate) =>
859 overflow(candidate) < overflow(current) ? candidate : current,
860 );
861 applyEditorPosition(best);
862 };
863
864 function queueEditorLayout(): void {
865 if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame);
866 editorLayoutFrame = window.requestAnimationFrame(() => {
867 editorLayoutFrame = null;
868 if (editor.style.display === "none") return;
869 if (editorExpanded && editorPosition) applyEditorPosition(editorPosition);
870 else positionCompactEditor();
871 });
872 }
873
874 adjust.addEventListener("click", () => {
875 if (selected.size === 0) return;
876 if (!editorExpanded) {
877 const rect = editor.getBoundingClientRect();
878 editorExpanded = true;
879 editorPosition = { left: rect.left, top: rect.top };
880 stylePanel.style.display = selected.size > 0 ? "grid" : "none";
881 dragHandle.style.display = "block";
882 adjust.setAttribute("aria-expanded", "true");
883 adjust.title = "Collapse annotation editor";
884 adjust.setAttribute("aria-label", "Collapse annotation editor");
885 if (selected.size > 0) syncStyleControls();
886 } else {
887 editorExpanded = false;
888 editorPosition = null;
889 stylePanel.style.display = "none";
890 dragHandle.style.display = "none";
891 adjust.setAttribute("aria-expanded", "false");
892 adjust.title = "Expand annotation editor";
893 adjust.setAttribute("aria-label", "Expand annotation editor");
894 }
895 queueEditorLayout();
896 });
897
898 const onEditorPointerDown = (event: PointerEvent): void => {
899 if (event.button !== 0 || !editorExpanded) return;
900 const rect = editor.getBoundingClientRect();
901 editorDrag = {
902 pointerId: event.pointerId,
903 offsetX: event.clientX - rect.left,
904 offsetY: event.clientY - rect.top,
905 };
906 dragHandle.setPointerCapture(event.pointerId);
907 dragHandle.style.cursor = "grabbing";
908 event.preventDefault();
909 event.stopPropagation();
910 };
911
912 const onEditorPointerMove = (event: PointerEvent): void => {
913 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;
914 applyEditorPosition({
915 left: event.clientX - editorDrag.offsetX,
916 top: event.clientY - editorDrag.offsetY,
917 });
918 event.preventDefault();
919 event.stopPropagation();
920 };
921
922 const onEditorPointerUp = (event: PointerEvent): void => {
923 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;
924 editorDrag = null;
925 dragHandle.style.cursor = "grab";
926 if (dragHandle.hasPointerCapture(event.pointerId))
927 dragHandle.releasePointerCapture(event.pointerId);
928 event.preventDefault();
929 event.stopPropagation();
930 };
931 dragHandle.addEventListener("pointerdown", onEditorPointerDown);
932 dragHandle.addEventListener("pointermove", onEditorPointerMove);
933 dragHandle.addEventListener("pointerup", onEditorPointerUp);
934 dragHandle.addEventListener("pointercancel", onEditorPointerUp);
935
936 const repaint = (): void => {
937 for (const target of selected.values()) updateSelectedVisual(target);
938 queueEditorLayout();
939 };
940
941 const removeTargetAtPoint = (x: number, y: number): boolean => {
942 for (const target of Array.from(selected.values()).toReversed()) {
943 const rect = target.element.getBoundingClientRect();
944 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
945 removeSelected(target);
946 return true;
947 }
948 }
949 const regionIndex = regions.findIndex(
950 (region) =>
951 x >= region.rect.x &&
952 x <= region.rect.x + region.rect.width &&
953 y >= region.rect.y &&
954 y <= region.rect.y + region.rect.height,
955 );
956 if (regionIndex >= 0) {
957 const [removed] = regions.splice(regionIndex, 1);
958 root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove();
959 updateStatus();
960 return true;
961 }
962 const strokeIndex = strokes.findIndex(
963 (stroke) =>
964 x >= stroke.bounds.x &&
965 x <= stroke.bounds.x + stroke.bounds.width &&
966 y >= stroke.bounds.y &&
967 y <= stroke.bounds.y + stroke.bounds.height,
968 );
969 if (strokeIndex >= 0) {
970 const [removed] = strokes.splice(strokeIndex, 1);
971 svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove();
972 updateStatus();
973 return true;
974 }
975 return false;
976 };
977
978 const selectElementsInRect = (rect: PreviewAnnotationRect): number => {
979 const candidates = Array.from(document.querySelectorAll("body *"))
980 .filter((element) => !isAnnotationNode(element))
981 .map((element) => ({ element, rect: element.getBoundingClientRect() }))
982 .filter(({ rect: candidate }) => {
983 if (candidate.width < 2 || candidate.height < 2) return false;
984 return !(
985 candidate.right < rect.x ||
986 candidate.left > rect.x + rect.width ||
987 candidate.bottom < rect.y ||
988 candidate.top > rect.y + rect.height
989 );
990 })
991 .filter(({ element, rect: candidate }) => {
992 const centerX = candidate.left + candidate.width / 2;
993 const centerY = candidate.top + candidate.height / 2;
994 return (
995 centerX >= rect.x &&
996 centerX <= rect.x + rect.width &&
997 centerY >= rect.y &&
998 centerY <= rect.y + rect.height &&
999 (element.children.length === 0 ||
1000 element instanceof HTMLButtonElement ||
1001 element instanceof HTMLAnchorElement ||
1002 element.getAttribute("role") === "button")
1003 );
1004 })
1005 .sort(
1006 (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height,
1007 )
1008 .slice(0, MAX_MARQUEE_ELEMENTS);
1009 for (const candidate of candidates) addSelected(candidate.element);
1010 return candidates.length;
1011 };
1012
1013 const clearHoverOutline = (): void => {
1014 hoverOutline.style.display = "none";
1015 };
1016
1017 const onPointerMove = (event: PointerEvent): void => {
1018 if (isAnnotationNode(event.target as Element)) {
1019 clearHoverOutline();
1020 return;
1021 }
1022 if (tool === "select" && dragStart === null) {
1023 const target = pickFromPoint(event.clientX, event.clientY);
1024 if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect()));
1025 else clearHoverOutline();
1026 return;
1027 }
1028 clearHoverOutline();
1029 if (tool === "marquee" && dragStart) {
1030 positionBox(
1031 marqueeBox,
1032 normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY),
1033 );
1034 return;
1035 }
1036 if (tool === "draw" && activeStroke) {
1037 activeStroke.target.points = [
1038 ...activeStroke.target.points,
1039 { x: event.clientX, y: event.clientY },
1040 ];
1041 activeStroke.target.bounds = strokeBounds(
1042 activeStroke.target.points,
1043 activeStroke.target.width,
1044 );
1045 activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points));
1046 }
1047 };
1048
1049 const onPointerDown = (event: PointerEvent): void => {
1050 if (event.button !== 0 || isAnnotationNode(event.target as Element)) return;
1051 event.preventDefault();
1052 event.stopPropagation();
1053 if (tool === "select") {
1054 const target = pickFromPoint(event.clientX, event.clientY);
1055 if (target) toggleSelected(target, event.shiftKey);
1056 return;
1057 }
1058 if (tool === "erase") {
1059 removeTargetAtPoint(event.clientX, event.clientY);
1060 return;
1061 }
1062 dragStart = { x: event.clientX, y: event.clientY };
1063 if (tool === "draw") {
1064 const stroke: PreviewAnnotationStrokeTarget = {
1065 id: nextId("stroke"),
1066 color: annotationTheme?.primary ?? "#2563eb",
1067 width: 4,
1068 points: [dragStart],
1069 bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 },
1070 };
1071 const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1072 path.setAttribute(OVERLAY_ATTRIBUTE, "");
1073 path.setAttribute("data-stroke-id", stroke.id);
1074 path.setAttribute("fill", "none");
1075 path.setAttribute("stroke", stroke.color);
1076 path.setAttribute("stroke-width", String(stroke.width));
1077 path.setAttribute("stroke-linecap", "round");
1078 path.setAttribute("stroke-linejoin", "round");
1079 svg.appendChild(path);
1080 activeStroke = { target: stroke, path };
1081 }
1082 };
1083
1084 const onPointerUp = (event: PointerEvent): void => {
1085 if (!dragStart) return;
1086 event.preventDefault();
1087 event.stopPropagation();
1088 if (tool === "marquee") {
1089 const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY);
1090 marqueeBox.style.display = "none";
1091 if (isUsableRect(rect)) {
1092 const found = selectElementsInRect(rect);
1093 if (found === 0) {
1094 const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect };
1095 regions.push(region);
1096 const regionBox = createBox(
1097 PRIMARY,
1098 "color-mix(in srgb, var(--t3-primary) 6%, transparent)",
1099 );
1100 regionBox.setAttribute("data-region-id", region.id);
1101 positionBox(regionBox, rect);
1102 root.appendChild(regionBox);
1103 }
1104 }
1105 } else if (tool === "draw" && activeStroke) {
1106 if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target);
1107 else activeStroke.path.remove();
1108 activeStroke = null;
1109 }
1110 dragStart = null;
1111 updateStatus();
1112 };
1113
1114 const onClick = (event: MouseEvent): void => {
1115 if (isAnnotationNode(event.target as Element)) return;
1116 event.preventDefault();
1117 event.stopPropagation();
1118 };
1119
1120 const onPointerOut = (event: PointerEvent): void => {
1121 if (event.relatedTarget === null) clearHoverOutline();
1122 };
1123
1124 const onWindowBlur = (): void => {
1125 clearHoverOutline();
1126 };
1127
1128 const restoreStyles = (): void => {
1129 for (const target of selected.values()) {
1130 if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement))
1131 continue;
1132 for (const [property, baseline] of target.baselineStyles) {
1133 if (baseline) target.element.style.setProperty(property, baseline);
1134 else target.element.style.removeProperty(property);
1135 }
1136 }
1137 };
1138
1139 const teardown = (notifyMain: boolean): void => {
1140 if (finished) return;
1141 finished = true;
1142 restoreStyles();
1143 window.removeEventListener("pointermove", onPointerMove, true);
1144 window.removeEventListener("pointerdown", onPointerDown, true);
1145 window.removeEventListener("pointerup", onPointerUp, true);
1146 window.removeEventListener("pointerout", onPointerOut, true);
1147 window.removeEventListener("click", onClick, true);
1148 window.removeEventListener("blur", onWindowBlur);
1149 window.removeEventListener("keydown", onKeyDown, true);
1150 window.removeEventListener("scroll", repaint, true);
1151 window.removeEventListener("resize", repaint);
1152 dragHandle.removeEventListener("pointerdown", onEditorPointerDown);
1153 dragHandle.removeEventListener("pointermove", onEditorPointerMove);
1154 dragHandle.removeEventListener("pointerup", onEditorPointerUp);
1155 dragHandle.removeEventListener("pointercancel", onEditorPointerUp);
1156 if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame);
1157 ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel);
1158 ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1159 document.documentElement.removeAttribute("data-t3code-annotation-tool");
1160 cursorStyle.remove();
1161 host.remove();
1162 activeSession = null;
1163 if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null);
1164 };
1165
1166 const onCancel = (): void => teardown(false);
1167 const onCaptured = (): void => teardown(false);
1168 const onKeyDown = (event: KeyboardEvent): void => {
1169 if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return;
1170 if (event.key === "Escape") {
1171 event.preventDefault();
1172 event.stopPropagation();
1173 teardown(true);
1174 return;
1175 }
1176 if (event.key === "v") tool = "select";
1177 else if (event.key === "r") tool = "marquee";
1178 else if (event.key === "d") tool = "draw";
1179 else if (event.key === "e") tool = "erase";
1180 else return;
1181 refreshToolButtons();
1182 };
1183
1184 submit.addEventListener("click", () => {
1185 if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0))
1186 return;
1187 pendingCapture = true;
1188 submit.disabled = true;
1189 submit.textContent = "Capturing…";
1190 void Promise.all(
1191 Array.from(selected.values()).map(async (target) => {
1192 const element = await captureElement(target.element);
1193 if (!element) return null;
1194 for (const change of styleChanges.values()) {
1195 if (change.targetId === target.id) change.selector = element.selector;
1196 }
1197 return {
1198 id: target.id,
1199 element,
1200 rect: rectFromDomRect(target.element.getBoundingClientRect()),
1201 };
1202 }),
1203 ).then((captured) => {
1204 const elements = captured.filter((target) => target !== null);
1205 const annotation: PreviewAnnotationPayload = {
1206 id: nextId("annotation"),
1207 pageUrl: location.href,
1208 pageTitle: document.title?.trim() || null,
1209 comment: comment.value.trim(),
1210 elements,
1211 regions: [...regions],
1212 strokes: [...strokes],
1213 styleChanges: Array.from(styleChanges.values()),
1214 screenshot: null,
1215 createdAt: new Date().toISOString(),
1216 };
1217 editor.style.display = "none";
1218 toolbar.style.display = "none";
1219 hoverOutline.style.display = "none";
1220 const screenshotRect = unionRects([
1221 ...elements.map((target) => target.rect),
1222 ...regions.map((region) => region.rect),
1223 ...strokes.map((stroke) => stroke.bounds),
1224 ]);
1225 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect);
1226 });
1227 });
1228 comment.addEventListener("keydown", (event) => {
1229 if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return;
1230 event.preventDefault();
1231 submit.click();
1232 });
1233
1234 window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false });
1235 window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false });
1236 window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false });
1237 window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true });
1238 window.addEventListener("click", onClick, { capture: true, passive: false });
1239 window.addEventListener("blur", onWindowBlur);
1240 window.addEventListener("keydown", onKeyDown, { capture: true });
1241 window.addEventListener("scroll", repaint, { capture: true, passive: true });
1242 window.addEventListener("resize", repaint, { passive: true });
1243 ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel);
1244 ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1245 document.documentElement.appendChild(host);
1246 refreshToolButtons();
1247 updateStatus();
1248 activeSession = {
1249 teardown,
1250 applyTheme: (theme) => applyAnnotationTheme(host, theme),
1251 };
1252}
1253
1254ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => {
1255 if (theme) annotationTheme = theme;
1256 startAnnotation();
1257});
1258ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => {
1259 annotationTheme = theme;
1260 activeSession?.applyTheme(theme);
1261});
1262ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false));
1import "./preview/PickPreload.ts";
apps/desktop/src/preview-view-manager.test.ts deleted-283
......@@ -1,283 +0,0 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const fromId = vi.fn(() => null);
4const mkdir = vi.fn(async () => undefined);
5const writeFile = vi.fn(async () => undefined);
6const showItemInFolder = vi.fn();
7const writeImage = vi.fn();
8const createFromPath = vi.fn(() => ({ isEmpty: () => false }));
9const webviewSend = vi.fn();
10
11vi.mock("node:fs/promises", () => ({ mkdir, writeFile }));
12
13vi.mock("electron", () => ({
14 clipboard: {
15 writeImage,
16 },
17 nativeImage: {
18 createFromPath,
19 },
20 session: {
21 fromPartition: vi.fn(),
22 },
23 shell: {
24 showItemInFolder,
25 },
26 webContents: {
27 fromId,
28 },
29}));
30
31describe("PreviewViewManager automation status", () => {
32 beforeEach(() => {
33 fromId.mockClear();
34 mkdir.mockClear();
35 writeFile.mockClear();
36 showItemInFolder.mockClear();
37 writeImage.mockClear();
38 createFromPath.mockClear();
39 webviewSend.mockClear();
40 });
41
42 it("reports an unregistered webview as temporarily unavailable", async () => {
43 const { PreviewViewManager } = await import("./preview-view-manager.ts");
44 const manager = new PreviewViewManager();
45
46 expect(manager.automationStatus("tab_1")).toEqual({
47 available: false,
48 visible: true,
49 tabId: "tab_1",
50 url: null,
51 title: null,
52 loading: false,
53 });
54
55 manager.createTab("tab_1");
56
57 expect(manager.automationStatus("tab_1")).toEqual({
58 available: false,
59 visible: true,
60 tabId: "tab_1",
61 url: null,
62 title: null,
63 loading: false,
64 });
65 expect(fromId).not.toHaveBeenCalled();
66 });
67
68 it("captures a PNG screenshot into browser artifacts", async () => {
69 const png = Buffer.from("preview-png");
70 const capturePage = vi.fn(async () => ({ toPNG: () => png }));
71 const listeners = new Map<string, (...args: never[]) => void>();
72 fromId.mockReturnValue({
73 id: 42,
74 isDestroyed: () => false,
75 getType: () => "webview",
76 getURL: () => "https://example.com:8443/path?query=value",
77 getTitle: () => "Example",
78 isLoading: () => false,
79 getZoomFactor: () => 1,
80 setZoomFactor: vi.fn(),
81 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
82 listeners.set(event, listener);
83 }),
84 off: vi.fn(),
85 ipc: { on: vi.fn(), off: vi.fn() },
86 send: webviewSend,
87 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
88 setWindowOpenHandler: vi.fn(),
89 debugger: {
90 isAttached: () => false,
91 attach: vi.fn(),
92 sendCommand: vi.fn(async () => undefined),
93 on: vi.fn(),
94 off: vi.fn(),
95 },
96 capturePage,
97 } as never);
98 const { PreviewViewManager } = await import("./preview-view-manager.ts");
99 const manager = new PreviewViewManager();
100 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
101 manager.createTab("tab_1");
102 manager.registerWebview("tab_1", 42);
103
104 expect(webviewSend).toHaveBeenCalledWith(
105 "preview:annotation-theme",
106 expect.objectContaining({
107 colorScheme: "light",
108 primary: "oklch(0.488 0.217 264)",
109 }),
110 );
111
112 const artifact = await manager.captureScreenshot("tab_1");
113
114 expect(capturePage).toHaveBeenCalledOnce();
115 expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", {
116 recursive: true,
117 });
118 expect(writeFile).toHaveBeenCalledWith(artifact.path, png);
119 expect(artifact).toMatchObject({
120 tabId: "tab_1",
121 mimeType: "image/png",
122 sizeBytes: png.byteLength,
123 });
124 expect(artifact.path).toMatch(
125 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
126 );
127 });
128
129 it("reveals only files inside the configured browser artifact directory", async () => {
130 const { PreviewViewManager } = await import("./preview-view-manager.ts");
131 const manager = new PreviewViewManager();
132 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
133
134 manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png");
135
136 expect(showItemInFolder).toHaveBeenCalledWith(
137 "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png",
138 );
139 expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow(
140 "outside the configured artifact directory",
141 );
142 });
143
144 it("copies screenshot artifacts to the system clipboard", async () => {
145 const { PreviewViewManager } = await import("./preview-view-manager.ts");
146 const manager = new PreviewViewManager();
147 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
148 const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png";
149
150 manager.copyArtifactToClipboard(artifactPath);
151
152 expect(createFromPath).toHaveBeenCalledWith(artifactPath);
153 expect(writeImage).toHaveBeenCalledOnce();
154 expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow(
155 "outside the configured artifact directory",
156 );
157 });
158
159 it("emits the resolved pointer target before dispatching an automation click", async () => {
160 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
161 const activity: string[] = [];
162 const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
163 if (method === "Runtime.evaluate") {
164 return {
165 result: {
166 value: { width: 800, height: 600 },
167 },
168 };
169 }
170 if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") {
171 activity.push("mousePressed");
172 humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 });
173 }
174 return undefined;
175 });
176 fromId.mockReturnValue({
177 id: 42,
178 isDestroyed: () => false,
179 getType: () => "webview",
180 getURL: () => "https://example.com",
181 getTitle: () => "Example",
182 isLoading: () => false,
183 isDevToolsOpened: () => false,
184 getZoomFactor: () => 1,
185 setZoomFactor: vi.fn(),
186 on: vi.fn(),
187 off: vi.fn(),
188 ipc: {
189 on: vi.fn((channel: string, listener: typeof humanInput) => {
190 if (channel === "preview:human-input") humanInput = listener;
191 }),
192 off: vi.fn(),
193 },
194 send: webviewSend,
195 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
196 setWindowOpenHandler: vi.fn(),
197 debugger: {
198 isAttached: () => false,
199 attach: vi.fn(),
200 sendCommand,
201 on: vi.fn(),
202 off: vi.fn(),
203 },
204 } as never);
205 const { PreviewViewManager } = await import("./preview-view-manager.ts");
206 const manager = new PreviewViewManager();
207 manager.onPointerEvent((event) => activity.push(event.phase));
208 manager.createTab("tab_1");
209 manager.registerWebview("tab_1", 42);
210
211 await manager.automationClick("tab_1", { x: 120, y: 80 });
212
213 expect(activity).toEqual(["move", "click", "mousePressed"]);
214 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
215 type: "mousePressed",
216 x: 120,
217 y: 80,
218 button: "left",
219 clickCount: 1,
220 });
221 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
222 type: "mouseReleased",
223 x: 120,
224 y: 80,
225 button: "left",
226 clickCount: 1,
227 });
228 });
229
230 it("still interrupts agent control for a different human pointer event", async () => {
231 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
232 const sendCommand = vi.fn(async (method: string) => {
233 if (method === "Runtime.evaluate") {
234 return {
235 result: {
236 value: { width: 800, height: 600 },
237 },
238 };
239 }
240 if (method === "Input.dispatchMouseEvent") {
241 humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 });
242 }
243 return undefined;
244 });
245 fromId.mockReturnValue({
246 id: 42,
247 isDestroyed: () => false,
248 getType: () => "webview",
249 getURL: () => "https://example.com",
250 getTitle: () => "Example",
251 isLoading: () => false,
252 isDevToolsOpened: () => false,
253 getZoomFactor: () => 1,
254 setZoomFactor: vi.fn(),
255 on: vi.fn(),
256 off: vi.fn(),
257 ipc: {
258 on: vi.fn((channel: string, listener: typeof humanInput) => {
259 if (channel === "preview:human-input") humanInput = listener;
260 }),
261 off: vi.fn(),
262 },
263 send: webviewSend,
264 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
265 setWindowOpenHandler: vi.fn(),
266 debugger: {
267 isAttached: () => false,
268 attach: vi.fn(),
269 sendCommand,
270 on: vi.fn(),
271 off: vi.fn(),
272 },
273 } as never);
274 const { PreviewViewManager } = await import("./preview-view-manager.ts");
275 const manager = new PreviewViewManager();
276 manager.createTab("tab_1");
277 manager.registerWebview("tab_1", 42);
278
279 await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({
280 name: "PreviewAutomationControlInterruptedError",
281 });
282 });
283});
apps/desktop/src/preview-view-manager.ts deleted-1741
......@@ -1,1741 +0,0 @@
1// @effect-diagnostics globalDate:off
2// @effect-diagnostics nodeBuiltinImport:off
3/**
4 * PreviewViewManager — desktop side of the in-app browser preview.
5 *
6 * Hosts per-tab Chromium WebContents references (the actual <webview>
7 * elements live in the renderer; we only attach listeners and forward state
8 * here). Single layer-scoped browser session partition.
9 */
10import type {
11 DesktopPreviewAnnotationTheme,
12 DesktopPreviewPointerEvent,
13 PreviewAnnotationPayload,
14 PreviewAnnotationRect,
15 DesktopPreviewRecordingArtifact,
16 DesktopPreviewRecordingFrame,
17 DesktopPreviewScreenshotArtifact,
18 PreviewAutomationClickInput,
19 PreviewAutomationActionEvent,
20 PreviewAutomationConsoleEntry,
21 PreviewAutomationEvaluateInput,
22 PreviewAutomationPressInput,
23 PreviewAutomationNetworkEntry,
24 PreviewAutomationScrollInput,
25 PreviewAutomationSnapshot,
26 PreviewAutomationStatus,
27 PreviewAutomationTypeInput,
28 PreviewAutomationWaitForInput,
29} from "@t3tools/contracts";
30import { normalizePreviewUrl } from "@t3tools/shared/preview";
31import {
32 type BrowserWindow,
33 type Session,
34 clipboard,
35 nativeImage,
36 session,
37 shell,
38 webContents,
39} from "electron";
40import { mkdir, writeFile } from "node:fs/promises";
41import { isAbsolute, join, relative, resolve, sep } from "node:path";
42import { createHash } from "node:crypto";
43import { setTimeout as sleep } from "node:timers/promises";
44
45import { isPreviewAnnotationPayload } from "./picked-element-payload.ts";
46import { playwrightInjectedRuntimeInstallExpression } from "./playwright-injected-runtime.ts";
47
48const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
49const START_PICK_CHANNEL = "preview:start-pick";
50const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
51const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
52const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
53const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
54const HUMAN_INPUT_CHANNEL = "preview:human-input";
55
56// Re-export the guest webview security posture from its dedicated module so
57// the constant is unit-testable in isolation. See
58// `preview-webview-preferences.ts` for the full security rationale.
59export { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts";
60import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts";
61
62export type PreviewNavStatus =
63 | { kind: "Idle" }
64 | { kind: "Loading"; url: string; title: string }
65 | { kind: "Success"; url: string; title: string }
66 | {
67 kind: "LoadFailed";
68 url: string;
69 title: string;
70 code: number;
71 description: string;
72 };
73
74export interface PreviewTabState {
75 tabId: string;
76 webContentsId: number | null;
77 navStatus: PreviewNavStatus;
78 canGoBack: boolean;
79 canGoForward: boolean;
80 zoomFactor: number;
81 controller: "human" | "agent" | "none";
82 updatedAt: string;
83}
84
85/** Discrete zoom levels mirroring Chrome's preset list. */
86const ZOOM_LEVELS: ReadonlyArray<number> = [
87 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0,
88];
89
90const DEFAULT_ZOOM_FACTOR = 1.0;
91const ZOOM_EPSILON = 0.001;
92const MAX_EVALUATION_BYTES = 64_000;
93const MAX_VISIBLE_TEXT_LENGTH = 20_000;
94const MAX_INTERACTIVE_ELEMENTS = 200;
95const MAX_SCREENSHOT_WIDTH = 1280;
96const DIAGNOSTIC_BUFFER_LIMIT = 200;
97const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
98const AGENT_CURSOR_MOVE_MS = 160;
99const AGENT_CURSOR_CLICK_LEAD_MS = 40;
100const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {
101 colorScheme: "light",
102 radius: "0.625rem",
103 background: "white",
104 foreground: "oklch(0.269 0 0)",
105 popover: "white",
106 popoverForeground: "oklch(0.269 0 0)",
107 primary: "oklch(0.488 0.217 264)",
108 primaryForeground: "white",
109 muted: "rgb(0 0 0 / 4%)",
110 mutedForeground: "oklch(0.556 0 0)",
111 accent: "rgb(0 0 0 / 4%)",
112 accentForeground: "oklch(0.269 0 0)",
113 border: "rgb(0 0 0 / 8%)",
114 input: "rgb(0 0 0 / 10%)",
115 ring: "oklch(0.488 0.217 264)",
116 fontSans: "system-ui, sans-serif",
117 fontMono: "ui-monospace, monospace",
118};
119
120const artifactSiteSlug = (rawUrl: string): string => {
121 try {
122 const url = new URL(rawUrl);
123 const slug = url.hostname
124 .toLowerCase()
125 .replace(/[^a-z0-9]+/g, "-")
126 .replace(/^-+|-+$/g, "")
127 .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH)
128 .replace(/-+$/g, "");
129 return slug || "site";
130 } catch {
131 return "site";
132 }
133};
134
135interface CdpEvaluationResult {
136 readonly result?: {
137 readonly value?: unknown;
138 readonly description?: string;
139 };
140 readonly exceptionDetails?: {
141 readonly text?: string;
142 readonly exception?: { readonly description?: string };
143 };
144}
145
146const automationError = (
147 tag:
148 | "PreviewAutomationExecutionError"
149 | "PreviewAutomationInvalidSelectorError"
150 | "PreviewAutomationResultTooLargeError"
151 | "PreviewAutomationTimeoutError"
152 | "PreviewAutomationControlInterruptedError",
153 message: string,
154 detail?: unknown,
155): Error & { detail?: unknown } => {
156 const error = new Error(message) as Error & { detail?: unknown };
157 error.name = tag;
158 if (detail !== undefined) error.detail = detail;
159 return error;
160};
161
162const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
163 if (typeof value !== "object" || value === null) return null;
164 const rect = value as Record<string, unknown>;
165 const x = rect["x"];
166 const y = rect["y"];
167 const width = rect["width"];
168 const height = rect["height"];
169 if (
170 typeof x !== "number" ||
171 !Number.isFinite(x) ||
172 typeof y !== "number" ||
173 !Number.isFinite(y) ||
174 typeof width !== "number" ||
175 !Number.isFinite(width) ||
176 typeof height !== "number" ||
177 !Number.isFinite(height) ||
178 width <= 0 ||
179 height <= 0
180 ) {
181 return null;
182 }
183 return {
184 x: Math.max(0, Math.floor(x)),
185 y: Math.max(0, Math.floor(y)),
186 width: Math.max(1, Math.ceil(width)),
187 height: Math.max(1, Math.ceil(height)),
188 };
189};
190
191const captureAnnotationScreenshot = async (
192 wc: Electron.WebContents,
193 cropRect: PreviewAnnotationRect | null,
194) => {
195 const image = await wc.capturePage(
196 cropRect
197 ? {
198 x: cropRect.x,
199 y: cropRect.y,
200 width: cropRect.width,
201 height: cropRect.height,
202 }
203 : undefined,
204 );
205 const size = image.getSize();
206 return {
207 dataUrl: image.toDataURL(),
208 width: size.width,
209 height: size.height,
210 cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height },
211 };
212};
213
214const findZoomStep = (current: number): number => {
215 for (let index = 0; index < ZOOM_LEVELS.length; index += 1) {
216 if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index;
217 if (ZOOM_LEVELS[index]! > current) return index - 1;
218 }
219 return ZOOM_LEVELS.length - 1;
220};
221
222const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
223 const step = findZoomStep(current);
224 if (direction === "in") {
225 return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current;
226 }
227 return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current;
228};
229
230type Listener = (tabId: string, state: PreviewTabState) => void;
231type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void;
232
233type PreviewInputSignal =
234 | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number }
235 | { readonly kind: "key"; readonly key: string; readonly code: string };
236
237interface ManagedListeners {
238 navigate: () => void;
239 failed: (event: Event, code: number, description: string) => void;
240 humanInput: (_event: unknown, signal?: unknown) => void;
241}
242
243interface PickSession {
244 readonly resolve: (payload: PreviewAnnotationPayload | null) => void;
245 readonly cleanup: () => void;
246}
247
248interface BrowserControlSession {
249 readonly webContentsId: number;
250 tail: Promise<void>;
251 initialized: Promise<void>;
252}
253
254interface BrowserDiagnostics {
255 readonly consoleEntries: PreviewAutomationConsoleEntry[];
256 readonly networkEntries: PreviewAutomationNetworkEntry[];
257 readonly requests: Map<string, { url: string; method: string }>;
258}
259
260type PointerEventListener = (event: DesktopPreviewPointerEvent) => void;
261
262interface ExpectedAgentInput {
263 readonly signal: PreviewInputSignal;
264 readonly expiresAt: number;
265}
266
267const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
268 key: string;
269 meta: boolean;
270 shift: boolean;
271 control: boolean;
272}> = Object.freeze([
273 // mod+shift+J → preview.toggle
274 { key: "j", meta: true, shift: true, control: false },
275 // mod+K → command palette
276 { key: "k", meta: true, shift: false, control: false },
277 // mod+, → settings (macOS convention)
278 { key: ",", meta: true, shift: false, control: false },
279 // mod+W → close tab/panel
280 { key: "w", meta: true, shift: false, control: false },
281]);
282
283const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
284 if (typeof value !== "object" || value === null || !("kind" in value)) return false;
285 if (value.kind === "pointer") {
286 return (
287 "x" in value &&
288 typeof value.x === "number" &&
289 "y" in value &&
290 typeof value.y === "number" &&
291 "button" in value &&
292 typeof value.button === "number"
293 );
294 }
295 return (
296 value.kind === "key" &&
297 "key" in value &&
298 typeof value.key === "string" &&
299 "code" in value &&
300 typeof value.code === "string"
301 );
302};
303
304const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => {
305 if (left.kind !== right.kind) return false;
306 if (left.kind === "pointer" && right.kind === "pointer") {
307 return (
308 Math.abs(left.x - right.x) <= 1 &&
309 Math.abs(left.y - right.y) <= 1 &&
310 left.button === right.button
311 );
312 }
313 return (
314 left.kind === "key" &&
315 right.kind === "key" &&
316 left.key === right.key &&
317 left.code === right.code
318 );
319};
320
321export class PreviewViewManager {
322 private annotationTheme = DEFAULT_ANNOTATION_THEME;
323 private artifactDirectory: string | null = null;
324 private mainWindow: BrowserWindow | null = null;
325 private readonly tabs = new Map<string, PreviewTabState>();
326 private readonly attached = new Map<number, ManagedListeners>();
327 private readonly browserSessions = new Map<string, Session>();
328 private readonly listeners = new Set<Listener>();
329 private readonly pointerEventListeners = new Set<PointerEventListener>();
330 private readonly recordingFrameListeners = new Set<RecordingFrameListener>();
331 /** In-flight preview annotation sessions, keyed by tabId. */
332 private readonly pickSessions = new Map<string, PickSession>();
333 /** One long-lived CDP attachment and serialized command queue per guest. */
334 private readonly controlSessions = new Map<number, BrowserControlSession>();
335 private readonly diagnostics = new Map<number, BrowserDiagnostics>();
336 private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>();
337 private readonly controlEpoch = new Map<string, number>();
338 private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>();
339 private actionSequence = 0;
340 private pointerSequence = 0;
341 private recordingTabId: string | null = null;
342
343 configureArtifactDirectory(directory: string): void {
344 this.artifactDirectory = resolve(directory);
345 }
346
347 private requireArtifactDirectory(): string {
348 if (!this.artifactDirectory) {
349 throw new Error("Preview artifact directory is not configured.");
350 }
351 return this.artifactDirectory;
352 }
353
354 private resolveArtifactPath(path: string): string {
355 const directory = this.requireArtifactDirectory();
356 const resolvedPath = resolve(path);
357 const relativePath = relative(directory, resolvedPath);
358 if (
359 relativePath.length === 0 ||
360 relativePath === ".." ||
361 relativePath.startsWith(`..${sep}`) ||
362 isAbsolute(relativePath)
363 ) {
364 throw new Error("Preview artifact path is outside the configured artifact directory.");
365 }
366 return resolvedPath;
367 }
368
369 revealArtifact(path: string): void {
370 const resolvedPath = this.resolveArtifactPath(path);
371 shell.showItemInFolder(resolvedPath);
372 }
373
374 copyArtifactToClipboard(path: string): void {
375 const resolvedPath = this.resolveArtifactPath(path);
376 const image = nativeImage.createFromPath(resolvedPath);
377 if (image.isEmpty()) {
378 throw new Error("Preview artifact could not be loaded as an image.");
379 }
380 clipboard.writeImage(image);
381 }
382
383 setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void {
384 this.annotationTheme = theme;
385 for (const tab of this.tabs.values()) {
386 if (tab.webContentsId == null) continue;
387 const wc = webContents.fromId(tab.webContentsId);
388 if (!wc || wc.isDestroyed()) continue;
389 wc.send(ANNOTATION_THEME_CHANNEL, theme);
390 }
391 }
392
393 setMainWindow(window: BrowserWindow): void {
394 this.mainWindow = window;
395 }
396
397 getBrowserPartition(scope = "shared"): string {
398 const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20);
399 return `${PREVIEW_PARTITION_PREFIX}${digest}`;
400 }
401
402 isBrowserPartition(partition: string): boolean {
403 return partition.startsWith(PREVIEW_PARTITION_PREFIX);
404 }
405
406 /**
407 * Returns the canonical `<webview webpreferences="...">` string. Renderer
408 * fetches this via the desktop bridge so the security posture for guest
409 * surfaces lives in exactly one place (here) and any future guest webview
410 * (docs panel, OAuth popup, etc.) can opt in by calling the same getter.
411 */
412 getWebviewPreferences(): string {
413 return PREVIEW_WEBVIEW_PREFERENCES;
414 }
415
416 getBrowserSession(scope = "shared"): Session {
417 const partition = this.getBrowserPartition(scope);
418 const existing = this.browserSessions.get(partition);
419 if (existing) return existing;
420 const sess = session.fromPartition(partition);
421 const ua = sess
422 .getUserAgent()
423 .replace(/Electron\/[\d.]+ /, "")
424 .replace(/\s*t3code\/[\d.]+/, "");
425 sess.setUserAgent(ua);
426 sess.setPermissionRequestHandler((_wc, perm, callback) => {
427 const allow = ["clipboard-read", "clipboard-write", "notifications", "geolocation"];
428 callback(allow.includes(perm));
429 });
430 this.browserSessions.set(partition, sess);
431 return sess;
432 }
433
434 createTab(tabId: string): PreviewTabState {
435 const existing = this.tabs.get(tabId);
436 if (existing) return existing;
437 const initial: PreviewTabState = {
438 tabId,
439 webContentsId: null,
440 navStatus: { kind: "Idle" },
441 canGoBack: false,
442 canGoForward: false,
443 zoomFactor: DEFAULT_ZOOM_FACTOR,
444 controller: "none",
445 updatedAt: new Date().toISOString(),
446 };
447 this.tabs.set(tabId, initial);
448 this.emit(tabId, initial);
449 return initial;
450 }
451
452 closeTab(tabId: string): void {
453 const tab = this.tabs.get(tabId);
454 if (!tab) return;
455 this.cancelPickElement(tabId);
456 if (tab.webContentsId != null) {
457 this.detachControlSession(tab.webContentsId);
458 this.detachListeners(tab.webContentsId);
459 }
460 const closed: PreviewTabState = {
461 ...tab,
462 webContentsId: null,
463 navStatus: { kind: "Idle" },
464 canGoBack: false,
465 canGoForward: false,
466 zoomFactor: DEFAULT_ZOOM_FACTOR,
467 controller: "none",
468 updatedAt: new Date().toISOString(),
469 };
470 this.tabs.delete(tabId);
471 this.emit(tabId, closed);
472 }
473
474 registerWebview(tabId: string, webContentsId: number): void {
475 const tab = this.tabs.get(tabId);
476 if (!tab) {
477 throw new PreviewTabNotFoundError(tabId);
478 }
479 const wc = webContents.fromId(webContentsId);
480 if (!wc) {
481 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
482 }
483 // Defence in depth: a malicious renderer could otherwise trick us into
484 // attaching listeners to the main window's WebContents (or any other
485 // process) by passing an arbitrary id.
486 if (wc.getType() !== "webview") {
487 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
488 }
489 if (this.mainWindow && wc.hostWebContents !== this.mainWindow.webContents) {
490 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
491 }
492 if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) {
493 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);
494 return;
495 }
496 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {
497 this.detachControlSession(tab.webContentsId);
498 this.detachListeners(tab.webContentsId);
499 // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`.
500 // Cancel it so the toggle button doesn't get stuck pressed waiting
501 // forever for a click on a webview that no longer hosts the listener.
502 this.cancelPickElement(tabId);
503 }
504 this.attachListeners(tabId, wc);
505 void this.ensureControlSession(wc).catch(() => undefined);
506 // Restore the persisted zoom factor onto the freshly-attached WebContents
507 // so a thread-switch + remount lands the user back where they were.
508 if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
509 try {
510 wc.setZoomFactor(tab.zoomFactor);
511 } catch {
512 // wc may have been torn down between resolution and call.
513 }
514 }
515 this.update(tabId, {
516 webContentsId,
517 navStatus: this.computeNavStatus(wc),
518 canGoBack: wc.navigationHistory.canGoBack(),
519 canGoForward: wc.navigationHistory.canGoForward(),
520 zoomFactor: tab.zoomFactor,
521 });
522 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);
523 }
524
525 async navigate(tabId: string, rawUrl: string): Promise<void> {
526 const wc = this.requireWebContents(tabId);
527 const url = this.normalizeUrl(rawUrl);
528 if (wc.getURL() === url) {
529 wc.reload();
530 return;
531 }
532 await wc.loadURL(url);
533 }
534
535 goBack(tabId: string): void {
536 const wc = this.requireWebContents(tabId);
537 if (wc.navigationHistory.canGoBack()) {
538 wc.navigationHistory.goBack();
539 }
540 }
541
542 goForward(tabId: string): void {
543 const wc = this.requireWebContents(tabId);
544 if (wc.navigationHistory.canGoForward()) {
545 wc.navigationHistory.goForward();
546 }
547 }
548
549 refresh(tabId: string): void {
550 const wc = this.requireWebContents(tabId);
551 wc.reload();
552 }
553
554 /** Bypass HTTP cache on the next load — equivalent to Cmd+Shift+R. */
555 hardReload(tabId: string): void {
556 const wc = this.requireWebContents(tabId);
557 wc.reloadIgnoringCache();
558 }
559
560 /**
561 * Open the guest webview's DevTools, detached so it doesn't steal panel
562 * area. Idempotent — re-invoking focuses the existing window.
563 */
564 openDevTools(tabId: string): void {
565 const wc = this.requireWebContents(tabId);
566 if (wc.isDevToolsOpened()) {
567 wc.devToolsWebContents?.focus();
568 return;
569 }
570 this.detachControlSession(wc.id);
571 wc.once("devtools-closed", () => {
572 if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined);
573 });
574 wc.openDevTools({ mode: "detach" });
575 }
576
577 /**
578 * Drop cookies/localStorage/etc. for the preview partition. Affects every
579 * preview tab since they all share `persist:t3code-preview`.
580 */
581 async clearCookies(): Promise<void> {
582 await Promise.all(
583 [...this.browserSessions.values()].map((sess) =>
584 sess.clearStorageData({
585 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],
586 }),
587 ),
588 );
589 }
590
591 /** Drop the HTTP cache for the preview partition. */
592 async clearCache(): Promise<void> {
593 await Promise.all([...this.browserSessions.values()].map((sess) => sess.clearCache()));
594 }
595
596 /**
597 * Activate annotation mode for `tabId`. Resolves after the guest submits a
598 * multi-target annotation and the desktop process captures its screenshot,
599 * or with `null` when the user cancels.
600 *
601 * Exactly one pick session may be active per tab — re-invoking while a
602 * pick is in flight cleanly resolves the old session with `null` first.
603 */
604 async pickElement(tabId: string): Promise<PreviewAnnotationPayload | null> {
605 const wc = this.requireWebContents(tabId);
606 this.cancelPickElement(tabId);
607 return new Promise<PreviewAnnotationPayload | null>((resolve) => {
608 // `wc.ipc` is the per-WebContents IpcMain that receives messages the
609 // webview's preload sends with `ipcRenderer.send(...)`. We use that
610 // (not the global `wc.on("ipc-message", ...)`, which is for
611 // `sendToHost` and only fires on the host renderer's <webview>
612 // element) so the main process actually observes the picked payload.
613 const cleanup = () => {
614 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
615 wc.off("destroyed", onDestroyed);
616 wc.off("did-start-navigation", onNavigated);
617 this.pickSessions.delete(tabId);
618 };
619 const session: PickSession = { resolve, cleanup };
620 const settle = (payload: PreviewAnnotationPayload | null) => {
621 if (this.pickSessions.get(tabId) !== session) return;
622 cleanup();
623 resolve(payload);
624 };
625 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {
626 const payload = args[0];
627 if (payload == null) {
628 settle(null);
629 return;
630 }
631 if (!isPreviewAnnotationPayload(payload)) {
632 settle(null);
633 return;
634 }
635 const cropRect = normalizeCaptureRect(args[1]);
636 void captureAnnotationScreenshot(wc, cropRect)
637 .then((screenshot) => settle({ ...payload, screenshot }))
638 .catch(() => settle(payload))
639 .finally(() => {
640 if (wc.isDestroyed()) return;
641 try {
642 wc.send(ANNOTATION_CAPTURED_CHANNEL);
643 } catch {
644 // The guest may have navigated while capture was in flight.
645 }
646 });
647 };
648 const onDestroyed = () => settle(null);
649 const onNavigated = () => settle(null);
650 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);
651 wc.once("destroyed", onDestroyed);
652 // A page navigation (incl. SPA → same-document) tears down the
653 // preload's listeners, so we cancel proactively to avoid hanging.
654 wc.once("did-start-navigation", onNavigated);
655 this.pickSessions.set(tabId, session);
656 // Force-focus the guest webContents BEFORE sending start-pick. Without
657 // this, Electron's input router will deliver the user's first
658 // mousemove/click to the host renderer (where the pick button lives)
659 // instead of to the guest's window listeners — manifest as "the
660 // picker overlay never appears on remote pages I haven't clicked
661 // into yet". The renderer-side handler in `PreviewView` is responsible
662 // for restoring focus to the previously-active host element when the
663 // pick promise resolves so the user's textarea cursor isn't lost.
664 try {
665 if (!wc.isFocused()) wc.focus();
666 } catch {
667 // wc may be torn down; the next try/catch settles.
668 }
669 try {
670 wc.send(START_PICK_CHANNEL, this.annotationTheme);
671 } catch {
672 settle(null);
673 }
674 });
675 }
676
677 cancelPickElement(tabId: string): void {
678 const session = this.pickSessions.get(tabId);
679 if (!session) return;
680 session.cleanup();
681 // Best-effort: tell the page to dismiss the overlay even if it's still
682 // alive — keeps the next invoke fresh.
683 const tab = this.tabs.get(tabId);
684 if (tab?.webContentsId != null) {
685 const wc = webContents.fromId(tab.webContentsId);
686 if (wc && !wc.isDestroyed()) {
687 try {
688 wc.send(CANCEL_PICK_CHANNEL);
689 } catch {
690 // wc may have been torn down; nothing to clean up.
691 }
692 }
693 }
694 session.resolve(null);
695 }
696
697 async startRecording(tabId: string): Promise<void> {
698 if (this.recordingTabId && this.recordingTabId !== tabId) {
699 throw new Error("Only one browser recording can be active per window.");
700 }
701 const wc = this.requireWebContents(tabId);
702 await this.withControlSession(tabId, wc, "recording.start", async (send) => {
703 await send("Page.enable");
704 await send("Page.startScreencast", {
705 format: "jpeg",
706 quality: 80,
707 maxWidth: 1600,
708 maxHeight: 1200,
709 everyNthFrame: 1,
710 });
711 });
712 this.recordingTabId = tabId;
713 }
714
715 async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> {
716 const wc = this.requireWebContents(tabId);
717 const createdAt = new Date().toISOString();
718 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`;
719 const directory = this.requireArtifactDirectory();
720 const path = join(directory, `${id}.png`);
721 const data = (await wc.capturePage()).toPNG();
722 await mkdir(directory, { recursive: true });
723 await writeFile(path, data);
724 return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt };
725 }
726
727 async stopRecording(tabId: string): Promise<void> {
728 if (this.recordingTabId !== tabId) return;
729 const wc = this.requireWebContents(tabId);
730 await this.withControlSession(tabId, wc, "recording.stop", async (send) => {
731 await send("Page.stopScreencast");
732 });
733 this.recordingTabId = null;
734 }
735
736 async saveRecording(
737 tabId: string,
738 mimeType: string,
739 data: Uint8Array,
740 ): Promise<DesktopPreviewRecordingArtifact> {
741 const createdAt = new Date().toISOString();
742 const id = `browser-recording-${Date.now().toString(36)}`;
743 const extension = mimeType.includes("mp4") ? "mp4" : "webm";
744 const directory = this.requireArtifactDirectory();
745 const path = join(directory, `${id}.${extension}`);
746 await mkdir(directory, { recursive: true });
747 await writeFile(path, data);
748 return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt };
749 }
750
751 onRecordingFrame(listener: RecordingFrameListener): () => void {
752 this.recordingFrameListeners.add(listener);
753 return () => this.recordingFrameListeners.delete(listener);
754 }
755
756 zoomIn(tabId: string): void {
757 this.applyZoom(tabId, (current) => nextZoomLevel(current, "in"));
758 }
759
760 zoomOut(tabId: string): void {
761 this.applyZoom(tabId, (current) => nextZoomLevel(current, "out"));
762 }
763
764 resetZoom(tabId: string): void {
765 this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR);
766 }
767
768 automationStatus(tabId: string): PreviewAutomationStatus {
769 const tab = this.tabs.get(tabId);
770 if (!tab || tab.webContentsId == null) {
771 const navStatus = tab?.navStatus;
772 return {
773 available: false,
774 visible: true,
775 tabId,
776 url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url,
777 title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title,
778 loading: navStatus?.kind === "Loading",
779 };
780 }
781 const wc = webContents.fromId(tab.webContentsId);
782 if (!wc || wc.isDestroyed()) {
783 return {
784 available: false,
785 visible: true,
786 tabId,
787 url: null,
788 title: null,
789 loading: false,
790 };
791 }
792 return {
793 available: true,
794 visible: true,
795 tabId,
796 url: wc.getURL() || null,
797 title: wc.getTitle() || null,
798 loading: wc.isLoading(),
799 };
800 }
801
802 async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> {
803 const wc = this.requireWebContents(tabId);
804 return this.withControlSession(tabId, wc, "snapshot", async (send) => {
805 await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]);
806 const page = await this.evaluateWithDebugger<{
807 url: string;
808 title: string;
809 loading: boolean;
810 visibleText: string;
811 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
812 }>(
813 send,
814 `(() => {
815 const selectorFor = (element) => {
816 if (element.id) return "#" + CSS.escape(element.id);
817 for (const attribute of ["data-testid", "name"]) {
818 const value = element.getAttribute(attribute);
819 if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";
820 }
821 const parts = [];
822 let current = element;
823 while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) {
824 let part = current.tagName.toLowerCase();
825 const parent = current.parentElement;
826 if (parent) {
827 const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
828 if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")";
829 }
830 parts.unshift(part);
831 current = parent;
832 }
833 return parts.join(" > ");
834 };
835 const visible = (element) => {
836 const style = getComputedStyle(element);
837 const rect = element.getBoundingClientRect();
838 return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
839 };
840 const elements = Array.from(document.querySelectorAll(
841 "a[href],button,input,textarea,select,[role],[tabindex]"
842 )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {
843 const rect = element.getBoundingClientRect();
844 return {
845 tag: element.tagName.toLowerCase(),
846 role: element.getAttribute("role"),
847 name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "",
848 selector: selectorFor(element),
849 x: rect.x,
850 y: rect.y,
851 width: rect.width,
852 height: rect.height
853 };
854 });
855 return {
856 url: location.href,
857 title: document.title,
858 loading: document.readyState !== "complete",
859 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
860 interactiveElements: elements
861 };
862 })()`,
863 true,
864 );
865 const accessibility = await send("Accessibility.getFullAXTree");
866 let image = await wc.capturePage();
867 let size = image.getSize();
868 if (size.width > MAX_SCREENSHOT_WIDTH) {
869 image = image.resize({ width: MAX_SCREENSHOT_WIDTH });
870 size = image.getSize();
871 }
872 return {
873 ...page,
874 accessibilityTree: accessibility,
875 consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])],
876 networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])],
877 actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])],
878 screenshot: {
879 mimeType: "image/png",
880 data: image.toPNG().toString("base64"),
881 width: size.width,
882 height: size.height,
883 },
884 };
885 });
886 }
887
888 async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> {
889 const wc = this.requireWebContents(tabId);
890 await this.withControlSession(tabId, wc, "click", async (send) => {
891 await Promise.all([
892 send("Runtime.enable"),
893 send("Input.setIgnoreInputEvents", { ignore: false }),
894 ]);
895 let x: number;
896 let y: number;
897 if ("selector" in input || "locator" in input) {
898 await this.ensurePlaywrightInjected(send);
899 const locator = this.automationLocator(input);
900 const point = await this.evaluateWithDebugger<
901 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }
902 >(
903 send,
904 `(() => {
905 try {
906 const injected = globalThis.__t3PlaywrightInjected;
907 const parsed = injected.parseSelector(${JSON.stringify(locator)});
908 const element = injected.querySelector(parsed, document, true);
909 if (!element) return { notFound: true };
910 const visible = injected.elementState(element, "visible");
911 const enabled = injected.elementState(element, "enabled");
912 if (!visible.matches || !enabled.matches) return { notFound: true };
913 element.scrollIntoView({ block: "center", inline: "center" });
914 const rect = element.getBoundingClientRect();
915 return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
916 } catch (error) {
917 return { invalidSelector: true, message: String(error) };
918 }
919 })()`,
920 true,
921 );
922 if ("invalidSelector" in point) {
923 throw automationError("PreviewAutomationInvalidSelectorError", point.message, {
924 selector: locator,
925 });
926 }
927 if ("notFound" in point) {
928 throw automationError(
929 "PreviewAutomationExecutionError",
930 `No element matches locator ${locator}.`,
931 );
932 }
933 x = point.x;
934 y = point.y;
935 } else {
936 x = input.x!;
937 y = input.y!;
938 }
939 const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>(
940 send,
941 "({ width: window.innerWidth, height: window.innerHeight })",
942 true,
943 );
944 if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) {
945 throw automationError(
946 "PreviewAutomationExecutionError",
947 `Click coordinates (${x}, ${y}) are outside the preview viewport.`,
948 );
949 }
950 this.emitPointerEvent({
951 tabId,
952 phase: "move",
953 x,
954 y,
955 sequence: this.pointerSequence++,
956 createdAt: new Date().toISOString(),
957 });
958 await sleep(AGENT_CURSOR_MOVE_MS);
959 this.emitPointerEvent({
960 tabId,
961 phase: "click",
962 x,
963 y,
964 sequence: this.pointerSequence++,
965 createdAt: new Date().toISOString(),
966 });
967 await sleep(AGENT_CURSOR_CLICK_LEAD_MS);
968 this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 });
969 await send("Input.dispatchMouseEvent", {
970 type: "mousePressed",
971 x,
972 y,
973 button: "left",
974 clickCount: 1,
975 });
976 await send("Input.dispatchMouseEvent", {
977 type: "mouseReleased",
978 x,
979 y,
980 button: "left",
981 clickCount: 1,
982 });
983 });
984 }
985
986 async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> {
987 const wc = this.requireWebContents(tabId);
988 await this.withControlSession(tabId, wc, "type", async (send) => {
989 await send("Runtime.enable");
990 const locator = this.automationLocator(input);
991 if (locator) await this.ensurePlaywrightInjected(send);
992 const focusResult = await this.evaluateWithDebugger<
993 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
994 >(
995 send,
996 `(() => {
997 try {
998 const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"};
999 if (!element) return { notFound: true };
1000 element.focus();
1001 if (${input.clear ?? false}) {
1002 if ("value" in element) element.value = "";
1003 else if (element.isContentEditable) element.textContent = "";
1004 element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }));
1005 }
1006 return { ok: true };
1007 } catch (error) {
1008 return { invalidSelector: true, message: String(error) };
1009 }
1010 })()`,
1011 true,
1012 );
1013 if ("invalidSelector" in focusResult) {
1014 throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, {
1015 selector: input.selector ?? "",
1016 });
1017 }
1018 if ("notFound" in focusResult) {
1019 throw automationError(
1020 "PreviewAutomationExecutionError",
1021 locator
1022 ? `No element matches locator ${locator}.`
1023 : "No element is focused in the preview.",
1024 );
1025 }
1026 await send("Input.insertText", { text: input.text });
1027 await this.evaluateWithDebugger(
1028 send,
1029 `(() => {
1030 const element = document.activeElement;
1031 element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} }));
1032 element?.dispatchEvent(new Event("change", { bubbles: true }));
1033 })()`,
1034 false,
1035 );
1036 });
1037 }
1038
1039 async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> {
1040 const wc = this.requireWebContents(tabId);
1041 await this.withControlSession(tabId, wc, "press", async (send) => {
1042 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {
1043 switch (modifier) {
1044 case "Alt":
1045 return value | 1;
1046 case "Control":
1047 return value | 2;
1048 case "Meta":
1049 return value | 4;
1050 case "Shift":
1051 return value | 8;
1052 }
1053 }, 0);
1054 const key = input.key;
1055 const text = key.length === 1 ? key : undefined;
1056 const params = {
1057 key,
1058 code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
1059 modifiers,
1060 ...(text ? { text, unmodifiedText: text } : {}),
1061 };
1062 this.expectAgentInput(tabId, { kind: "key", key, code: params.code });
1063 await send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
1064 await send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
1065 });
1066 }
1067
1068 async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> {
1069 const wc = this.requireWebContents(tabId);
1070 await this.withControlSession(tabId, wc, "scroll", async (send) => {
1071 await send("Runtime.enable");
1072 const locator = this.automationLocator(input);
1073 if (locator) await this.ensurePlaywrightInjected(send);
1074 const result = await this.evaluateWithDebugger<
1075 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
1076 >(
1077 send,
1078 `(() => {
1079 try {
1080 const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"};
1081 if (!target) return { notFound: true };
1082 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });
1083 return { ok: true };
1084 } catch (error) {
1085 return { invalidSelector: true, message: String(error) };
1086 }
1087 })()`,
1088 true,
1089 );
1090 if ("invalidSelector" in result) {
1091 throw automationError("PreviewAutomationInvalidSelectorError", result.message, {
1092 selector: input.selector ?? "",
1093 });
1094 }
1095 if ("notFound" in result) {
1096 throw automationError(
1097 "PreviewAutomationExecutionError",
1098 `No element matches locator ${locator}.`,
1099 );
1100 }
1101 });
1102 }
1103
1104 async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> {
1105 const wc = this.requireWebContents(tabId);
1106 return this.withControlSession(tabId, wc, "evaluate", async (send) => {
1107 await send("Runtime.enable");
1108 const value = await this.evaluateWithDebugger(
1109 send,
1110 input.expression,
1111 input.returnByValue ?? true,
1112 input.awaitPromise ?? true,
1113 );
1114 const serialized = JSON.stringify(value);
1115 if (
1116 serialized !== undefined &&
1117 Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES
1118 ) {
1119 throw automationError(
1120 "PreviewAutomationResultTooLargeError",
1121 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,
1122 { maximumBytes: MAX_EVALUATION_BYTES },
1123 );
1124 }
1125 return value;
1126 });
1127 }
1128
1129 async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> {
1130 const wc = this.requireWebContents(tabId);
1131 const timeoutMs = input.timeoutMs ?? 15_000;
1132 await this.withControlSession(tabId, wc, "waitFor", async (send) => {
1133 await send("Runtime.enable");
1134 const locator = this.automationLocator(input);
1135 if (locator) await this.ensurePlaywrightInjected(send);
1136 const deadline = Date.now() + timeoutMs;
1137 while (Date.now() <= deadline) {
1138 const result = await this.evaluateWithDebugger<
1139 { matched: boolean } | { invalidSelector: true; message: string }
1140 >(
1141 send,
1142 `(() => {
1143 try {
1144 const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"};
1145 const textMatched = ${
1146 input.text
1147 ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})`
1148 : "true"
1149 };
1150 const urlMatched = ${
1151 input.urlIncludes
1152 ? `location.href.includes(${JSON.stringify(input.urlIncludes)})`
1153 : "true"
1154 };
1155 return { matched: selectorMatched && textMatched && urlMatched };
1156 } catch (error) {
1157 return { invalidSelector: true, message: String(error) };
1158 }
1159 })()`,
1160 true,
1161 );
1162 if ("invalidSelector" in result) {
1163 throw automationError("PreviewAutomationInvalidSelectorError", result.message, {
1164 selector: input.selector ?? "",
1165 });
1166 }
1167 if (result.matched) return;
1168 await sleep(100);
1169 }
1170 throw automationError(
1171 "PreviewAutomationTimeoutError",
1172 `Preview condition did not match within ${timeoutMs}ms.`,
1173 );
1174 });
1175 }
1176
1177 private applyZoom(tabId: string, transform: (current: number) => number): void {
1178 const tab = this.tabs.get(tabId);
1179 if (!tab) return;
1180 const next = transform(tab.zoomFactor);
1181 if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return;
1182 if (tab.webContentsId != null) {
1183 const wc = webContents.fromId(tab.webContentsId);
1184 if (wc && !wc.isDestroyed()) {
1185 wc.setZoomFactor(next);
1186 }
1187 }
1188 this.update(tabId, { zoomFactor: next });
1189 }
1190
1191 private async withControlSession<A>(
1192 tabId: string,
1193 wc: Electron.WebContents,
1194 action: string,
1195 use: (
1196 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
1197 ) => Promise<A>,
1198 ): Promise<A> {
1199 const actionEvent: PreviewAutomationActionEvent = {
1200 id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`,
1201 action,
1202 status: "running",
1203 startedAt: new Date().toISOString(),
1204 };
1205 this.pushAction(tabId, actionEvent);
1206 const epoch = this.controlEpoch.get(tabId) ?? 0;
1207 const control = await this.ensureControlSession(wc);
1208 let resolveTail: () => void = () => undefined;
1209 const previous = control.tail;
1210 control.tail = new Promise<void>((resolve) => {
1211 resolveTail = resolve;
1212 });
1213 await previous;
1214 this.update(tabId, { controller: "agent" });
1215 try {
1216 const send = async (method: string, commandParams?: Record<string, unknown>) => {
1217 if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) {
1218 throw automationError(
1219 "PreviewAutomationControlInterruptedError",
1220 "Browser control was interrupted by human input.",
1221 );
1222 }
1223 const result = await wc.debugger.sendCommand(method, commandParams);
1224 if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) {
1225 throw automationError(
1226 "PreviewAutomationControlInterruptedError",
1227 "Browser control was interrupted by human input.",
1228 );
1229 }
1230 return result;
1231 };
1232 const result = await use(send);
1233 this.replaceAction(tabId, {
1234 ...actionEvent,
1235 status: "succeeded",
1236 completedAt: new Date().toISOString(),
1237 });
1238 return result;
1239 } catch (cause) {
1240 const interrupted =
1241 cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError";
1242 this.replaceAction(tabId, {
1243 ...actionEvent,
1244 status: interrupted ? "interrupted" : "failed",
1245 completedAt: new Date().toISOString(),
1246 error: cause instanceof Error ? cause.message : String(cause),
1247 });
1248 if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause;
1249 throw automationError(
1250 "PreviewAutomationExecutionError",
1251 cause instanceof Error ? cause.message : String(cause),
1252 { tabId, cause },
1253 );
1254 } finally {
1255 if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" });
1256 resolveTail();
1257 }
1258 }
1259
1260 private pushAction(tabId: string, event: PreviewAutomationActionEvent): void {
1261 const timeline = this.actionTimeline.get(tabId) ?? [];
1262 timeline.push(event);
1263 if (timeline.length > 200) timeline.splice(0, timeline.length - 200);
1264 this.actionTimeline.set(tabId, timeline);
1265 }
1266
1267 private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void {
1268 const timeline = this.actionTimeline.get(tabId);
1269 if (!timeline) return;
1270 const index = timeline.findIndex((candidate) => candidate.id === event.id);
1271 if (index >= 0) timeline[index] = event;
1272 }
1273
1274 private async ensureControlSession(wc: Electron.WebContents): Promise<BrowserControlSession> {
1275 const existing = this.controlSessions.get(wc.id);
1276 if (existing) {
1277 await existing.initialized;
1278 return existing;
1279 }
1280 if (wc.isDevToolsOpened()) {
1281 throw automationError(
1282 "PreviewAutomationExecutionError",
1283 "Close preview DevTools before using agent browser control.",
1284 );
1285 }
1286 if (wc.debugger.isAttached()) {
1287 throw automationError(
1288 "PreviewAutomationExecutionError",
1289 "Preview control cannot attach because another debugger owns this page.",
1290 );
1291 }
1292 const control: BrowserControlSession = {
1293 webContentsId: wc.id,
1294 tail: Promise.resolve(),
1295 initialized: Promise.resolve(),
1296 };
1297 const diagnostics: BrowserDiagnostics = {
1298 consoleEntries: [],
1299 networkEntries: [],
1300 requests: new Map(),
1301 };
1302 this.diagnostics.set(wc.id, diagnostics);
1303 wc.debugger.on("message", (_event, method, params) => {
1304 if (method === "Page.screencastFrame") {
1305 const frame = params as Record<string, unknown>;
1306 const sessionId = frame["sessionId"];
1307 if (typeof sessionId === "number") {
1308 void wc.debugger
1309 .sendCommand("Page.screencastFrameAck", { sessionId })
1310 .catch(() => undefined);
1311 }
1312 const tabId = this.tabIdForWebContents(wc.id);
1313 const metadata =
1314 typeof frame["metadata"] === "object" && frame["metadata"] !== null
1315 ? (frame["metadata"] as Record<string, unknown>)
1316 : {};
1317 if (tabId && typeof frame["data"] === "string") {
1318 const payload: DesktopPreviewRecordingFrame = {
1319 tabId,
1320 data: frame["data"],
1321 width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
1322 height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
1323 receivedAt: new Date().toISOString(),
1324 };
1325 for (const listener of this.recordingFrameListeners) listener(payload);
1326 }
1327 }
1328 this.captureDiagnosticMessage(diagnostics, method, params as Record<string, unknown>);
1329 });
1330 control.initialized = (async () => {
1331 wc.debugger.attach("1.3");
1332 await Promise.all([
1333 wc.debugger.sendCommand("Runtime.enable"),
1334 wc.debugger.sendCommand("Accessibility.enable"),
1335 wc.debugger.sendCommand("Network.enable"),
1336 wc.debugger.sendCommand("Log.enable"),
1337 ]);
1338 })();
1339 this.controlSessions.set(wc.id, control);
1340 try {
1341 await control.initialized;
1342 return control;
1343 } catch (cause) {
1344 this.controlSessions.delete(wc.id);
1345 throw cause;
1346 }
1347 }
1348
1349 private detachControlSession(webContentsId: number): void {
1350 this.controlSessions.delete(webContentsId);
1351 this.diagnostics.delete(webContentsId);
1352 const wc = webContents.fromId(webContentsId);
1353 if (!wc || wc.isDestroyed() || !wc.debugger.isAttached()) return;
1354 try {
1355 wc.debugger.detach();
1356 } catch {
1357 // Target teardown can race detachment.
1358 }
1359 }
1360
1361 private tabIdForWebContents(webContentsId: number): string | null {
1362 for (const [tabId, tab] of this.tabs) {
1363 if (tab.webContentsId === webContentsId) return tabId;
1364 }
1365 return null;
1366 }
1367
1368 private captureDiagnosticMessage(
1369 diagnostics: BrowserDiagnostics,
1370 method: string,
1371 params: Record<string, unknown>,
1372 ): void {
1373 const timestamp = new Date().toISOString();
1374 if (method === "Runtime.consoleAPICalled") {
1375 const args = Array.isArray(params["args"]) ? params["args"] : [];
1376 const text = args
1377 .map((arg) => {
1378 if (typeof arg !== "object" || arg === null) return String(arg);
1379 const value = arg as Record<string, unknown>;
1380 return String(value["value"] ?? value["description"] ?? "");
1381 })
1382 .join(" ");
1383 this.pushBounded(diagnostics.consoleEntries, {
1384 level: typeof params["type"] === "string" ? params["type"] : "log",
1385 text,
1386 timestamp,
1387 source: "console",
1388 });
1389 return;
1390 }
1391 if (method === "Runtime.exceptionThrown") {
1392 const details =
1393 typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null
1394 ? (params["exceptionDetails"] as Record<string, unknown>)
1395 : {};
1396 this.pushBounded(diagnostics.consoleEntries, {
1397 level: "error",
1398 text: String(details["text"] ?? "Uncaught exception"),
1399 timestamp,
1400 source: "exception",
1401 });
1402 return;
1403 }
1404 if (method === "Log.entryAdded") {
1405 const entry =
1406 typeof params["entry"] === "object" && params["entry"] !== null
1407 ? (params["entry"] as Record<string, unknown>)
1408 : {};
1409 this.pushBounded(diagnostics.consoleEntries, {
1410 level: typeof entry["level"] === "string" ? entry["level"] : "info",
1411 text: String(entry["text"] ?? ""),
1412 timestamp,
1413 source: typeof entry["source"] === "string" ? entry["source"] : "log",
1414 });
1415 return;
1416 }
1417 const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null;
1418 if (method === "Network.requestWillBeSent" && requestId) {
1419 const request =
1420 typeof params["request"] === "object" && params["request"] !== null
1421 ? (params["request"] as Record<string, unknown>)
1422 : {};
1423 diagnostics.requests.set(requestId, {
1424 url: String(request["url"] ?? ""),
1425 method: String(request["method"] ?? "GET"),
1426 });
1427 return;
1428 }
1429 if (method === "Network.responseReceived" && requestId) {
1430 const request = diagnostics.requests.get(requestId);
1431 const response =
1432 typeof params["response"] === "object" && params["response"] !== null
1433 ? (params["response"] as Record<string, unknown>)
1434 : {};
1435 const status = typeof response["status"] === "number" ? response["status"] : null;
1436 if (request && status !== null && status >= 400) {
1437 this.pushBounded(diagnostics.networkEntries, {
1438 ...request,
1439 status,
1440 failed: true,
1441 timestamp,
1442 });
1443 }
1444 return;
1445 }
1446 if (method === "Network.loadingFailed" && requestId) {
1447 const request = diagnostics.requests.get(requestId);
1448 if (request) {
1449 this.pushBounded(diagnostics.networkEntries, {
1450 ...request,
1451 status: null,
1452 failed: true,
1453 errorText: String(params["errorText"] ?? "Network request failed"),
1454 timestamp,
1455 });
1456 }
1457 diagnostics.requests.delete(requestId);
1458 return;
1459 }
1460 if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId);
1461 }
1462
1463 private pushBounded<A>(buffer: A[], entry: A): void {
1464 buffer.push(entry);
1465 if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) {
1466 buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT);
1467 }
1468 }
1469
1470 private async evaluateWithDebugger<A = unknown>(
1471 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
1472 expression: string,
1473 returnByValue: boolean,
1474 awaitPromise = true,
1475 ): Promise<A> {
1476 const response = (await send("Runtime.evaluate", {
1477 expression,
1478 awaitPromise,
1479 returnByValue,
1480 userGesture: true,
1481 })) as CdpEvaluationResult;
1482 if (response.exceptionDetails) {
1483 throw automationError(
1484 "PreviewAutomationExecutionError",
1485 response.exceptionDetails.exception?.description ??
1486 response.exceptionDetails.text ??
1487 "JavaScript evaluation failed.",
1488 );
1489 }
1490 return response.result?.value as A;
1491 }
1492
1493 private automationLocator(input: {
1494 readonly selector?: string | undefined;
1495 readonly locator?: string | undefined;
1496 }): string | null {
1497 if (input.locator) return input.locator;
1498 if (input.selector) return `css=${input.selector}`;
1499 return null;
1500 }
1501
1502 private async ensurePlaywrightInjected(
1503 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
1504 ): Promise<void> {
1505 const installed = await this.evaluateWithDebugger<boolean>(
1506 send,
1507 "Boolean(globalThis.__t3PlaywrightInjected)",
1508 true,
1509 );
1510 if (installed) return;
1511 const expression = await playwrightInjectedRuntimeInstallExpression();
1512 await this.evaluateWithDebugger(send, expression, true);
1513 }
1514
1515 onStateChange(listener: Listener): () => void {
1516 this.listeners.add(listener);
1517 return () => {
1518 this.listeners.delete(listener);
1519 };
1520 }
1521
1522 onPointerEvent(listener: PointerEventListener): () => void {
1523 this.pointerEventListeners.add(listener);
1524 return () => {
1525 this.pointerEventListeners.delete(listener);
1526 };
1527 }
1528
1529 private emitPointerEvent(event: DesktopPreviewPointerEvent): void {
1530 for (const listener of this.pointerEventListeners) listener(event);
1531 }
1532
1533 private expectAgentInput(tabId: string, signal: PreviewInputSignal): void {
1534 const now = Date.now();
1535 const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter(
1536 (expected) => expected.expiresAt > now,
1537 );
1538 pending.push({ signal, expiresAt: now + 1_000 });
1539 this.expectedAgentInputs.set(tabId, pending);
1540 }
1541
1542 private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean {
1543 const now = Date.now();
1544 const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter(
1545 (expected) => expected.expiresAt > now,
1546 );
1547 const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal));
1548 if (index < 0) {
1549 if (pending.length === 0) this.expectedAgentInputs.delete(tabId);
1550 else this.expectedAgentInputs.set(tabId, pending);
1551 return false;
1552 }
1553 pending.splice(index, 1);
1554 if (pending.length === 0) this.expectedAgentInputs.delete(tabId);
1555 else this.expectedAgentInputs.set(tabId, pending);
1556 return true;
1557 }
1558
1559 destroy(): void {
1560 for (const tabId of Array.from(this.tabs.keys())) {
1561 this.closeTab(tabId);
1562 }
1563 this.listeners.clear();
1564 this.expectedAgentInputs.clear();
1565 this.pointerEventListeners.clear();
1566 this.recordingFrameListeners.clear();
1567 }
1568
1569 private attachListeners(tabId: string, wc: Electron.WebContents): void {
1570 const sync = () => {
1571 if (wc.isDestroyed()) return;
1572 this.update(tabId, {
1573 navStatus: this.computeNavStatus(wc),
1574 canGoBack: wc.navigationHistory.canGoBack(),
1575 canGoForward: wc.navigationHistory.canGoForward(),
1576 });
1577 };
1578 const failed = (_event: Event, code: number, description: string): void => {
1579 // -3 = ABORTED (user navigated away mid-load); ignore.
1580 if (code === -3) return;
1581 this.update(tabId, {
1582 navStatus: {
1583 kind: "LoadFailed",
1584 url: wc.getURL(),
1585 title: wc.getTitle(),
1586 code,
1587 description,
1588 },
1589 });
1590 };
1591 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
1592 if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) {
1593 return;
1594 }
1595 this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1);
1596 this.update(tabId, { controller: "human" });
1597 void sleep(750).then(() => {
1598 if (this.tabs.get(tabId)?.controller === "human") {
1599 this.update(tabId, { controller: "none" });
1600 }
1601 });
1602 };
1603
1604 wc.on("did-navigate", sync);
1605 wc.on("did-navigate-in-page", sync);
1606 wc.on("page-title-updated", sync);
1607 wc.on("did-start-loading", sync);
1608 wc.on("did-stop-loading", sync);
1609 wc.on("did-fail-load", failed as never);
1610 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1611
1612 // Keep external links inside the same view (matches ami's policy).
1613 wc.setWindowOpenHandler(({ url }) => {
1614 void wc.loadURL(url);
1615 return { action: "deny" };
1616 });
1617
1618 // Forward app-level shortcuts to the main window so mod+shift+J etc.
1619 // still toggles the preview panel even when the webview has focus.
1620 wc.on("before-input-event", (event, input) => {
1621 if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) {
1622 event.preventDefault();
1623 this.mainWindow.webContents.sendInputEvent({
1624 type: "keyDown",
1625 keyCode: input.key,
1626 modifiers: [
1627 ...(input.meta ? (["meta"] as const) : []),
1628 ...(input.shift ? (["shift"] as const) : []),
1629 ...(input.control ? (["control"] as const) : []),
1630 ...(input.alt ? (["alt"] as const) : []),
1631 ],
1632 });
1633 }
1634 });
1635
1636 this.attached.set(wc.id, { navigate: sync, failed, humanInput });
1637 }
1638
1639 private detachListeners(webContentsId: number): void {
1640 const handlers = this.attached.get(webContentsId);
1641 if (!handlers) return;
1642 this.attached.delete(webContentsId);
1643 const wc = webContents.fromId(webContentsId);
1644 if (!wc || wc.isDestroyed()) return;
1645 wc.off("did-navigate", handlers.navigate);
1646 wc.off("did-navigate-in-page", handlers.navigate);
1647 wc.off("page-title-updated", handlers.navigate);
1648 wc.off("did-start-loading", handlers.navigate);
1649 wc.off("did-stop-loading", handlers.navigate);
1650 wc.off("did-fail-load", handlers.failed as never);
1651 wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput);
1652 }
1653
1654 private isAppShortcut(input: Electron.Input): boolean {
1655 if (input.type !== "keyDown") return false;
1656 return APP_FORWARDED_SHORTCUTS.some(
1657 (shortcut) =>
1658 shortcut.key.toLowerCase() === input.key.toLowerCase() &&
1659 shortcut.meta === input.meta &&
1660 shortcut.shift === input.shift &&
1661 shortcut.control === input.control,
1662 );
1663 }
1664
1665 private computeNavStatus(wc: Electron.WebContents): PreviewNavStatus {
1666 const url = wc.getURL();
1667 const title = wc.getTitle();
1668 if (url === "" || url === "about:blank") return { kind: "Idle" };
1669 if (wc.isLoading()) return { kind: "Loading", url, title };
1670 return { kind: "Success", url, title };
1671 }
1672
1673 private requireWebContents(tabId: string): Electron.WebContents {
1674 const tab = this.tabs.get(tabId);
1675 if (!tab) throw new PreviewTabNotFoundError(tabId);
1676 if (tab.webContentsId == null) throw new PreviewWebviewNotInitializedError(tabId);
1677 const wc = webContents.fromId(tab.webContentsId);
1678 if (!wc) throw new PreviewWebContentsNotFoundError(tabId, tab.webContentsId);
1679 return wc;
1680 }
1681
1682 private update(tabId: string, patch: Partial<PreviewTabState>): void {
1683 const current = this.tabs.get(tabId);
1684 if (!current) return;
1685 const next: PreviewTabState = {
1686 ...current,
1687 ...patch,
1688 updatedAt: new Date().toISOString(),
1689 };
1690 this.tabs.set(tabId, next);
1691 this.emit(tabId, next);
1692 }
1693
1694 private emit(tabId: string, state: PreviewTabState): void {
1695 for (const listener of this.listeners) {
1696 try {
1697 listener(tabId, state);
1698 } catch {
1699 // listener errors must not crash the manager
1700 }
1701 }
1702 }
1703
1704 private normalizeUrl(input: string): string {
1705 // Surface the shared error directly so the IPC caller (and any future
1706 // desktop-side telemetry) gets the `detail` field for free. Defining a
1707 // bespoke desktop class would just lose information.
1708 return normalizePreviewUrl(input);
1709 }
1710}
1711
1712export class PreviewTabNotFoundError extends Error {
1713 readonly tabId: string;
1714 constructor(tabId: string) {
1715 super(`Preview tab not found: ${tabId}`);
1716 this.name = "PreviewTabNotFoundError";
1717 this.tabId = tabId;
1718 }
1719}
1720
1721export class PreviewWebContentsNotFoundError extends Error {
1722 readonly tabId: string;
1723 readonly webContentsId: number;
1724 constructor(tabId: string, webContentsId: number) {
1725 super(`WebContents ${webContentsId} not found for preview tab ${tabId}`);
1726 this.name = "PreviewWebContentsNotFoundError";
1727 this.tabId = tabId;
1728 this.webContentsId = webContentsId;
1729 }
1730}
1731
1732export class PreviewWebviewNotInitializedError extends Error {
1733 readonly tabId: string;
1734 constructor(tabId: string) {
1735 super(`Preview tab "${tabId}" has no webview registered`);
1736 this.name = "PreviewWebviewNotInitializedError";
1737 this.tabId = tabId;
1738 }
1739}
1740
1741export const previewViewManager = new PreviewViewManager();
apps/desktop/src/preview-webview-preferences.test.ts deleted-78
......@@ -1,78 +0,0 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { PREVIEW_WEBVIEW_PREFERENCES } from "./preview-webview-preferences.ts";
4
5/**
6 * Mirrors Electron's webview attribute parser closely enough to catch the
7 * regressions we've already hit:
8 *
9 * - whitespace inside the comma-separated list silently drops keys (so
10 * `" sandbox=true"` becomes an unknown key and Electron falls back to
11 * defaults — re-opening the Node-leak window we closed),
12 * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy
13 * strings and assigned to a boolean preference, which silently flips
14 * `contextIsolation=no` to ENABLED (then react-grab can't see the React
15 * DevTools hook and componentName resolution always returns null).
16 *
17 * The actual Electron parser does roughly:
18 *
19 * for (const pair of webpreferences.split(',')) {
20 * const [key, value] = pair.split('=');
21 * prefs[key] = value; // value left as a string
22 * }
23 *
24 * then later coerces booleans via `Boolean(value)`. Replicating that here
25 * keeps the test independent of Electron internals while still failing if
26 * we accidentally ship `"contextIsolation=no"` again.
27 */
28function parseWebPreferences(input: string): Record<string, unknown> {
29 const out: Record<string, unknown> = {};
30 for (const pair of input.split(",")) {
31 if (pair !== pair.trim()) {
32 // Electron's parser doesn't trim; surface the bug as undefined-key.
33 out[pair] = pair.split("=")[1];
34 continue;
35 }
36 const [key, value] = pair.split("=");
37 if (!key) continue;
38 out[key] = value;
39 }
40 return out;
41}
42
43describe("PREVIEW_WEBVIEW_PREFERENCES", () => {
44 const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES);
45
46 it("contains exactly the three security-critical keys", () => {
47 expect(Object.keys(parsed).toSorted()).toEqual(
48 ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(),
49 );
50 });
51
52 it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => {
53 // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so
54 // `contextIsolation="no"` would silently leave isolation ENABLED. Lock
55 // the values to `"true"` / `"false"` so the parser does the right thing.
56 for (const value of Object.values(parsed)) {
57 expect(value).toMatch(/^(true|false)$/);
58 }
59 });
60
61 it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => {
62 expect(parsed["contextIsolation"]).toBe("false");
63 });
64
65 it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => {
66 expect(parsed["sandbox"]).toBe("true");
67 });
68
69 it("disables nodeIntegration (defense in depth — page never gets Node)", () => {
70 expect(parsed["nodeIntegration"]).toBe("false");
71 });
72
73 it("contains no whitespace (Electron's parser does not trim)", () => {
74 // Electron splits on `,` without trimming, so any whitespace would turn
75 // a key into an unknown one and silently drop the security flag.
76 expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/);
77 });
78});
apps/desktop/src/preview-webview-preferences.ts deleted-42
......@@ -1,42 +0,0 @@
1/**
2 * webPreferences override applied to every preview `<webview>` element via
3 * its `webpreferences="..."` attribute. Single source of truth so all guest
4 * surfaces inherit the same security posture.
5 *
6 * Lives in its own electron-free module so the value is unit-testable
7 * without importing `preview-view-manager.ts` (which transitively imports
8 * `electron` and blows up under vitest).
9 *
10 * - `contextIsolation=false`: the picker preload needs to share `globalThis`
11 * with the page so react-grab/bippy can read the React DevTools hook
12 * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without
13 * this every pick comes back with `componentName: null` even on dev React
14 * apps.
15 * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical
16 * when paired with `contextIsolation=false` — without sandbox, the preload
17 * has full Node access (`require`, `fs`, `child_process`, ...) and that
18 * `require` would land on the page's shared `globalThis`, giving any
19 * third-party page in the preview full Node + IPC access to the host.
20 * In sandboxed mode Electron still synthesizes the `electron` module for
21 * the preload's `import { ipcRenderer }` line, but no Node globals leak.
22 * - `nodeIntegration=false`: pinned for clarity (the page itself never gets
23 * Node access).
24 *
25 * Format notes (locked down by `preview-webview-preferences.test.ts`):
26 * - Whitespace-free. Electron's webpreferences parser splits on `,` and
27 * does not trim, so a leading space would turn a key into an unknown one
28 * and silently drop it.
29 * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not
30 * special-cased by the parser; `value="no"` becomes the truthy STRING
31 * `"no"` when assigned to a boolean webPreferences key. Most critically,
32 * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED →
33 * react-grab can't see the React DevTools hook.
34 *
35 * Defense in depth: `apps/desktop/src/main.ts` also runs a
36 * `will-attach-webview` handler that force-sets `sandbox: true` and
37 * `nodeIntegration*: false` on the actual webPreferences object, gated on
38 * the preview partition, so even if this string is ever wrong, the
39 * security-critical flags can't regress on preview tabs.
40 */
41export const PREVIEW_WEBVIEW_PREFERENCES =
42 "contextIsolation=false,sandbox=true,nodeIntegration=false";
apps/desktop/src/preview/Annotation.css created+68
......@@ -0,0 +1,68 @@
1@import "tailwindcss";
2
3@theme inline {
4 --font-sans: var(--t3-font-sans);
5 --font-mono: var(--t3-font-mono);
6 --color-background: var(--t3-background);
7 --color-foreground: var(--t3-foreground);
8 --color-popover: var(--t3-popover);
9 --color-popover-foreground: var(--t3-popover-foreground);
10 --color-primary: var(--t3-primary);
11 --color-primary-foreground: var(--t3-primary-foreground);
12 --color-muted: var(--t3-muted);
13 --color-muted-foreground: var(--t3-muted-foreground);
14 --color-accent: var(--t3-accent);
15 --color-accent-foreground: var(--t3-accent-foreground);
16 --color-border: var(--t3-border);
17 --color-input: var(--t3-input);
18 --color-ring: var(--t3-ring);
19 --radius-sm: calc(var(--t3-radius) - 4px);
20 --radius-md: calc(var(--t3-radius) - 2px);
21 --radius-lg: var(--t3-radius);
22 --radius-xl: calc(var(--t3-radius) + 4px);
23 --radius-2xl: calc(var(--t3-radius) + 8px);
24}
25
26:host {
27 --t3-font-sans:
28 "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
29 sans-serif;
30 --t3-font-mono:
31 "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;
32 --t3-radius: 0.625rem;
33 --t3-background: white;
34 --t3-foreground: oklch(0.269 0 0);
35 --t3-popover: white;
36 --t3-popover-foreground: oklch(0.269 0 0);
37 --t3-primary: oklch(0.488 0.217 264);
38 --t3-primary-foreground: white;
39 --t3-muted: rgb(0 0 0 / 4%);
40 --t3-muted-foreground: oklch(0.556 0 0);
41 --t3-accent: rgb(0 0 0 / 4%);
42 --t3-accent-foreground: oklch(0.269 0 0);
43 --t3-border: rgb(0 0 0 / 8%);
44 --t3-input: rgb(0 0 0 / 10%);
45 --t3-ring: oklch(0.488 0.217 264);
46 color: var(--t3-foreground);
47 font-family: var(--t3-font-sans);
48}
49
50* {
51 box-sizing: border-box;
52 border-color: var(--t3-border);
53}
54
55button,
56input,
57select,
58textarea {
59 font: inherit;
60}
61
62button:focus-visible,
63input:focus-visible,
64select:focus-visible,
65textarea:focus-visible {
66 outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);
67 outline-offset: 1px;
68}
apps/desktop/src/preview/AnnotationStyles.generated.ts created+3
......@@ -0,0 +1,3 @@
1// Generated by scripts/build-preview-annotation-css.mjs. Do not edit.
2export const previewAnnotationStyles =
3 '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "<length>";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "<percentage>";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n';
apps/desktop/src/preview/BrowserSession.ts created+93
......@@ -0,0 +1,93 @@
1import type { Session } from "electron";
2import { session } from "electron";
3import { createHash } from "node:crypto";
4import * as Context from "effect/Context";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8
9const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
10
11export class BrowserSessionError extends Data.TaggedError("BrowserSessionError")<{
12 readonly operation: string;
13 readonly cause: unknown;
14}> {
15 override get message() {
16 return `Desktop preview browser session operation failed: ${this.operation}`;
17 }
18}
19
20export interface BrowserSessionShape {
21 readonly getPartition: (scope?: string) => string;
22 readonly isPartition: (partition: string) => boolean;
23 readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionError>;
24 readonly clearCookies: () => Effect.Effect<void, BrowserSessionError>;
25 readonly clearCache: () => Effect.Effect<void, BrowserSessionError>;
26}
27
28export class BrowserSession extends Context.Service<BrowserSession, BrowserSessionShape>()(
29 "@t3tools/desktop/preview/BrowserSession",
30) {}
31
32const make = Effect.fn("BrowserSession.make")(() =>
33 Effect.sync(() => {
34 const sessions = new Map<string, Session>();
35 const getPartition = (scope = "shared"): string => {
36 const digest = createHash("sha256").update(scope).digest("hex").slice(0, 20);
37 return `${PREVIEW_PARTITION_PREFIX}${digest}`;
38 };
39
40 const getSession = Effect.fn("BrowserSession.getSession")(function* (scope = "shared") {
41 const partition = getPartition(scope);
42 const existing = sessions.get(partition);
43 if (existing) return existing;
44 return yield* Effect.try({
45 try: () => {
46 const browserSession = session.fromPartition(partition);
47 const userAgent = browserSession
48 .getUserAgent()
49 .replace(/Electron\/[\d.]+ /, "")
50 .replace(/\s*t3code\/[\d.]+/, "");
51 browserSession.setUserAgent(userAgent);
52 browserSession.setPermissionRequestHandler((_webContents, permission, callback) => {
53 const allowed = ["clipboard-read", "clipboard-write", "notifications", "geolocation"];
54 callback(allowed.includes(permission));
55 });
56 sessions.set(partition, browserSession);
57 return browserSession;
58 },
59 catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }),
60 });
61 });
62
63 return BrowserSession.of({
64 getPartition,
65 isPartition: (partition) => partition.startsWith(PREVIEW_PARTITION_PREFIX),
66 getSession,
67 clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () {
68 yield* Effect.tryPromise({
69 try: () =>
70 Promise.all(
71 [...sessions.values()].map((browserSession) =>
72 browserSession.clearStorageData({
73 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],
74 }),
75 ),
76 ),
77 catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }),
78 });
79 }),
80 clearCache: Effect.fn("BrowserSession.clearCache")(function* () {
81 yield* Effect.tryPromise({
82 try: () =>
83 Promise.all(
84 [...sessions.values()].map((browserSession) => browserSession.clearCache()),
85 ),
86 catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }),
87 });
88 }),
89 });
90 }),
91);
92
93export const layer = Layer.effect(BrowserSession, make());
apps/desktop/src/preview/GuestProtocol.ts created+6
......@@ -0,0 +1,6 @@
1export const START_PICK_CHANNEL = "preview:start-pick";
2export const CANCEL_PICK_CHANNEL = "preview:cancel-pick";
3export const ELEMENT_PICKED_CHANNEL = "preview:element-picked";
4export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured";
5export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme";
6export const HUMAN_INPUT_CHANNEL = "preview:human-input";
apps/desktop/src/preview/Manager.test.ts created+283
......@@ -0,0 +1,283 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const fromId = vi.fn(() => null);
4const mkdir = vi.fn(async () => undefined);
5const writeFile = vi.fn(async () => undefined);
6const showItemInFolder = vi.fn();
7const writeImage = vi.fn();
8const createFromPath = vi.fn(() => ({ isEmpty: () => false }));
9const webviewSend = vi.fn();
10
11vi.mock("node:fs/promises", () => ({ mkdir, writeFile }));
12
13vi.mock("electron", () => ({
14 clipboard: {
15 writeImage,
16 },
17 nativeImage: {
18 createFromPath,
19 },
20 session: {
21 fromPartition: vi.fn(),
22 },
23 shell: {
24 showItemInFolder,
25 },
26 webContents: {
27 fromId,
28 },
29}));
30
31describe("PreviewViewManager automation status", () => {
32 beforeEach(() => {
33 fromId.mockClear();
34 mkdir.mockClear();
35 writeFile.mockClear();
36 showItemInFolder.mockClear();
37 writeImage.mockClear();
38 createFromPath.mockClear();
39 webviewSend.mockClear();
40 });
41
42 it("reports an unregistered webview as temporarily unavailable", async () => {
43 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
44 const manager = new PreviewViewManager();
45
46 expect(manager.automationStatus("tab_1")).toEqual({
47 available: false,
48 visible: true,
49 tabId: "tab_1",
50 url: null,
51 title: null,
52 loading: false,
53 });
54
55 manager.createTab("tab_1");
56
57 expect(manager.automationStatus("tab_1")).toEqual({
58 available: false,
59 visible: true,
60 tabId: "tab_1",
61 url: null,
62 title: null,
63 loading: false,
64 });
65 expect(fromId).not.toHaveBeenCalled();
66 });
67
68 it("captures a PNG screenshot into browser artifacts", async () => {
69 const png = Buffer.from("preview-png");
70 const capturePage = vi.fn(async () => ({ toPNG: () => png }));
71 const listeners = new Map<string, (...args: never[]) => void>();
72 fromId.mockReturnValue({
73 id: 42,
74 isDestroyed: () => false,
75 getType: () => "webview",
76 getURL: () => "https://example.com:8443/path?query=value",
77 getTitle: () => "Example",
78 isLoading: () => false,
79 getZoomFactor: () => 1,
80 setZoomFactor: vi.fn(),
81 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
82 listeners.set(event, listener);
83 }),
84 off: vi.fn(),
85 ipc: { on: vi.fn(), off: vi.fn() },
86 send: webviewSend,
87 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
88 setWindowOpenHandler: vi.fn(),
89 debugger: {
90 isAttached: () => false,
91 attach: vi.fn(),
92 sendCommand: vi.fn(async () => undefined),
93 on: vi.fn(),
94 off: vi.fn(),
95 },
96 capturePage,
97 } as never);
98 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
99 const manager = new PreviewViewManager();
100 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
101 manager.createTab("tab_1");
102 manager.registerWebview("tab_1", 42);
103
104 expect(webviewSend).toHaveBeenCalledWith(
105 "preview:annotation-theme",
106 expect.objectContaining({
107 colorScheme: "light",
108 primary: "oklch(0.488 0.217 264)",
109 }),
110 );
111
112 const artifact = await manager.captureScreenshot("tab_1");
113
114 expect(capturePage).toHaveBeenCalledOnce();
115 expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts", {
116 recursive: true,
117 });
118 expect(writeFile).toHaveBeenCalledWith(artifact.path, png);
119 expect(artifact).toMatchObject({
120 tabId: "tab_1",
121 mimeType: "image/png",
122 sizeBytes: png.byteLength,
123 });
124 expect(artifact.path).toMatch(
125 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
126 );
127 });
128
129 it("reveals only files inside the configured browser artifact directory", async () => {
130 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
131 const manager = new PreviewViewManager();
132 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
133
134 manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png");
135
136 expect(showItemInFolder).toHaveBeenCalledWith(
137 "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png",
138 );
139 expect(() => manager.revealArtifact("/tmp/t3/dev/settings.json")).toThrow(
140 "outside the configured artifact directory",
141 );
142 });
143
144 it("copies screenshot artifacts to the system clipboard", async () => {
145 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
146 const manager = new PreviewViewManager();
147 manager.configureArtifactDirectory("/tmp/t3/dev/browser-artifacts");
148 const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png";
149
150 manager.copyArtifactToClipboard(artifactPath);
151
152 expect(createFromPath).toHaveBeenCalledWith(artifactPath);
153 expect(writeImage).toHaveBeenCalledOnce();
154 expect(() => manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json")).toThrow(
155 "outside the configured artifact directory",
156 );
157 });
158
159 it("emits the resolved pointer target before dispatching an automation click", async () => {
160 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
161 const activity: string[] = [];
162 const sendCommand = vi.fn(async (method: string, params?: Record<string, unknown>) => {
163 if (method === "Runtime.evaluate") {
164 return {
165 result: {
166 value: { width: 800, height: 600 },
167 },
168 };
169 }
170 if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") {
171 activity.push("mousePressed");
172 humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 });
173 }
174 return undefined;
175 });
176 fromId.mockReturnValue({
177 id: 42,
178 isDestroyed: () => false,
179 getType: () => "webview",
180 getURL: () => "https://example.com",
181 getTitle: () => "Example",
182 isLoading: () => false,
183 isDevToolsOpened: () => false,
184 getZoomFactor: () => 1,
185 setZoomFactor: vi.fn(),
186 on: vi.fn(),
187 off: vi.fn(),
188 ipc: {
189 on: vi.fn((channel: string, listener: typeof humanInput) => {
190 if (channel === "preview:human-input") humanInput = listener;
191 }),
192 off: vi.fn(),
193 },
194 send: webviewSend,
195 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
196 setWindowOpenHandler: vi.fn(),
197 debugger: {
198 isAttached: () => false,
199 attach: vi.fn(),
200 sendCommand,
201 on: vi.fn(),
202 off: vi.fn(),
203 },
204 } as never);
205 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
206 const manager = new PreviewViewManager();
207 manager.onPointerEvent((event) => activity.push(event.phase));
208 manager.createTab("tab_1");
209 manager.registerWebview("tab_1", 42);
210
211 await manager.automationClick("tab_1", { x: 120, y: 80 });
212
213 expect(activity).toEqual(["move", "click", "mousePressed"]);
214 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
215 type: "mousePressed",
216 x: 120,
217 y: 80,
218 button: "left",
219 clickCount: 1,
220 });
221 expect(sendCommand).toHaveBeenCalledWith("Input.dispatchMouseEvent", {
222 type: "mouseReleased",
223 x: 120,
224 y: 80,
225 button: "left",
226 clickCount: 1,
227 });
228 });
229
230 it("still interrupts agent control for a different human pointer event", async () => {
231 let humanInput: ((_event: unknown, signal: unknown) => void) | undefined;
232 const sendCommand = vi.fn(async (method: string) => {
233 if (method === "Runtime.evaluate") {
234 return {
235 result: {
236 value: { width: 800, height: 600 },
237 },
238 };
239 }
240 if (method === "Input.dispatchMouseEvent") {
241 humanInput?.({}, { kind: "pointer", x: 400, y: 300, button: 0 });
242 }
243 return undefined;
244 });
245 fromId.mockReturnValue({
246 id: 42,
247 isDestroyed: () => false,
248 getType: () => "webview",
249 getURL: () => "https://example.com",
250 getTitle: () => "Example",
251 isLoading: () => false,
252 isDevToolsOpened: () => false,
253 getZoomFactor: () => 1,
254 setZoomFactor: vi.fn(),
255 on: vi.fn(),
256 off: vi.fn(),
257 ipc: {
258 on: vi.fn((channel: string, listener: typeof humanInput) => {
259 if (channel === "preview:human-input") humanInput = listener;
260 }),
261 off: vi.fn(),
262 },
263 send: webviewSend,
264 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
265 setWindowOpenHandler: vi.fn(),
266 debugger: {
267 isAttached: () => false,
268 attach: vi.fn(),
269 sendCommand,
270 on: vi.fn(),
271 off: vi.fn(),
272 },
273 } as never);
274 const { PreviewViewManager } = (await import("./Manager.ts")).__testing;
275 const manager = new PreviewViewManager();
276 manager.createTab("tab_1");
277 manager.registerWebview("tab_1", 42);
278
279 await expect(manager.automationClick("tab_1", { x: 120, y: 80 })).rejects.toMatchObject({
280 name: "PreviewAutomationControlInterruptedError",
281 });
282 });
283});
apps/desktop/src/preview/Manager.ts created+1949
......@@ -0,0 +1,1949 @@
1// @effect-diagnostics globalDate:off
2// @effect-diagnostics nodeBuiltinImport:off
3/**
4 * PreviewViewManager — desktop side of the in-app browser preview.
5 *
6 * Hosts per-tab Chromium WebContents references (the actual <webview>
7 * elements live in the renderer; we only attach listeners and forward state
8 * here). Single layer-scoped browser session partition.
9 */
10import type {
11 DesktopPreviewAnnotationTheme,
12 DesktopPreviewPointerEvent,
13 PreviewAnnotationPayload,
14 PreviewAnnotationRect,
15 DesktopPreviewRecordingArtifact,
16 DesktopPreviewRecordingFrame,
17 DesktopPreviewScreenshotArtifact,
18 PreviewAutomationClickInput,
19 PreviewAutomationActionEvent,
20 PreviewAutomationConsoleEntry,
21 PreviewAutomationEvaluateInput,
22 PreviewAutomationPressInput,
23 PreviewAutomationNetworkEntry,
24 PreviewAutomationScrollInput,
25 PreviewAutomationSnapshot,
26 PreviewAutomationStatus,
27 PreviewAutomationTypeInput,
28 PreviewAutomationWaitForInput,
29} from "@t3tools/contracts";
30import { normalizePreviewUrl } from "@t3tools/shared/preview";
31import {
32 type BrowserWindow,
33 type Session,
34 clipboard,
35 nativeImage,
36 shell,
37 webContents,
38} from "electron";
39import { mkdir, writeFile } from "node:fs/promises";
40import { isAbsolute, join, relative, resolve, sep } from "node:path";
41import { setTimeout as sleep } from "node:timers/promises";
42import * as Context from "effect/Context";
43import * as Data from "effect/Data";
44import * as Effect from "effect/Effect";
45import * as Layer from "effect/Layer";
46import type * as Scope from "effect/Scope";
47
48import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
49import * as BrowserSession from "./BrowserSession.ts";
50import {
51 ANNOTATION_CAPTURED_CHANNEL,
52 ANNOTATION_THEME_CHANNEL,
53 CANCEL_PICK_CHANNEL,
54 ELEMENT_PICKED_CHANNEL,
55 HUMAN_INPUT_CHANNEL,
56 START_PICK_CHANNEL,
57} from "./GuestProtocol.ts";
58import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
59import { playwrightInjectedRuntimeInstallExpression } from "./PlaywrightInjectedRuntime.ts";
60
61export type PreviewNavStatus =
62 | { kind: "Idle" }
63 | { kind: "Loading"; url: string; title: string }
64 | { kind: "Success"; url: string; title: string }
65 | {
66 kind: "LoadFailed";
67 url: string;
68 title: string;
69 code: number;
70 description: string;
71 };
72
73export interface PreviewTabState {
74 tabId: string;
75 webContentsId: number | null;
76 navStatus: PreviewNavStatus;
77 canGoBack: boolean;
78 canGoForward: boolean;
79 zoomFactor: number;
80 controller: "human" | "agent" | "none";
81 updatedAt: string;
82}
83
84/** Discrete zoom levels mirroring Chrome's preset list. */
85const ZOOM_LEVELS: ReadonlyArray<number> = [
86 0.25, 0.33, 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0,
87];
88
89const DEFAULT_ZOOM_FACTOR = 1.0;
90const ZOOM_EPSILON = 0.001;
91const MAX_EVALUATION_BYTES = 64_000;
92const MAX_VISIBLE_TEXT_LENGTH = 20_000;
93const MAX_INTERACTIVE_ELEMENTS = 200;
94const MAX_SCREENSHOT_WIDTH = 1280;
95const DIAGNOSTIC_BUFFER_LIMIT = 200;
96const MAX_ARTIFACT_SITE_SLUG_LENGTH = 80;
97const AGENT_CURSOR_MOVE_MS = 160;
98const AGENT_CURSOR_CLICK_LEAD_MS = 40;
99const DEFAULT_ANNOTATION_THEME: DesktopPreviewAnnotationTheme = {
100 colorScheme: "light",
101 radius: "0.625rem",
102 background: "white",
103 foreground: "oklch(0.269 0 0)",
104 popover: "white",
105 popoverForeground: "oklch(0.269 0 0)",
106 primary: "oklch(0.488 0.217 264)",
107 primaryForeground: "white",
108 muted: "rgb(0 0 0 / 4%)",
109 mutedForeground: "oklch(0.556 0 0)",
110 accent: "rgb(0 0 0 / 4%)",
111 accentForeground: "oklch(0.269 0 0)",
112 border: "rgb(0 0 0 / 8%)",
113 input: "rgb(0 0 0 / 10%)",
114 ring: "oklch(0.488 0.217 264)",
115 fontSans: "system-ui, sans-serif",
116 fontMono: "ui-monospace, monospace",
117};
118
119const artifactSiteSlug = (rawUrl: string): string => {
120 try {
121 const url = new URL(rawUrl);
122 const slug = url.hostname
123 .toLowerCase()
124 .replace(/[^a-z0-9]+/g, "-")
125 .replace(/^-+|-+$/g, "")
126 .slice(0, MAX_ARTIFACT_SITE_SLUG_LENGTH)
127 .replace(/-+$/g, "");
128 return slug || "site";
129 } catch {
130 return "site";
131 }
132};
133
134interface CdpEvaluationResult {
135 readonly result?: {
136 readonly value?: unknown;
137 readonly description?: string;
138 };
139 readonly exceptionDetails?: {
140 readonly text?: string;
141 readonly exception?: { readonly description?: string };
142 };
143}
144
145const automationError = (
146 tag:
147 | "PreviewAutomationExecutionError"
148 | "PreviewAutomationInvalidSelectorError"
149 | "PreviewAutomationResultTooLargeError"
150 | "PreviewAutomationTimeoutError"
151 | "PreviewAutomationControlInterruptedError",
152 message: string,
153 detail?: unknown,
154): Error & { detail?: unknown } => {
155 const error = new Error(message) as Error & { detail?: unknown };
156 error.name = tag;
157 if (detail !== undefined) error.detail = detail;
158 return error;
159};
160
161const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => {
162 if (typeof value !== "object" || value === null) return null;
163 const rect = value as Record<string, unknown>;
164 const x = rect["x"];
165 const y = rect["y"];
166 const width = rect["width"];
167 const height = rect["height"];
168 if (
169 typeof x !== "number" ||
170 !Number.isFinite(x) ||
171 typeof y !== "number" ||
172 !Number.isFinite(y) ||
173 typeof width !== "number" ||
174 !Number.isFinite(width) ||
175 typeof height !== "number" ||
176 !Number.isFinite(height) ||
177 width <= 0 ||
178 height <= 0
179 ) {
180 return null;
181 }
182 return {
183 x: Math.max(0, Math.floor(x)),
184 y: Math.max(0, Math.floor(y)),
185 width: Math.max(1, Math.ceil(width)),
186 height: Math.max(1, Math.ceil(height)),
187 };
188};
189
190const captureAnnotationScreenshot = async (
191 wc: Electron.WebContents,
192 cropRect: PreviewAnnotationRect | null,
193) => {
194 const image = await wc.capturePage(
195 cropRect
196 ? {
197 x: cropRect.x,
198 y: cropRect.y,
199 width: cropRect.width,
200 height: cropRect.height,
201 }
202 : undefined,
203 );
204 const size = image.getSize();
205 return {
206 dataUrl: image.toDataURL(),
207 width: size.width,
208 height: size.height,
209 cropRect: cropRect ?? { x: 0, y: 0, width: size.width, height: size.height },
210 };
211};
212
213const findZoomStep = (current: number): number => {
214 for (let index = 0; index < ZOOM_LEVELS.length; index += 1) {
215 if (Math.abs(ZOOM_LEVELS[index]! - current) < ZOOM_EPSILON) return index;
216 if (ZOOM_LEVELS[index]! > current) return index - 1;
217 }
218 return ZOOM_LEVELS.length - 1;
219};
220
221const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
222 const step = findZoomStep(current);
223 if (direction === "in") {
224 return ZOOM_LEVELS[Math.min(step + 1, ZOOM_LEVELS.length - 1)] ?? current;
225 }
226 return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current;
227};
228
229type Listener = (tabId: string, state: PreviewTabState) => void;
230type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void;
231
232type PreviewInputSignal =
233 | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number }
234 | { readonly kind: "key"; readonly key: string; readonly code: string };
235
236interface ManagedListeners {
237 navigate: () => void;
238 failed: (event: Event, code: number, description: string) => void;
239 humanInput: (_event: unknown, signal?: unknown) => void;
240 beforeInput: (event: Electron.Event, input: Electron.Input) => void;
241}
242
243interface PickSession {
244 readonly resolve: (payload: PreviewAnnotationPayload | null) => void;
245 readonly cleanup: () => void;
246}
247
248interface BrowserControlSession {
249 readonly webContentsId: number;
250 tail: Promise<void>;
251 initialized: Promise<void>;
252 readonly onMessage: (
253 event: Electron.Event,
254 method: string,
255 params: Record<string, unknown>,
256 ) => void;
257}
258
259interface BrowserDiagnostics {
260 readonly consoleEntries: PreviewAutomationConsoleEntry[];
261 readonly networkEntries: PreviewAutomationNetworkEntry[];
262 readonly requests: Map<string, { url: string; method: string }>;
263}
264
265type PointerEventListener = (event: DesktopPreviewPointerEvent) => void;
266
267interface ExpectedAgentInput {
268 readonly signal: PreviewInputSignal;
269 readonly expiresAt: number;
270}
271
272const APP_FORWARDED_SHORTCUTS: ReadonlyArray<{
273 key: string;
274 meta: boolean;
275 shift: boolean;
276 control: boolean;
277}> = Object.freeze([
278 // mod+shift+J → preview.toggle
279 { key: "j", meta: true, shift: true, control: false },
280 // mod+K → command palette
281 { key: "k", meta: true, shift: false, control: false },
282 // mod+, → settings (macOS convention)
283 { key: ",", meta: true, shift: false, control: false },
284 // mod+W → close tab/panel
285 { key: "w", meta: true, shift: false, control: false },
286]);
287
288const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
289 if (typeof value !== "object" || value === null || !("kind" in value)) return false;
290 if (value.kind === "pointer") {
291 return (
292 "x" in value &&
293 typeof value.x === "number" &&
294 "y" in value &&
295 typeof value.y === "number" &&
296 "button" in value &&
297 typeof value.button === "number"
298 );
299 }
300 return (
301 value.kind === "key" &&
302 "key" in value &&
303 typeof value.key === "string" &&
304 "code" in value &&
305 typeof value.code === "string"
306 );
307};
308
309const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => {
310 if (left.kind !== right.kind) return false;
311 if (left.kind === "pointer" && right.kind === "pointer") {
312 return (
313 Math.abs(left.x - right.x) <= 1 &&
314 Math.abs(left.y - right.y) <= 1 &&
315 left.button === right.button
316 );
317 }
318 return (
319 left.kind === "key" &&
320 right.kind === "key" &&
321 left.key === right.key &&
322 left.code === right.code
323 );
324};
325
326class PreviewViewManager {
327 private annotationTheme = DEFAULT_ANNOTATION_THEME;
328 private artifactDirectory: string | null = null;
329 private mainWindow: BrowserWindow | null = null;
330 private readonly tabs = new Map<string, PreviewTabState>();
331 private readonly attached = new Map<number, ManagedListeners>();
332 private readonly listeners = new Set<Listener>();
333 private readonly pointerEventListeners = new Set<PointerEventListener>();
334 private readonly recordingFrameListeners = new Set<RecordingFrameListener>();
335 /** In-flight preview annotation sessions, keyed by tabId. */
336 private readonly pickSessions = new Map<string, PickSession>();
337 /** One long-lived CDP attachment and serialized command queue per guest. */
338 private readonly controlSessions = new Map<number, BrowserControlSession>();
339 private readonly diagnostics = new Map<number, BrowserDiagnostics>();
340 private readonly expectedAgentInputs = new Map<string, ExpectedAgentInput[]>();
341 private readonly controlEpoch = new Map<string, number>();
342 private readonly actionTimeline = new Map<string, PreviewAutomationActionEvent[]>();
343 private actionSequence = 0;
344 private pointerSequence = 0;
345 private recordingTabId: string | null = null;
346
347 configureArtifactDirectory(directory: string): void {
348 this.artifactDirectory = resolve(directory);
349 }
350
351 private requireArtifactDirectory(): string {
352 if (!this.artifactDirectory) {
353 throw new Error("Preview artifact directory is not configured.");
354 }
355 return this.artifactDirectory;
356 }
357
358 private resolveArtifactPath(path: string): string {
359 const directory = this.requireArtifactDirectory();
360 const resolvedPath = resolve(path);
361 const relativePath = relative(directory, resolvedPath);
362 if (
363 relativePath.length === 0 ||
364 relativePath === ".." ||
365 relativePath.startsWith(`..${sep}`) ||
366 isAbsolute(relativePath)
367 ) {
368 throw new Error("Preview artifact path is outside the configured artifact directory.");
369 }
370 return resolvedPath;
371 }
372
373 revealArtifact(path: string): void {
374 const resolvedPath = this.resolveArtifactPath(path);
375 shell.showItemInFolder(resolvedPath);
376 }
377
378 copyArtifactToClipboard(path: string): void {
379 const resolvedPath = this.resolveArtifactPath(path);
380 const image = nativeImage.createFromPath(resolvedPath);
381 if (image.isEmpty()) {
382 throw new Error("Preview artifact could not be loaded as an image.");
383 }
384 clipboard.writeImage(image);
385 }
386
387 setAnnotationTheme(theme: DesktopPreviewAnnotationTheme): void {
388 this.annotationTheme = theme;
389 for (const tab of this.tabs.values()) {
390 if (tab.webContentsId == null) continue;
391 const wc = webContents.fromId(tab.webContentsId);
392 if (!wc || wc.isDestroyed()) continue;
393 wc.send(ANNOTATION_THEME_CHANNEL, theme);
394 }
395 }
396
397 setMainWindow(window: BrowserWindow): void {
398 this.mainWindow = window;
399 }
400
401 createTab(tabId: string): PreviewTabState {
402 const existing = this.tabs.get(tabId);
403 if (existing) return existing;
404 const initial: PreviewTabState = {
405 tabId,
406 webContentsId: null,
407 navStatus: { kind: "Idle" },
408 canGoBack: false,
409 canGoForward: false,
410 zoomFactor: DEFAULT_ZOOM_FACTOR,
411 controller: "none",
412 updatedAt: new Date().toISOString(),
413 };
414 this.tabs.set(tabId, initial);
415 this.emit(tabId, initial);
416 return initial;
417 }
418
419 closeTab(tabId: string): void {
420 const tab = this.tabs.get(tabId);
421 if (!tab) return;
422 this.cancelPickElement(tabId);
423 if (tab.webContentsId != null) {
424 this.detachControlSession(tab.webContentsId);
425 this.detachListeners(tab.webContentsId);
426 }
427 const closed: PreviewTabState = {
428 ...tab,
429 webContentsId: null,
430 navStatus: { kind: "Idle" },
431 canGoBack: false,
432 canGoForward: false,
433 zoomFactor: DEFAULT_ZOOM_FACTOR,
434 controller: "none",
435 updatedAt: new Date().toISOString(),
436 };
437 this.tabs.delete(tabId);
438 this.emit(tabId, closed);
439 }
440
441 registerWebview(tabId: string, webContentsId: number): void {
442 const tab = this.tabs.get(tabId);
443 if (!tab) {
444 throw new PreviewTabNotFoundError(tabId);
445 }
446 const wc = webContents.fromId(webContentsId);
447 if (!wc) {
448 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
449 }
450 // Defence in depth: a malicious renderer could otherwise trick us into
451 // attaching listeners to the main window's WebContents (or any other
452 // process) by passing an arbitrary id.
453 if (wc.getType() !== "webview") {
454 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
455 }
456 if (this.mainWindow && wc.hostWebContents !== this.mainWindow.webContents) {
457 throw new PreviewWebContentsNotFoundError(tabId, webContentsId);
458 }
459 if (tab.webContentsId === webContentsId && this.attached.has(webContentsId)) {
460 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);
461 return;
462 }
463 if (tab.webContentsId != null && tab.webContentsId !== webContentsId) {
464 this.detachControlSession(tab.webContentsId);
465 this.detachListeners(tab.webContentsId);
466 // Any in-flight pick is bound to the OLD WebContents via `wc.ipc.on`.
467 // Cancel it so the toggle button doesn't get stuck pressed waiting
468 // forever for a click on a webview that no longer hosts the listener.
469 this.cancelPickElement(tabId);
470 }
471 this.attachListeners(tabId, wc);
472 void this.ensureControlSession(wc).catch(() => undefined);
473 // Restore the persisted zoom factor onto the freshly-attached WebContents
474 // so a thread-switch + remount lands the user back where they were.
475 if (Math.abs(tab.zoomFactor - DEFAULT_ZOOM_FACTOR) > ZOOM_EPSILON) {
476 try {
477 wc.setZoomFactor(tab.zoomFactor);
478 } catch {
479 // wc may have been torn down between resolution and call.
480 }
481 }
482 this.update(tabId, {
483 webContentsId,
484 navStatus: this.computeNavStatus(wc),
485 canGoBack: wc.navigationHistory.canGoBack(),
486 canGoForward: wc.navigationHistory.canGoForward(),
487 zoomFactor: tab.zoomFactor,
488 });
489 wc.send(ANNOTATION_THEME_CHANNEL, this.annotationTheme);
490 }
491
492 async navigate(tabId: string, rawUrl: string): Promise<void> {
493 const wc = this.requireWebContents(tabId);
494 const url = this.normalizeUrl(rawUrl);
495 if (wc.getURL() === url) {
496 wc.reload();
497 return;
498 }
499 await wc.loadURL(url);
500 }
501
502 goBack(tabId: string): void {
503 const wc = this.requireWebContents(tabId);
504 if (wc.navigationHistory.canGoBack()) {
505 wc.navigationHistory.goBack();
506 }
507 }
508
509 goForward(tabId: string): void {
510 const wc = this.requireWebContents(tabId);
511 if (wc.navigationHistory.canGoForward()) {
512 wc.navigationHistory.goForward();
513 }
514 }
515
516 refresh(tabId: string): void {
517 const wc = this.requireWebContents(tabId);
518 wc.reload();
519 }
520
521 /** Bypass HTTP cache on the next load — equivalent to Cmd+Shift+R. */
522 hardReload(tabId: string): void {
523 const wc = this.requireWebContents(tabId);
524 wc.reloadIgnoringCache();
525 }
526
527 /**
528 * Open the guest webview's DevTools, detached so it doesn't steal panel
529 * area. Idempotent — re-invoking focuses the existing window.
530 */
531 openDevTools(tabId: string): void {
532 const wc = this.requireWebContents(tabId);
533 if (wc.isDevToolsOpened()) {
534 wc.devToolsWebContents?.focus();
535 return;
536 }
537 this.detachControlSession(wc.id);
538 wc.once("devtools-closed", () => {
539 if (!wc.isDestroyed()) void this.ensureControlSession(wc).catch(() => undefined);
540 });
541 wc.openDevTools({ mode: "detach" });
542 }
543
544 /**
545 * Activate annotation mode for `tabId`. Resolves after the guest submits a
546 * multi-target annotation and the desktop process captures its screenshot,
547 * or with `null` when the user cancels.
548 *
549 * Exactly one pick session may be active per tab — re-invoking while a
550 * pick is in flight cleanly resolves the old session with `null` first.
551 */
552 async pickElement(tabId: string): Promise<PreviewAnnotationPayload | null> {
553 const wc = this.requireWebContents(tabId);
554 this.cancelPickElement(tabId);
555 return new Promise<PreviewAnnotationPayload | null>((resolve) => {
556 // `wc.ipc` is the per-WebContents IpcMain that receives messages the
557 // webview's preload sends with `ipcRenderer.send(...)`. We use that
558 // (not the global `wc.on("ipc-message", ...)`, which is for
559 // `sendToHost` and only fires on the host renderer's <webview>
560 // element) so the main process actually observes the picked payload.
561 const cleanup = () => {
562 wc.ipc.removeListener(ELEMENT_PICKED_CHANNEL, onMessage);
563 wc.off("destroyed", onDestroyed);
564 wc.off("did-start-navigation", onNavigated);
565 this.pickSessions.delete(tabId);
566 };
567 const session: PickSession = { resolve, cleanup };
568 const settle = (payload: PreviewAnnotationPayload | null) => {
569 if (this.pickSessions.get(tabId) !== session) return;
570 cleanup();
571 resolve(payload);
572 };
573 const onMessage = (_event: Electron.IpcMainEvent, ...args: unknown[]): void => {
574 const payload = args[0];
575 if (payload == null) {
576 settle(null);
577 return;
578 }
579 if (!isPreviewAnnotationPayload(payload)) {
580 settle(null);
581 return;
582 }
583 const cropRect = normalizeCaptureRect(args[1]);
584 void captureAnnotationScreenshot(wc, cropRect)
585 .then((screenshot) => settle({ ...payload, screenshot }))
586 .catch(() => settle(payload))
587 .finally(() => {
588 if (wc.isDestroyed()) return;
589 try {
590 wc.send(ANNOTATION_CAPTURED_CHANNEL);
591 } catch {
592 // The guest may have navigated while capture was in flight.
593 }
594 });
595 };
596 const onDestroyed = () => settle(null);
597 const onNavigated = () => settle(null);
598 wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage);
599 wc.once("destroyed", onDestroyed);
600 // A page navigation (incl. SPA → same-document) tears down the
601 // preload's listeners, so we cancel proactively to avoid hanging.
602 wc.once("did-start-navigation", onNavigated);
603 this.pickSessions.set(tabId, session);
604 // Force-focus the guest webContents BEFORE sending start-pick. Without
605 // this, Electron's input router will deliver the user's first
606 // mousemove/click to the host renderer (where the pick button lives)
607 // instead of to the guest's window listeners — manifest as "the
608 // picker overlay never appears on remote pages I haven't clicked
609 // into yet". The renderer-side handler in `PreviewView` is responsible
610 // for restoring focus to the previously-active host element when the
611 // pick promise resolves so the user's textarea cursor isn't lost.
612 try {
613 if (!wc.isFocused()) wc.focus();
614 } catch {
615 // wc may be torn down; the next try/catch settles.
616 }
617 try {
618 wc.send(START_PICK_CHANNEL, this.annotationTheme);
619 } catch {
620 settle(null);
621 }
622 });
623 }
624
625 cancelPickElement(tabId: string): void {
626 const session = this.pickSessions.get(tabId);
627 if (!session) return;
628 session.cleanup();
629 // Best-effort: tell the page to dismiss the overlay even if it's still
630 // alive — keeps the next invoke fresh.
631 const tab = this.tabs.get(tabId);
632 if (tab?.webContentsId != null) {
633 const wc = webContents.fromId(tab.webContentsId);
634 if (wc && !wc.isDestroyed()) {
635 try {
636 wc.send(CANCEL_PICK_CHANNEL);
637 } catch {
638 // wc may have been torn down; nothing to clean up.
639 }
640 }
641 }
642 session.resolve(null);
643 }
644
645 async startRecording(tabId: string): Promise<void> {
646 if (this.recordingTabId && this.recordingTabId !== tabId) {
647 throw new Error("Only one browser recording can be active per window.");
648 }
649 const wc = this.requireWebContents(tabId);
650 await this.withControlSession(tabId, wc, "recording.start", async (send) => {
651 await send("Page.enable");
652 await send("Page.startScreencast", {
653 format: "jpeg",
654 quality: 80,
655 maxWidth: 1600,
656 maxHeight: 1200,
657 everyNthFrame: 1,
658 });
659 });
660 this.recordingTabId = tabId;
661 }
662
663 async captureScreenshot(tabId: string): Promise<DesktopPreviewScreenshotArtifact> {
664 const wc = this.requireWebContents(tabId);
665 const createdAt = new Date().toISOString();
666 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${Date.now().toString(36)}`;
667 const directory = this.requireArtifactDirectory();
668 const path = join(directory, `${id}.png`);
669 const data = (await wc.capturePage()).toPNG();
670 await mkdir(directory, { recursive: true });
671 await writeFile(path, data);
672 return { id, tabId, path, mimeType: "image/png", sizeBytes: data.byteLength, createdAt };
673 }
674
675 async stopRecording(tabId: string): Promise<void> {
676 if (this.recordingTabId !== tabId) return;
677 const wc = this.requireWebContents(tabId);
678 await this.withControlSession(tabId, wc, "recording.stop", async (send) => {
679 await send("Page.stopScreencast");
680 });
681 this.recordingTabId = null;
682 }
683
684 async saveRecording(
685 tabId: string,
686 mimeType: string,
687 data: Uint8Array,
688 ): Promise<DesktopPreviewRecordingArtifact> {
689 const createdAt = new Date().toISOString();
690 const id = `browser-recording-${Date.now().toString(36)}`;
691 const extension = mimeType.includes("mp4") ? "mp4" : "webm";
692 const directory = this.requireArtifactDirectory();
693 const path = join(directory, `${id}.${extension}`);
694 await mkdir(directory, { recursive: true });
695 await writeFile(path, data);
696 return { id, tabId, path, mimeType, sizeBytes: data.byteLength, createdAt };
697 }
698
699 onRecordingFrame(listener: RecordingFrameListener): () => void {
700 this.recordingFrameListeners.add(listener);
701 return () => this.recordingFrameListeners.delete(listener);
702 }
703
704 zoomIn(tabId: string): void {
705 this.applyZoom(tabId, (current) => nextZoomLevel(current, "in"));
706 }
707
708 zoomOut(tabId: string): void {
709 this.applyZoom(tabId, (current) => nextZoomLevel(current, "out"));
710 }
711
712 resetZoom(tabId: string): void {
713 this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR);
714 }
715
716 automationStatus(tabId: string): PreviewAutomationStatus {
717 const tab = this.tabs.get(tabId);
718 if (!tab || tab.webContentsId == null) {
719 const navStatus = tab?.navStatus;
720 return {
721 available: false,
722 visible: true,
723 tabId,
724 url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url,
725 title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title,
726 loading: navStatus?.kind === "Loading",
727 };
728 }
729 const wc = webContents.fromId(tab.webContentsId);
730 if (!wc || wc.isDestroyed()) {
731 return {
732 available: false,
733 visible: true,
734 tabId,
735 url: null,
736 title: null,
737 loading: false,
738 };
739 }
740 return {
741 available: true,
742 visible: true,
743 tabId,
744 url: wc.getURL() || null,
745 title: wc.getTitle() || null,
746 loading: wc.isLoading(),
747 };
748 }
749
750 async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> {
751 const wc = this.requireWebContents(tabId);
752 return this.withControlSession(tabId, wc, "snapshot", async (send) => {
753 await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]);
754 const page = await this.evaluateWithDebugger<{
755 url: string;
756 title: string;
757 loading: boolean;
758 visibleText: string;
759 interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
760 }>(
761 send,
762 `(() => {
763 const selectorFor = (element) => {
764 if (element.id) return "#" + CSS.escape(element.id);
765 for (const attribute of ["data-testid", "name"]) {
766 const value = element.getAttribute(attribute);
767 if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";
768 }
769 const parts = [];
770 let current = element;
771 while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) {
772 let part = current.tagName.toLowerCase();
773 const parent = current.parentElement;
774 if (parent) {
775 const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName);
776 if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")";
777 }
778 parts.unshift(part);
779 current = parent;
780 }
781 return parts.join(" > ");
782 };
783 const visible = (element) => {
784 const style = getComputedStyle(element);
785 const rect = element.getBoundingClientRect();
786 return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
787 };
788 const elements = Array.from(document.querySelectorAll(
789 "a[href],button,input,textarea,select,[role],[tabindex]"
790 )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {
791 const rect = element.getBoundingClientRect();
792 return {
793 tag: element.tagName.toLowerCase(),
794 role: element.getAttribute("role"),
795 name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "",
796 selector: selectorFor(element),
797 x: rect.x,
798 y: rect.y,
799 width: rect.width,
800 height: rect.height
801 };
802 });
803 return {
804 url: location.href,
805 title: document.title,
806 loading: document.readyState !== "complete",
807 visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
808 interactiveElements: elements
809 };
810 })()`,
811 true,
812 );
813 const accessibility = await send("Accessibility.getFullAXTree");
814 let image = await wc.capturePage();
815 let size = image.getSize();
816 if (size.width > MAX_SCREENSHOT_WIDTH) {
817 image = image.resize({ width: MAX_SCREENSHOT_WIDTH });
818 size = image.getSize();
819 }
820 return {
821 ...page,
822 accessibilityTree: accessibility,
823 consoleEntries: [...(this.diagnostics.get(wc.id)?.consoleEntries ?? [])],
824 networkEntries: [...(this.diagnostics.get(wc.id)?.networkEntries ?? [])],
825 actionTimeline: [...(this.actionTimeline.get(tabId) ?? [])],
826 screenshot: {
827 mimeType: "image/png",
828 data: image.toPNG().toString("base64"),
829 width: size.width,
830 height: size.height,
831 },
832 };
833 });
834 }
835
836 async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> {
837 const wc = this.requireWebContents(tabId);
838 await this.withControlSession(tabId, wc, "click", async (send) => {
839 await Promise.all([
840 send("Runtime.enable"),
841 send("Input.setIgnoreInputEvents", { ignore: false }),
842 ]);
843 let x: number;
844 let y: number;
845 if ("selector" in input || "locator" in input) {
846 await this.ensurePlaywrightInjected(send);
847 const locator = this.automationLocator(input);
848 const point = await this.evaluateWithDebugger<
849 { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true }
850 >(
851 send,
852 `(() => {
853 try {
854 const injected = globalThis.__t3PlaywrightInjected;
855 const parsed = injected.parseSelector(${JSON.stringify(locator)});
856 const element = injected.querySelector(parsed, document, true);
857 if (!element) return { notFound: true };
858 const visible = injected.elementState(element, "visible");
859 const enabled = injected.elementState(element, "enabled");
860 if (!visible.matches || !enabled.matches) return { notFound: true };
861 element.scrollIntoView({ block: "center", inline: "center" });
862 const rect = element.getBoundingClientRect();
863 return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
864 } catch (error) {
865 return { invalidSelector: true, message: String(error) };
866 }
867 })()`,
868 true,
869 );
870 if ("invalidSelector" in point) {
871 throw automationError("PreviewAutomationInvalidSelectorError", point.message, {
872 selector: locator,
873 });
874 }
875 if ("notFound" in point) {
876 throw automationError(
877 "PreviewAutomationExecutionError",
878 `No element matches locator ${locator}.`,
879 );
880 }
881 x = point.x;
882 y = point.y;
883 } else {
884 x = input.x!;
885 y = input.y!;
886 }
887 const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>(
888 send,
889 "({ width: window.innerWidth, height: window.innerHeight })",
890 true,
891 );
892 if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) {
893 throw automationError(
894 "PreviewAutomationExecutionError",
895 `Click coordinates (${x}, ${y}) are outside the preview viewport.`,
896 );
897 }
898 this.emitPointerEvent({
899 tabId,
900 phase: "move",
901 x,
902 y,
903 sequence: this.pointerSequence++,
904 createdAt: new Date().toISOString(),
905 });
906 await sleep(AGENT_CURSOR_MOVE_MS);
907 this.emitPointerEvent({
908 tabId,
909 phase: "click",
910 x,
911 y,
912 sequence: this.pointerSequence++,
913 createdAt: new Date().toISOString(),
914 });
915 await sleep(AGENT_CURSOR_CLICK_LEAD_MS);
916 this.expectAgentInput(tabId, { kind: "pointer", x, y, button: 0 });
917 await send("Input.dispatchMouseEvent", {
918 type: "mousePressed",
919 x,
920 y,
921 button: "left",
922 clickCount: 1,
923 });
924 await send("Input.dispatchMouseEvent", {
925 type: "mouseReleased",
926 x,
927 y,
928 button: "left",
929 clickCount: 1,
930 });
931 });
932 }
933
934 async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> {
935 const wc = this.requireWebContents(tabId);
936 await this.withControlSession(tabId, wc, "type", async (send) => {
937 await send("Runtime.enable");
938 const locator = this.automationLocator(input);
939 if (locator) await this.ensurePlaywrightInjected(send);
940 const focusResult = await this.evaluateWithDebugger<
941 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
942 >(
943 send,
944 `(() => {
945 try {
946 const element = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "document.activeElement"};
947 if (!element) return { notFound: true };
948 element.focus();
949 if (${input.clear ?? false}) {
950 if ("value" in element) element.value = "";
951 else if (element.isContentEditable) element.textContent = "";
952 element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }));
953 }
954 return { ok: true };
955 } catch (error) {
956 return { invalidSelector: true, message: String(error) };
957 }
958 })()`,
959 true,
960 );
961 if ("invalidSelector" in focusResult) {
962 throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, {
963 selector: input.selector ?? "",
964 });
965 }
966 if ("notFound" in focusResult) {
967 throw automationError(
968 "PreviewAutomationExecutionError",
969 locator
970 ? `No element matches locator ${locator}.`
971 : "No element is focused in the preview.",
972 );
973 }
974 await send("Input.insertText", { text: input.text });
975 await this.evaluateWithDebugger(
976 send,
977 `(() => {
978 const element = document.activeElement;
979 element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} }));
980 element?.dispatchEvent(new Event("change", { bubbles: true }));
981 })()`,
982 false,
983 );
984 });
985 }
986
987 async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> {
988 const wc = this.requireWebContents(tabId);
989 await this.withControlSession(tabId, wc, "press", async (send) => {
990 const modifiers = (input.modifiers ?? []).reduce((value, modifier) => {
991 switch (modifier) {
992 case "Alt":
993 return value | 1;
994 case "Control":
995 return value | 2;
996 case "Meta":
997 return value | 4;
998 case "Shift":
999 return value | 8;
1000 }
1001 }, 0);
1002 const key = input.key;
1003 const text = key.length === 1 ? key : undefined;
1004 const params = {
1005 key,
1006 code: key.length === 1 ? `Key${key.toUpperCase()}` : key,
1007 modifiers,
1008 ...(text ? { text, unmodifiedText: text } : {}),
1009 };
1010 this.expectAgentInput(tabId, { kind: "key", key, code: params.code });
1011 await send("Input.dispatchKeyEvent", { type: "keyDown", ...params });
1012 await send("Input.dispatchKeyEvent", { type: "keyUp", ...params });
1013 });
1014 }
1015
1016 async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> {
1017 const wc = this.requireWebContents(tabId);
1018 await this.withControlSession(tabId, wc, "scroll", async (send) => {
1019 await send("Runtime.enable");
1020 const locator = this.automationLocator(input);
1021 if (locator) await this.ensurePlaywrightInjected(send);
1022 const result = await this.evaluateWithDebugger<
1023 { ok: true } | { invalidSelector: true; message: string } | { notFound: true }
1024 >(
1025 send,
1026 `(() => {
1027 try {
1028 const target = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, true); })()` : "window"};
1029 if (!target) return { notFound: true };
1030 target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" });
1031 return { ok: true };
1032 } catch (error) {
1033 return { invalidSelector: true, message: String(error) };
1034 }
1035 })()`,
1036 true,
1037 );
1038 if ("invalidSelector" in result) {
1039 throw automationError("PreviewAutomationInvalidSelectorError", result.message, {
1040 selector: input.selector ?? "",
1041 });
1042 }
1043 if ("notFound" in result) {
1044 throw automationError(
1045 "PreviewAutomationExecutionError",
1046 `No element matches locator ${locator}.`,
1047 );
1048 }
1049 });
1050 }
1051
1052 async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> {
1053 const wc = this.requireWebContents(tabId);
1054 return this.withControlSession(tabId, wc, "evaluate", async (send) => {
1055 await send("Runtime.enable");
1056 const value = await this.evaluateWithDebugger(
1057 send,
1058 input.expression,
1059 input.returnByValue ?? true,
1060 input.awaitPromise ?? true,
1061 );
1062 const serialized = JSON.stringify(value);
1063 if (
1064 serialized !== undefined &&
1065 Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES
1066 ) {
1067 throw automationError(
1068 "PreviewAutomationResultTooLargeError",
1069 `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`,
1070 { maximumBytes: MAX_EVALUATION_BYTES },
1071 );
1072 }
1073 return value;
1074 });
1075 }
1076
1077 async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> {
1078 const wc = this.requireWebContents(tabId);
1079 const timeoutMs = input.timeoutMs ?? 15_000;
1080 await this.withControlSession(tabId, wc, "waitFor", async (send) => {
1081 await send("Runtime.enable");
1082 const locator = this.automationLocator(input);
1083 if (locator) await this.ensurePlaywrightInjected(send);
1084 const deadline = Date.now() + timeoutMs;
1085 while (Date.now() <= deadline) {
1086 const result = await this.evaluateWithDebugger<
1087 { matched: boolean } | { invalidSelector: true; message: string }
1088 >(
1089 send,
1090 `(() => {
1091 try {
1092 const selectorMatched = ${locator ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${JSON.stringify(locator)}), document, false) !== null; })()` : "true"};
1093 const textMatched = ${
1094 input.text
1095 ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})`
1096 : "true"
1097 };
1098 const urlMatched = ${
1099 input.urlIncludes
1100 ? `location.href.includes(${JSON.stringify(input.urlIncludes)})`
1101 : "true"
1102 };
1103 return { matched: selectorMatched && textMatched && urlMatched };
1104 } catch (error) {
1105 return { invalidSelector: true, message: String(error) };
1106 }
1107 })()`,
1108 true,
1109 );
1110 if ("invalidSelector" in result) {
1111 throw automationError("PreviewAutomationInvalidSelectorError", result.message, {
1112 selector: input.selector ?? "",
1113 });
1114 }
1115 if (result.matched) return;
1116 await sleep(100);
1117 }
1118 throw automationError(
1119 "PreviewAutomationTimeoutError",
1120 `Preview condition did not match within ${timeoutMs}ms.`,
1121 );
1122 });
1123 }
1124
1125 private applyZoom(tabId: string, transform: (current: number) => number): void {
1126 const tab = this.tabs.get(tabId);
1127 if (!tab) return;
1128 const next = transform(tab.zoomFactor);
1129 if (Math.abs(next - tab.zoomFactor) < ZOOM_EPSILON) return;
1130 if (tab.webContentsId != null) {
1131 const wc = webContents.fromId(tab.webContentsId);
1132 if (wc && !wc.isDestroyed()) {
1133 wc.setZoomFactor(next);
1134 }
1135 }
1136 this.update(tabId, { zoomFactor: next });
1137 }
1138
1139 private async withControlSession<A>(
1140 tabId: string,
1141 wc: Electron.WebContents,
1142 action: string,
1143 use: (
1144 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
1145 ) => Promise<A>,
1146 ): Promise<A> {
1147 const actionEvent: PreviewAutomationActionEvent = {
1148 id: `browser-action-${Date.now().toString(36)}-${(this.actionSequence++).toString(36)}`,
1149 action,
1150 status: "running",
1151 startedAt: new Date().toISOString(),
1152 };
1153 this.pushAction(tabId, actionEvent);
1154 const epoch = this.controlEpoch.get(tabId) ?? 0;
1155 const control = await this.ensureControlSession(wc);
1156 let resolveTail: () => void = () => undefined;
1157 const previous = control.tail;
1158 control.tail = new Promise<void>((resolve) => {
1159 resolveTail = resolve;
1160 });
1161 await previous;
1162 this.update(tabId, { controller: "agent" });
1163 try {
1164 const send = async (method: string, commandParams?: Record<string, unknown>) => {
1165 if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) {
1166 throw automationError(
1167 "PreviewAutomationControlInterruptedError",
1168 "Browser control was interrupted by human input.",
1169 );
1170 }
1171 const result = await wc.debugger.sendCommand(method, commandParams);
1172 if ((this.controlEpoch.get(tabId) ?? 0) !== epoch) {
1173 throw automationError(
1174 "PreviewAutomationControlInterruptedError",
1175 "Browser control was interrupted by human input.",
1176 );
1177 }
1178 return result;
1179 };
1180 const result = await use(send);
1181 this.replaceAction(tabId, {
1182 ...actionEvent,
1183 status: "succeeded",
1184 completedAt: new Date().toISOString(),
1185 });
1186 return result;
1187 } catch (cause) {
1188 const interrupted =
1189 cause instanceof Error && cause.name === "PreviewAutomationControlInterruptedError";
1190 this.replaceAction(tabId, {
1191 ...actionEvent,
1192 status: interrupted ? "interrupted" : "failed",
1193 completedAt: new Date().toISOString(),
1194 error: cause instanceof Error ? cause.message : String(cause),
1195 });
1196 if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause;
1197 throw automationError(
1198 "PreviewAutomationExecutionError",
1199 cause instanceof Error ? cause.message : String(cause),
1200 { tabId, cause },
1201 );
1202 } finally {
1203 if (this.tabs.has(tabId)) this.update(tabId, { controller: "none" });
1204 resolveTail();
1205 }
1206 }
1207
1208 private pushAction(tabId: string, event: PreviewAutomationActionEvent): void {
1209 const timeline = this.actionTimeline.get(tabId) ?? [];
1210 timeline.push(event);
1211 if (timeline.length > 200) timeline.splice(0, timeline.length - 200);
1212 this.actionTimeline.set(tabId, timeline);
1213 }
1214
1215 private replaceAction(tabId: string, event: PreviewAutomationActionEvent): void {
1216 const timeline = this.actionTimeline.get(tabId);
1217 if (!timeline) return;
1218 const index = timeline.findIndex((candidate) => candidate.id === event.id);
1219 if (index >= 0) timeline[index] = event;
1220 }
1221
1222 private async ensureControlSession(wc: Electron.WebContents): Promise<BrowserControlSession> {
1223 const existing = this.controlSessions.get(wc.id);
1224 if (existing) {
1225 await existing.initialized;
1226 return existing;
1227 }
1228 if (wc.isDevToolsOpened()) {
1229 throw automationError(
1230 "PreviewAutomationExecutionError",
1231 "Close preview DevTools before using agent browser control.",
1232 );
1233 }
1234 if (wc.debugger.isAttached()) {
1235 throw automationError(
1236 "PreviewAutomationExecutionError",
1237 "Preview control cannot attach because another debugger owns this page.",
1238 );
1239 }
1240 const diagnostics: BrowserDiagnostics = {
1241 consoleEntries: [],
1242 networkEntries: [],
1243 requests: new Map(),
1244 };
1245 this.diagnostics.set(wc.id, diagnostics);
1246 const onMessage: BrowserControlSession["onMessage"] = (_event, method, params) => {
1247 if (method === "Page.screencastFrame") {
1248 const frame = params;
1249 const sessionId = frame["sessionId"];
1250 if (typeof sessionId === "number") {
1251 void wc.debugger
1252 .sendCommand("Page.screencastFrameAck", { sessionId })
1253 .catch(() => undefined);
1254 }
1255 const tabId = this.tabIdForWebContents(wc.id);
1256 const metadata =
1257 typeof frame["metadata"] === "object" && frame["metadata"] !== null
1258 ? (frame["metadata"] as Record<string, unknown>)
1259 : {};
1260 if (tabId && typeof frame["data"] === "string") {
1261 const payload: DesktopPreviewRecordingFrame = {
1262 tabId,
1263 data: frame["data"],
1264 width: typeof metadata["deviceWidth"] === "number" ? metadata["deviceWidth"] : 0,
1265 height: typeof metadata["deviceHeight"] === "number" ? metadata["deviceHeight"] : 0,
1266 receivedAt: new Date().toISOString(),
1267 };
1268 for (const listener of this.recordingFrameListeners) listener(payload);
1269 }
1270 }
1271 this.captureDiagnosticMessage(diagnostics, method, params);
1272 };
1273 const control: BrowserControlSession = {
1274 webContentsId: wc.id,
1275 tail: Promise.resolve(),
1276 initialized: Promise.resolve(),
1277 onMessage,
1278 };
1279 wc.debugger.on("message", onMessage);
1280 control.initialized = (async () => {
1281 wc.debugger.attach("1.3");
1282 await Promise.all([
1283 wc.debugger.sendCommand("Runtime.enable"),
1284 wc.debugger.sendCommand("Accessibility.enable"),
1285 wc.debugger.sendCommand("Network.enable"),
1286 wc.debugger.sendCommand("Log.enable"),
1287 ]);
1288 })();
1289 this.controlSessions.set(wc.id, control);
1290 try {
1291 await control.initialized;
1292 return control;
1293 } catch (cause) {
1294 this.controlSessions.delete(wc.id);
1295 throw cause;
1296 }
1297 }
1298
1299 private detachControlSession(webContentsId: number): void {
1300 const control = this.controlSessions.get(webContentsId);
1301 this.controlSessions.delete(webContentsId);
1302 this.diagnostics.delete(webContentsId);
1303 const wc = webContents.fromId(webContentsId);
1304 if (!wc || wc.isDestroyed()) return;
1305 if (control) {
1306 wc.debugger.off("message", control.onMessage);
1307 }
1308 if (!wc.debugger.isAttached()) return;
1309 try {
1310 wc.debugger.detach();
1311 } catch {
1312 // Target teardown can race detachment.
1313 }
1314 }
1315
1316 private tabIdForWebContents(webContentsId: number): string | null {
1317 for (const [tabId, tab] of this.tabs) {
1318 if (tab.webContentsId === webContentsId) return tabId;
1319 }
1320 return null;
1321 }
1322
1323 private captureDiagnosticMessage(
1324 diagnostics: BrowserDiagnostics,
1325 method: string,
1326 params: Record<string, unknown>,
1327 ): void {
1328 const timestamp = new Date().toISOString();
1329 if (method === "Runtime.consoleAPICalled") {
1330 const args = Array.isArray(params["args"]) ? params["args"] : [];
1331 const text = args
1332 .map((arg) => {
1333 if (typeof arg !== "object" || arg === null) return String(arg);
1334 const value = arg as Record<string, unknown>;
1335 return String(value["value"] ?? value["description"] ?? "");
1336 })
1337 .join(" ");
1338 this.pushBounded(diagnostics.consoleEntries, {
1339 level: typeof params["type"] === "string" ? params["type"] : "log",
1340 text,
1341 timestamp,
1342 source: "console",
1343 });
1344 return;
1345 }
1346 if (method === "Runtime.exceptionThrown") {
1347 const details =
1348 typeof params["exceptionDetails"] === "object" && params["exceptionDetails"] !== null
1349 ? (params["exceptionDetails"] as Record<string, unknown>)
1350 : {};
1351 this.pushBounded(diagnostics.consoleEntries, {
1352 level: "error",
1353 text: String(details["text"] ?? "Uncaught exception"),
1354 timestamp,
1355 source: "exception",
1356 });
1357 return;
1358 }
1359 if (method === "Log.entryAdded") {
1360 const entry =
1361 typeof params["entry"] === "object" && params["entry"] !== null
1362 ? (params["entry"] as Record<string, unknown>)
1363 : {};
1364 this.pushBounded(diagnostics.consoleEntries, {
1365 level: typeof entry["level"] === "string" ? entry["level"] : "info",
1366 text: String(entry["text"] ?? ""),
1367 timestamp,
1368 source: typeof entry["source"] === "string" ? entry["source"] : "log",
1369 });
1370 return;
1371 }
1372 const requestId = typeof params["requestId"] === "string" ? params["requestId"] : null;
1373 if (method === "Network.requestWillBeSent" && requestId) {
1374 const request =
1375 typeof params["request"] === "object" && params["request"] !== null
1376 ? (params["request"] as Record<string, unknown>)
1377 : {};
1378 diagnostics.requests.set(requestId, {
1379 url: String(request["url"] ?? ""),
1380 method: String(request["method"] ?? "GET"),
1381 });
1382 return;
1383 }
1384 if (method === "Network.responseReceived" && requestId) {
1385 const request = diagnostics.requests.get(requestId);
1386 const response =
1387 typeof params["response"] === "object" && params["response"] !== null
1388 ? (params["response"] as Record<string, unknown>)
1389 : {};
1390 const status = typeof response["status"] === "number" ? response["status"] : null;
1391 if (request && status !== null && status >= 400) {
1392 this.pushBounded(diagnostics.networkEntries, {
1393 ...request,
1394 status,
1395 failed: true,
1396 timestamp,
1397 });
1398 }
1399 return;
1400 }
1401 if (method === "Network.loadingFailed" && requestId) {
1402 const request = diagnostics.requests.get(requestId);
1403 if (request) {
1404 this.pushBounded(diagnostics.networkEntries, {
1405 ...request,
1406 status: null,
1407 failed: true,
1408 errorText: String(params["errorText"] ?? "Network request failed"),
1409 timestamp,
1410 });
1411 }
1412 diagnostics.requests.delete(requestId);
1413 return;
1414 }
1415 if (method === "Network.loadingFinished" && requestId) diagnostics.requests.delete(requestId);
1416 }
1417
1418 private pushBounded<A>(buffer: A[], entry: A): void {
1419 buffer.push(entry);
1420 if (buffer.length > DIAGNOSTIC_BUFFER_LIMIT) {
1421 buffer.splice(0, buffer.length - DIAGNOSTIC_BUFFER_LIMIT);
1422 }
1423 }
1424
1425 private async evaluateWithDebugger<A = unknown>(
1426 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
1427 expression: string,
1428 returnByValue: boolean,
1429 awaitPromise = true,
1430 ): Promise<A> {
1431 const response = (await send("Runtime.evaluate", {
1432 expression,
1433 awaitPromise,
1434 returnByValue,
1435 userGesture: true,
1436 })) as CdpEvaluationResult;
1437 if (response.exceptionDetails) {
1438 throw automationError(
1439 "PreviewAutomationExecutionError",
1440 response.exceptionDetails.exception?.description ??
1441 response.exceptionDetails.text ??
1442 "JavaScript evaluation failed.",
1443 );
1444 }
1445 return response.result?.value as A;
1446 }
1447
1448 private automationLocator(input: {
1449 readonly selector?: string | undefined;
1450 readonly locator?: string | undefined;
1451 }): string | null {
1452 if (input.locator) return input.locator;
1453 if (input.selector) return `css=${input.selector}`;
1454 return null;
1455 }
1456
1457 private async ensurePlaywrightInjected(
1458 send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>,
1459 ): Promise<void> {
1460 const installed = await this.evaluateWithDebugger<boolean>(
1461 send,
1462 "Boolean(globalThis.__t3PlaywrightInjected)",
1463 true,
1464 );
1465 if (installed) return;
1466 const expression = await playwrightInjectedRuntimeInstallExpression();
1467 await this.evaluateWithDebugger(send, expression, true);
1468 }
1469
1470 onStateChange(listener: Listener): () => void {
1471 this.listeners.add(listener);
1472 return () => {
1473 this.listeners.delete(listener);
1474 };
1475 }
1476
1477 onPointerEvent(listener: PointerEventListener): () => void {
1478 this.pointerEventListeners.add(listener);
1479 return () => {
1480 this.pointerEventListeners.delete(listener);
1481 };
1482 }
1483
1484 private emitPointerEvent(event: DesktopPreviewPointerEvent): void {
1485 for (const listener of this.pointerEventListeners) listener(event);
1486 }
1487
1488 private expectAgentInput(tabId: string, signal: PreviewInputSignal): void {
1489 const now = Date.now();
1490 const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter(
1491 (expected) => expected.expiresAt > now,
1492 );
1493 pending.push({ signal, expiresAt: now + 1_000 });
1494 this.expectedAgentInputs.set(tabId, pending);
1495 }
1496
1497 private consumeExpectedAgentInput(tabId: string, signal: PreviewInputSignal): boolean {
1498 const now = Date.now();
1499 const pending = (this.expectedAgentInputs.get(tabId) ?? []).filter(
1500 (expected) => expected.expiresAt > now,
1501 );
1502 const index = pending.findIndex((expected) => inputSignalsMatch(expected.signal, signal));
1503 if (index < 0) {
1504 if (pending.length === 0) this.expectedAgentInputs.delete(tabId);
1505 else this.expectedAgentInputs.set(tabId, pending);
1506 return false;
1507 }
1508 pending.splice(index, 1);
1509 if (pending.length === 0) this.expectedAgentInputs.delete(tabId);
1510 else this.expectedAgentInputs.set(tabId, pending);
1511 return true;
1512 }
1513
1514 destroy(): void {
1515 for (const tabId of Array.from(this.tabs.keys())) {
1516 this.closeTab(tabId);
1517 }
1518 this.listeners.clear();
1519 this.expectedAgentInputs.clear();
1520 this.pointerEventListeners.clear();
1521 this.recordingFrameListeners.clear();
1522 }
1523
1524 private attachListeners(tabId: string, wc: Electron.WebContents): void {
1525 const sync = () => {
1526 if (wc.isDestroyed()) return;
1527 this.update(tabId, {
1528 navStatus: this.computeNavStatus(wc),
1529 canGoBack: wc.navigationHistory.canGoBack(),
1530 canGoForward: wc.navigationHistory.canGoForward(),
1531 });
1532 };
1533 const failed = (_event: Event, code: number, description: string): void => {
1534 // -3 = ABORTED (user navigated away mid-load); ignore.
1535 if (code === -3) return;
1536 this.update(tabId, {
1537 navStatus: {
1538 kind: "LoadFailed",
1539 url: wc.getURL(),
1540 title: wc.getTitle(),
1541 code,
1542 description,
1543 },
1544 });
1545 };
1546 const humanInput = (_event: unknown, rawSignal?: unknown): void => {
1547 if (isPreviewInputSignal(rawSignal) && this.consumeExpectedAgentInput(tabId, rawSignal)) {
1548 return;
1549 }
1550 this.controlEpoch.set(tabId, (this.controlEpoch.get(tabId) ?? 0) + 1);
1551 this.update(tabId, { controller: "human" });
1552 void sleep(750).then(() => {
1553 if (this.tabs.get(tabId)?.controller === "human") {
1554 this.update(tabId, { controller: "none" });
1555 }
1556 });
1557 };
1558
1559 wc.on("did-navigate", sync);
1560 wc.on("did-navigate-in-page", sync);
1561 wc.on("page-title-updated", sync);
1562 wc.on("did-start-loading", sync);
1563 wc.on("did-stop-loading", sync);
1564 wc.on("did-fail-load", failed as never);
1565 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1566
1567 // Keep external links inside the same view (matches ami's policy).
1568 wc.setWindowOpenHandler(({ url }) => {
1569 void wc.loadURL(url);
1570 return { action: "deny" };
1571 });
1572
1573 // Forward app-level shortcuts to the main window so mod+shift+J etc.
1574 // still toggles the preview panel even when the webview has focus.
1575 const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
1576 if (this.isAppShortcut(input) && this.mainWindow && !this.mainWindow.isDestroyed()) {
1577 event.preventDefault();
1578 this.mainWindow.webContents.sendInputEvent({
1579 type: "keyDown",
1580 keyCode: input.key,
1581 modifiers: [
1582 ...(input.meta ? (["meta"] as const) : []),
1583 ...(input.shift ? (["shift"] as const) : []),
1584 ...(input.control ? (["control"] as const) : []),
1585 ...(input.alt ? (["alt"] as const) : []),
1586 ],
1587 });
1588 }
1589 };
1590 wc.on("before-input-event", beforeInput);
1591
1592 this.attached.set(wc.id, { navigate: sync, failed, humanInput, beforeInput });
1593 }
1594
1595 private detachListeners(webContentsId: number): void {
1596 const handlers = this.attached.get(webContentsId);
1597 if (!handlers) return;
1598 this.attached.delete(webContentsId);
1599 const wc = webContents.fromId(webContentsId);
1600 if (!wc || wc.isDestroyed()) return;
1601 wc.off("did-navigate", handlers.navigate);
1602 wc.off("did-navigate-in-page", handlers.navigate);
1603 wc.off("page-title-updated", handlers.navigate);
1604 wc.off("did-start-loading", handlers.navigate);
1605 wc.off("did-stop-loading", handlers.navigate);
1606 wc.off("did-fail-load", handlers.failed as never);
1607 wc.off("before-input-event", handlers.beforeInput);
1608 wc.ipc.off(HUMAN_INPUT_CHANNEL, handlers.humanInput);
1609 }
1610
1611 private isAppShortcut(input: Electron.Input): boolean {
1612 if (input.type !== "keyDown") return false;
1613 return APP_FORWARDED_SHORTCUTS.some(
1614 (shortcut) =>
1615 shortcut.key.toLowerCase() === input.key.toLowerCase() &&
1616 shortcut.meta === input.meta &&
1617 shortcut.shift === input.shift &&
1618 shortcut.control === input.control,
1619 );
1620 }
1621
1622 private computeNavStatus(wc: Electron.WebContents): PreviewNavStatus {
1623 const url = wc.getURL();
1624 const title = wc.getTitle();
1625 if (url === "" || url === "about:blank") return { kind: "Idle" };
1626 if (wc.isLoading()) return { kind: "Loading", url, title };
1627 return { kind: "Success", url, title };
1628 }
1629
1630 private requireWebContents(tabId: string): Electron.WebContents {
1631 const tab = this.tabs.get(tabId);
1632 if (!tab) throw new PreviewTabNotFoundError(tabId);
1633 if (tab.webContentsId == null) throw new PreviewWebviewNotInitializedError(tabId);
1634 const wc = webContents.fromId(tab.webContentsId);
1635 if (!wc) throw new PreviewWebContentsNotFoundError(tabId, tab.webContentsId);
1636 return wc;
1637 }
1638
1639 private update(tabId: string, patch: Partial<PreviewTabState>): void {
1640 const current = this.tabs.get(tabId);
1641 if (!current) return;
1642 const next: PreviewTabState = {
1643 ...current,
1644 ...patch,
1645 updatedAt: new Date().toISOString(),
1646 };
1647 this.tabs.set(tabId, next);
1648 this.emit(tabId, next);
1649 }
1650
1651 private emit(tabId: string, state: PreviewTabState): void {
1652 for (const listener of this.listeners) {
1653 try {
1654 listener(tabId, state);
1655 } catch {
1656 // listener errors must not crash the manager
1657 }
1658 }
1659 }
1660
1661 private normalizeUrl(input: string): string {
1662 // Surface the shared error directly so the IPC caller (and any future
1663 // desktop-side telemetry) gets the `detail` field for free. Defining a
1664 // bespoke desktop class would just lose information.
1665 return normalizePreviewUrl(input);
1666 }
1667}
1668
1669export class PreviewTabNotFoundError extends Error {
1670 readonly tabId: string;
1671 constructor(tabId: string) {
1672 super(`Preview tab not found: ${tabId}`);
1673 this.name = "PreviewTabNotFoundError";
1674 this.tabId = tabId;
1675 }
1676}
1677
1678export class PreviewWebContentsNotFoundError extends Error {
1679 readonly tabId: string;
1680 readonly webContentsId: number;
1681 constructor(tabId: string, webContentsId: number) {
1682 super(`WebContents ${webContentsId} not found for preview tab ${tabId}`);
1683 this.name = "PreviewWebContentsNotFoundError";
1684 this.tabId = tabId;
1685 this.webContentsId = webContentsId;
1686 }
1687}
1688
1689export class PreviewWebviewNotInitializedError extends Error {
1690 readonly tabId: string;
1691 constructor(tabId: string) {
1692 super(`Preview tab "${tabId}" has no webview registered`);
1693 this.name = "PreviewWebviewNotInitializedError";
1694 this.tabId = tabId;
1695 }
1696}
1697
1698export class PreviewManagerError extends Data.TaggedError("PreviewManagerError")<{
1699 readonly operation: string;
1700 readonly cause: unknown;
1701}> {
1702 override get message() {
1703 return `Desktop preview operation failed: ${this.operation}`;
1704 }
1705}
1706
1707export interface PreviewManagerShape {
1708 readonly setMainWindow: (window: BrowserWindow) => Effect.Effect<void, PreviewManagerError>;
1709 readonly getBrowserSession: (scope?: string) => Effect.Effect<Session, PreviewManagerError>;
1710 readonly isBrowserPartition: (partition: string) => boolean;
1711 readonly createTab: (tabId: string) => Effect.Effect<PreviewTabState, PreviewManagerError>;
1712 readonly closeTab: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1713 readonly registerWebview: (
1714 tabId: string,
1715 webContentsId: number,
1716 ) => Effect.Effect<void, PreviewManagerError>;
1717 readonly navigate: (tabId: string, url: string) => Effect.Effect<void, PreviewManagerError>;
1718 readonly goBack: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1719 readonly goForward: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1720 readonly refresh: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1721 readonly zoomIn: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1722 readonly zoomOut: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1723 readonly resetZoom: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1724 readonly hardReload: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1725 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1726 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
1727 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
1728 readonly getBrowserPartition: (scope?: string) => string;
1729 readonly setAnnotationTheme: (
1730 theme: DesktopPreviewAnnotationTheme,
1731 ) => Effect.Effect<void, PreviewManagerError>;
1732 readonly pickElement: (
1733 tabId: string,
1734 ) => Effect.Effect<PreviewAnnotationPayload | null, PreviewManagerError>;
1735 readonly cancelPickElement: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1736 readonly captureScreenshot: (
1737 tabId: string,
1738 ) => Effect.Effect<DesktopPreviewScreenshotArtifact, PreviewManagerError>;
1739 readonly revealArtifact: (path: string) => Effect.Effect<void, PreviewManagerError>;
1740 readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>;
1741 readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1742 readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
1743 readonly saveRecording: (
1744 tabId: string,
1745 mimeType: string,
1746 data: Uint8Array,
1747 ) => Effect.Effect<DesktopPreviewRecordingArtifact, PreviewManagerError>;
1748 readonly automationStatus: (
1749 tabId: string,
1750 ) => Effect.Effect<PreviewAutomationStatus, PreviewManagerError>;
1751 readonly automationSnapshot: (
1752 tabId: string,
1753 ) => Effect.Effect<PreviewAutomationSnapshot, PreviewManagerError>;
1754 readonly automationClick: (
1755 tabId: string,
1756 input: PreviewAutomationClickInput,
1757 ) => Effect.Effect<void, PreviewManagerError>;
1758 readonly automationType: (
1759 tabId: string,
1760 input: PreviewAutomationTypeInput,
1761 ) => Effect.Effect<void, PreviewManagerError>;
1762 readonly automationPress: (
1763 tabId: string,
1764 input: PreviewAutomationPressInput,
1765 ) => Effect.Effect<void, PreviewManagerError>;
1766 readonly automationScroll: (
1767 tabId: string,
1768 input: PreviewAutomationScrollInput,
1769 ) => Effect.Effect<void, PreviewManagerError>;
1770 readonly automationEvaluate: (
1771 tabId: string,
1772 input: PreviewAutomationEvaluateInput,
1773 ) => Effect.Effect<unknown, PreviewManagerError>;
1774 readonly automationWaitFor: (
1775 tabId: string,
1776 input: PreviewAutomationWaitForInput,
1777 ) => Effect.Effect<void, PreviewManagerError>;
1778 readonly subscribeStateChanges: (listener: Listener) => Effect.Effect<void, never, Scope.Scope>;
1779 readonly subscribePointerEvents: (
1780 listener: PointerEventListener,
1781 ) => Effect.Effect<void, never, Scope.Scope>;
1782 readonly subscribeRecordingFrames: (
1783 listener: RecordingFrameListener,
1784 ) => Effect.Effect<void, never, Scope.Scope>;
1785}
1786
1787export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()(
1788 "@t3tools/desktop/preview/Manager/PreviewManager",
1789) {}
1790
1791const make = Effect.fn("PreviewManager.make")(function* () {
1792 const environment = yield* DesktopEnvironment.DesktopEnvironment;
1793 const browserSession = yield* BrowserSession.BrowserSession;
1794 const manager = new PreviewViewManager();
1795 manager.configureArtifactDirectory(environment.browserArtifactsDir);
1796 yield* Effect.addFinalizer(() => Effect.sync(() => manager.destroy()));
1797
1798 const attempt = <A>(
1799 operation: string,
1800 evaluate: () => A,
1801 ): Effect.Effect<A, PreviewManagerError> =>
1802 Effect.try({
1803 try: evaluate,
1804 catch: (cause) => new PreviewManagerError({ operation, cause }),
1805 });
1806 const attemptPromise = <A>(
1807 operation: string,
1808 evaluate: () => Promise<A>,
1809 ): Effect.Effect<A, PreviewManagerError> =>
1810 Effect.tryPromise({
1811 try: evaluate,
1812 catch: (cause) => new PreviewManagerError({ operation, cause }),
1813 });
1814 const browserSessionEffect = <A>(
1815 operation: string,
1816 effect: Effect.Effect<A, BrowserSession.BrowserSessionError>,
1817 ): Effect.Effect<A, PreviewManagerError> =>
1818 effect.pipe(Effect.mapError((cause) => new PreviewManagerError({ operation, cause })));
1819
1820 return PreviewManager.of({
1821 setMainWindow: Effect.fn("PreviewManager.setMainWindow")(function* (window) {
1822 yield* attempt("setMainWindow", () => manager.setMainWindow(window));
1823 }),
1824 getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(function* (scope) {
1825 return yield* browserSessionEffect("getBrowserSession", browserSession.getSession(scope));
1826 }),
1827 isBrowserPartition: browserSession.isPartition,
1828 createTab: Effect.fn("PreviewManager.createTab")(function* (tabId) {
1829 return yield* attempt("createTab", () => manager.createTab(tabId));
1830 }),
1831 closeTab: Effect.fn("PreviewManager.closeTab")(function* (tabId) {
1832 yield* attempt("closeTab", () => manager.closeTab(tabId));
1833 }),
1834 registerWebview: Effect.fn("PreviewManager.registerWebview")(function* (tabId, webContentsId) {
1835 yield* attempt("registerWebview", () => manager.registerWebview(tabId, webContentsId));
1836 }),
1837 navigate: Effect.fn("PreviewManager.navigate")(function* (tabId, url) {
1838 yield* attemptPromise("navigate", () => manager.navigate(tabId, url));
1839 }),
1840 goBack: Effect.fn("PreviewManager.goBack")(function* (tabId) {
1841 yield* attempt("goBack", () => manager.goBack(tabId));
1842 }),
1843 goForward: Effect.fn("PreviewManager.goForward")(function* (tabId) {
1844 yield* attempt("goForward", () => manager.goForward(tabId));
1845 }),
1846 refresh: Effect.fn("PreviewManager.refresh")(function* (tabId) {
1847 yield* attempt("refresh", () => manager.refresh(tabId));
1848 }),
1849 zoomIn: Effect.fn("PreviewManager.zoomIn")(function* (tabId) {
1850 yield* attempt("zoomIn", () => manager.zoomIn(tabId));
1851 }),
1852 zoomOut: Effect.fn("PreviewManager.zoomOut")(function* (tabId) {
1853 yield* attempt("zoomOut", () => manager.zoomOut(tabId));
1854 }),
1855 resetZoom: Effect.fn("PreviewManager.resetZoom")(function* (tabId) {
1856 yield* attempt("resetZoom", () => manager.resetZoom(tabId));
1857 }),
1858 hardReload: Effect.fn("PreviewManager.hardReload")(function* (tabId) {
1859 yield* attempt("hardReload", () => manager.hardReload(tabId));
1860 }),
1861 openDevTools: Effect.fn("PreviewManager.openDevTools")(function* (tabId) {
1862 yield* attempt("openDevTools", () => manager.openDevTools(tabId));
1863 }),
1864 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
1865 yield* browserSessionEffect("clearCookies", browserSession.clearCookies());
1866 }),
1867 clearCache: Effect.fn("PreviewManager.clearCache")(function* () {
1868 yield* browserSessionEffect("clearCache", browserSession.clearCache());
1869 }),
1870 getBrowserPartition: browserSession.getPartition,
1871 setAnnotationTheme: Effect.fn("PreviewManager.setAnnotationTheme")(function* (theme) {
1872 yield* attempt("setAnnotationTheme", () => manager.setAnnotationTheme(theme));
1873 }),
1874 pickElement: Effect.fn("PreviewManager.pickElement")(function* (tabId) {
1875 return yield* attemptPromise("pickElement", () => manager.pickElement(tabId));
1876 }),
1877 cancelPickElement: Effect.fn("PreviewManager.cancelPickElement")(function* (tabId) {
1878 yield* attempt("cancelPickElement", () => manager.cancelPickElement(tabId));
1879 }),
1880 captureScreenshot: Effect.fn("PreviewManager.captureScreenshot")(function* (tabId) {
1881 return yield* attemptPromise("captureScreenshot", () => manager.captureScreenshot(tabId));
1882 }),
1883 revealArtifact: Effect.fn("PreviewManager.revealArtifact")(function* (path) {
1884 yield* attempt("revealArtifact", () => manager.revealArtifact(path));
1885 }),
1886 copyArtifactToClipboard: Effect.fn("PreviewManager.copyArtifactToClipboard")(function* (path) {
1887 yield* attempt("copyArtifactToClipboard", () => manager.copyArtifactToClipboard(path));
1888 }),
1889 startRecording: Effect.fn("PreviewManager.startRecording")(function* (tabId) {
1890 yield* attemptPromise("startRecording", () => manager.startRecording(tabId));
1891 }),
1892 stopRecording: Effect.fn("PreviewManager.stopRecording")(function* (tabId) {
1893 yield* attemptPromise("stopRecording", () => manager.stopRecording(tabId));
1894 }),
1895 saveRecording: Effect.fn("PreviewManager.saveRecording")(function* (tabId, mimeType, data) {
1896 return yield* attemptPromise("saveRecording", () =>
1897 manager.saveRecording(tabId, mimeType, data),
1898 );
1899 }),
1900 automationStatus: Effect.fn("PreviewManager.automationStatus")(function* (tabId) {
1901 return yield* attempt("automationStatus", () => manager.automationStatus(tabId));
1902 }),
1903 automationSnapshot: Effect.fn("PreviewManager.automationSnapshot")(function* (tabId) {
1904 return yield* attemptPromise("automationSnapshot", () => manager.automationSnapshot(tabId));
1905 }),
1906 automationClick: Effect.fn("PreviewManager.automationClick")(function* (tabId, input) {
1907 yield* attemptPromise("automationClick", () => manager.automationClick(tabId, input));
1908 }),
1909 automationType: Effect.fn("PreviewManager.automationType")(function* (tabId, input) {
1910 yield* attemptPromise("automationType", () => manager.automationType(tabId, input));
1911 }),
1912 automationPress: Effect.fn("PreviewManager.automationPress")(function* (tabId, input) {
1913 yield* attemptPromise("automationPress", () => manager.automationPress(tabId, input));
1914 }),
1915 automationScroll: Effect.fn("PreviewManager.automationScroll")(function* (tabId, input) {
1916 yield* attemptPromise("automationScroll", () => manager.automationScroll(tabId, input));
1917 }),
1918 automationEvaluate: Effect.fn("PreviewManager.automationEvaluate")(function* (tabId, input) {
1919 return yield* attemptPromise("automationEvaluate", () =>
1920 manager.automationEvaluate(tabId, input),
1921 );
1922 }),
1923 automationWaitFor: Effect.fn("PreviewManager.automationWaitFor")(function* (tabId, input) {
1924 yield* attemptPromise("automationWaitFor", () => manager.automationWaitFor(tabId, input));
1925 }),
1926 subscribeStateChanges: (listener) =>
1927 Effect.acquireRelease(
1928 Effect.sync(() => manager.onStateChange(listener)),
1929 (unsubscribe) => Effect.sync(unsubscribe),
1930 ).pipe(Effect.asVoid),
1931 subscribePointerEvents: (listener) =>
1932 Effect.acquireRelease(
1933 Effect.sync(() => manager.onPointerEvent(listener)),
1934 (unsubscribe) => Effect.sync(unsubscribe),
1935 ).pipe(Effect.asVoid),
1936 subscribeRecordingFrames: (listener) =>
1937 Effect.acquireRelease(
1938 Effect.sync(() => manager.onRecordingFrame(listener)),
1939 (unsubscribe) => Effect.sync(unsubscribe),
1940 ).pipe(Effect.asVoid),
1941 });
1942});
1943
1944export const layer = Layer.effect(PreviewManager, make());
1945
1946/** Exposed for tests. */
1947export const __testing = {
1948 PreviewViewManager,
1949};
apps/desktop/src/preview/PickLabelPosition.ts created+46
......@@ -0,0 +1,46 @@
1/**
2 * Pure clamp/flip math for the floating label that follows the cursor while
3 * the user is picking an element in the in-app browser. Lives in its own
4 * electron-free module so the geometry can be unit-tested without spinning
5 * up an Electron preload context (`PickPreload.ts` itself imports
6 * `electron` and `react-grab/primitives`, which can't load under vitest).
7 *
8 * - Horizontally pins the label to `targetLeft`, clamped into
9 * `[VIEWPORT_MARGIN, viewportWidth - labelWidth - VIEWPORT_MARGIN]`.
10 * - Vertically prefers above the target. If the label would overflow the
11 * top, flips below; if THAT also overflows the bottom, pins to the
12 * bottom margin (better to overlap the highlight than disappear).
13 */
14
15/** Distance in CSS pixels between the highlight and the floating label. */
16export const LABEL_GAP = 4;
17/** Minimum padding the label keeps from any viewport edge. */
18export const VIEWPORT_MARGIN = 4;
19
20export function computeLabelPosition(input: {
21 targetLeft: number;
22 targetTop: number;
23 targetBottom: number;
24 labelWidth: number;
25 labelHeight: number;
26 viewportWidth: number;
27 viewportHeight: number;
28}): { x: number; y: number } {
29 const { targetLeft, targetTop, targetBottom, labelWidth, labelHeight } = input;
30 const { viewportWidth, viewportHeight } = input;
31
32 let x = targetLeft;
33 const maxX = viewportWidth - labelWidth - VIEWPORT_MARGIN;
34 if (x > maxX) x = maxX;
35 if (x < VIEWPORT_MARGIN) x = VIEWPORT_MARGIN;
36
37 let y = targetTop - labelHeight - LABEL_GAP;
38 if (y < VIEWPORT_MARGIN) {
39 y = targetBottom + LABEL_GAP;
40 if (y + labelHeight > viewportHeight - VIEWPORT_MARGIN) {
41 y = Math.max(VIEWPORT_MARGIN, viewportHeight - labelHeight - VIEWPORT_MARGIN);
42 }
43 }
44
45 return { x, y };
46}
apps/desktop/src/preview/PickPreload.test.ts created+86
......@@ -0,0 +1,86 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { computeLabelPosition } from "./PickLabelPosition.ts";
4
5const VIEWPORT = { viewportWidth: 1280, viewportHeight: 800 };
6
7describe("computeLabelPosition", () => {
8 it("anchors to the element's top-left when there's room above and to the right", () => {
9 const { x, y } = computeLabelPosition({
10 ...VIEWPORT,
11 targetLeft: 200,
12 targetTop: 200,
13 targetBottom: 240,
14 labelWidth: 120,
15 labelHeight: 18,
16 });
17 expect(x).toBe(200);
18 // 200 (top) - 18 (height) - 4 (gap)
19 expect(y).toBe(200 - 18 - 4);
20 });
21
22 it("clamps left edge so the label stays inside the viewport", () => {
23 const { x } = computeLabelPosition({
24 ...VIEWPORT,
25 targetLeft: -50,
26 targetTop: 200,
27 targetBottom: 240,
28 labelWidth: 120,
29 labelHeight: 18,
30 });
31 expect(x).toBe(4);
32 });
33
34 it("clamps right edge when the label would overflow the viewport (the bug we shipped)", () => {
35 const { x } = computeLabelPosition({
36 ...VIEWPORT,
37 targetLeft: 1240,
38 targetTop: 200,
39 targetBottom: 240,
40 labelWidth: 200,
41 labelHeight: 18,
42 });
43 // viewportWidth (1280) - labelWidth (200) - margin (4) = 1076
44 expect(x).toBe(1076);
45 });
46
47 it("flips the label below the element when there's no room above", () => {
48 const { y } = computeLabelPosition({
49 ...VIEWPORT,
50 targetLeft: 200,
51 targetTop: 4,
52 targetBottom: 44,
53 labelWidth: 120,
54 labelHeight: 18,
55 });
56 // labelY = 4 - 18 - 4 = -18 → flip → 44 + 4 = 48
57 expect(y).toBe(48);
58 });
59
60 it("pins to the bottom margin when the element fills the viewport (no room above OR below)", () => {
61 const { y } = computeLabelPosition({
62 ...VIEWPORT,
63 targetLeft: 200,
64 targetTop: 0,
65 targetBottom: 800,
66 labelWidth: 120,
67 labelHeight: 18,
68 });
69 // Above overflows top → flip below = 800 + 4 = 804 → also overflows
70 // bottom → pin to viewportHeight - labelHeight - margin = 778.
71 expect(y).toBe(800 - 18 - 4);
72 });
73
74 it("never returns a negative coordinate", () => {
75 const { x, y } = computeLabelPosition({
76 ...VIEWPORT,
77 targetLeft: -1000,
78 targetTop: -1000,
79 targetBottom: -900,
80 labelWidth: 5000,
81 labelHeight: 5000,
82 });
83 expect(x).toBeGreaterThanOrEqual(0);
84 expect(y).toBeGreaterThanOrEqual(0);
85 });
86});
apps/desktop/src/preview/PickPreload.ts created+1263
......@@ -0,0 +1,1263 @@
1// @effect-diagnostics globalDate:off
2import { ipcRenderer } from "electron";
3import { getElementContext } from "react-grab/primitives";
4import type {
5 DesktopPreviewAnnotationTheme,
6 PickedElementPayload,
7 PickedElementStackFrame,
8 PreviewAnnotationPayload,
9 PreviewAnnotationPoint,
10 PreviewAnnotationRect,
11 PreviewAnnotationRegionTarget,
12 PreviewAnnotationStrokeTarget,
13 PreviewAnnotationStyleChange,
14} from "@t3tools/contracts";
15
16import { previewAnnotationStyles } from "./AnnotationStyles.generated.ts";
17import {
18 ANNOTATION_CAPTURED_CHANNEL,
19 ANNOTATION_THEME_CHANNEL,
20 CANCEL_PICK_CHANNEL,
21 ELEMENT_PICKED_CHANNEL,
22 HUMAN_INPUT_CHANNEL,
23 START_PICK_CHANNEL,
24} from "./GuestProtocol.ts";
25const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui";
26const Z_INDEX_OVERLAY = 2147483646;
27const PRIMARY = "var(--t3-primary)";
28const PRIMARY_FILL = "color-mix(in srgb, var(--t3-primary) 10%, transparent)";
29const MAX_MARQUEE_ELEMENTS = 20;
30const CONTENT_LAYER_Z_INDEX = 1;
31const CHROME_LAYER_Z_INDEX = 10;
32
33type AnnotationTool = "select" | "marquee" | "draw" | "erase";
34
35interface SelectedElement {
36 id: string;
37 element: Element;
38 outline: HTMLDivElement;
39 label: HTMLDivElement;
40 baselineStyles: Map<string, string>;
41}
42
43interface AnnotationSession {
44 teardown: (notifyMain: boolean) => void;
45 applyTheme: (theme: DesktopPreviewAnnotationTheme) => void;
46}
47
48let activeSession: AnnotationSession | null = null;
49let idSequence = 0;
50let annotationTheme: DesktopPreviewAnnotationTheme | null = null;
51
52const applyAnnotationTheme = (
53 host: HTMLElement,
54 theme: DesktopPreviewAnnotationTheme | null,
55): void => {
56 if (!theme) return;
57 host.style.colorScheme = theme.colorScheme;
58 const variables = {
59 "--t3-radius": theme.radius,
60 "--t3-background": theme.background,
61 "--t3-foreground": theme.foreground,
62 "--t3-popover": theme.popover,
63 "--t3-popover-foreground": theme.popoverForeground,
64 "--t3-primary": theme.primary,
65 "--t3-primary-foreground": theme.primaryForeground,
66 "--t3-muted": theme.muted,
67 "--t3-muted-foreground": theme.mutedForeground,
68 "--t3-accent": theme.accent,
69 "--t3-accent-foreground": theme.accentForeground,
70 "--t3-border": theme.border,
71 "--t3-input": theme.input,
72 "--t3-ring": theme.ring,
73 "--t3-font-sans": theme.fontSans,
74 "--t3-font-mono": theme.fontMono,
75 };
76 for (const [name, value] of Object.entries(variables)) {
77 host.style.setProperty(name, value);
78 }
79};
80
81const reportHumanPointerInput = (event: PointerEvent): void => {
82 if (!event.isTrusted) return;
83 ipcRenderer.send(HUMAN_INPUT_CHANNEL, {
84 kind: "pointer",
85 x: event.clientX,
86 y: event.clientY,
87 button: event.button,
88 });
89};
90
91const reportHumanKeyInput = (event: KeyboardEvent): void => {
92 if (!event.isTrusted) return;
93 ipcRenderer.send(HUMAN_INPUT_CHANNEL, {
94 kind: "key",
95 key: event.key,
96 code: event.code,
97 });
98};
99
100window.addEventListener("pointerdown", reportHumanPointerInput, true);
101window.addEventListener("keydown", reportHumanKeyInput, true);
102
103const nextId = (prefix: string): string => {
104 idSequence += 1;
105 return `${prefix}_${idSequence.toString(36)}`;
106};
107
108const rectFromDomRect = (rect: DOMRect): PreviewAnnotationRect => ({
109 x: rect.left,
110 y: rect.top,
111 width: rect.width,
112 height: rect.height,
113});
114
115const normalizeRect = (
116 startX: number,
117 startY: number,
118 endX: number,
119 endY: number,
120): PreviewAnnotationRect => ({
121 x: Math.min(startX, endX),
122 y: Math.min(startY, endY),
123 width: Math.abs(endX - startX),
124 height: Math.abs(endY - startY),
125});
126
127const isUsableRect = (rect: PreviewAnnotationRect): boolean => rect.width >= 3 && rect.height >= 3;
128
129function unionRects(
130 rects: ReadonlyArray<PreviewAnnotationRect>,
131 padding = 20,
132): PreviewAnnotationRect | null {
133 if (rects.length === 0) return null;
134 const left = Math.min(...rects.map((rect) => rect.x));
135 const top = Math.min(...rects.map((rect) => rect.y));
136 const right = Math.max(...rects.map((rect) => rect.x + rect.width));
137 const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
138 const x = Math.max(0, left - padding);
139 const y = Math.max(0, top - padding);
140 const maxWidth = Math.max(1, window.innerWidth - x);
141 const maxHeight = Math.max(1, window.innerHeight - y);
142 return {
143 x,
144 y,
145 width: Math.min(maxWidth, right - left + padding * 2),
146 height: Math.min(maxHeight, bottom - top + padding * 2),
147 };
148}
149
150function isAnnotationNode(element: Element): boolean {
151 return element instanceof Element && element.closest(`[${OVERLAY_ATTRIBUTE}]`) !== null;
152}
153
154function pickFromPoint(clientX: number, clientY: number): Element | null {
155 for (const candidate of document.elementsFromPoint(clientX, clientY)) {
156 if (!(candidate instanceof Element)) continue;
157 if (isAnnotationNode(candidate)) continue;
158 if (candidate === document.documentElement || candidate === document.body) continue;
159 return candidate;
160 }
161 return null;
162}
163
164function describeRawElement(element: Element): string {
165 const tag = element.tagName.toLowerCase();
166 const id = element.id ? `#${element.id}` : "";
167 const classes =
168 element instanceof HTMLElement && typeof element.className === "string"
169 ? element.className
170 .trim()
171 .split(/\s+/)
172 .filter(Boolean)
173 .slice(0, 2)
174 .map((name) => `.${name}`)
175 .join("")
176 : "";
177 return `${tag}${id}${classes}`;
178}
179
180function createBox(color: string, fill: string): HTMLDivElement {
181 const node = document.createElement("div");
182 node.setAttribute(OVERLAY_ATTRIBUTE, "");
183 node.style.cssText = [
184 "position:fixed",
185 "pointer-events:none",
186 `border:2px solid ${color}`,
187 `background:${fill}`,
188 "border-radius:3px",
189 "box-sizing:border-box",
190 "display:none",
191 `z-index:${CONTENT_LAYER_Z_INDEX}`,
192 ].join(";");
193 return node;
194}
195
196function positionBox(node: HTMLElement, rect: PreviewAnnotationRect): void {
197 node.style.display = "block";
198 node.style.transform = `translate(${rect.x}px, ${rect.y}px)`;
199 node.style.width = `${rect.width}px`;
200 node.style.height = `${rect.height}px`;
201}
202
203function createLabel(): HTMLDivElement {
204 const label = document.createElement("div");
205 label.setAttribute(OVERLAY_ATTRIBUTE, "");
206 label.className =
207 "fixed z-1 max-w-70 overflow-hidden rounded-md bg-primary px-2 py-1 font-sans text-xs font-semibold text-primary-foreground shadow-md";
208 label.style.cssText = [
209 "position:fixed",
210 "pointer-events:none",
211 "white-space:nowrap",
212 "text-overflow:ellipsis",
213 `z-index:${CONTENT_LAYER_Z_INDEX}`,
214 ].join(";");
215 return label;
216}
217
218function updateSelectedVisual(target: SelectedElement): void {
219 if (!target.element.isConnected) {
220 target.outline.style.display = "none";
221 target.label.style.display = "none";
222 return;
223 }
224 const rect = target.element.getBoundingClientRect();
225 positionBox(target.outline, rectFromDomRect(rect));
226 target.label.textContent = describeRawElement(target.element);
227 target.label.style.display = "block";
228 target.label.style.transform = `translate(${Math.max(4, rect.left)}px, ${Math.max(4, rect.top - 22)}px)`;
229}
230
231function toStackFrame(frame: {
232 functionName?: string;
233 fileName?: string;
234 lineNumber?: number;
235 columnNumber?: number;
236}): PickedElementStackFrame {
237 return {
238 functionName: frame.functionName ?? null,
239 fileName: frame.fileName ?? null,
240 lineNumber: frame.lineNumber ?? null,
241 columnNumber: frame.columnNumber ?? null,
242 };
243}
244
245async function captureElement(element: Element): Promise<PickedElementPayload | null> {
246 try {
247 const context = await getElementContext(element);
248 const stack = (context.stack ?? []).map(toStackFrame);
249 return {
250 pageUrl: location.href,
251 pageTitle: document.title?.trim() || null,
252 tagName: element.tagName.toLowerCase(),
253 selector: context.selector,
254 htmlPreview: context.htmlPreview ?? "",
255 componentName: context.componentName,
256 source: stack[0] ?? null,
257 stack,
258 styles: context.styles ?? "",
259 pickedAt: new Date().toISOString(),
260 };
261 } catch {
262 return null;
263 }
264}
265
266function createButton(label: string, title: string): HTMLButtonElement {
267 const button = document.createElement("button");
268 button.type = "button";
269 button.textContent = label;
270 button.title = title;
271 button.className =
272 "inline-flex h-7 cursor-pointer items-center justify-center rounded-md border border-transparent px-2 font-sans text-xs font-medium text-foreground outline-none hover:bg-accent disabled:pointer-events-none disabled:opacity-60";
273 return button;
274}
275
276function styleControl(input: HTMLInputElement | HTMLSelectElement): void {
277 input.setAttribute("aria-label", input.getAttribute("aria-label") ?? "Style value");
278 input.className =
279 "h-7 min-w-0 w-full appearance-none rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground shadow-xs outline-none";
280}
281
282function createUnitControl(input: HTMLInputElement): HTMLElement {
283 const wrapper = document.createElement("div");
284 wrapper.style.cssText = "position:relative;min-width:0";
285 const unit = document.createElement("span");
286 unit.textContent = input.dataset.unit ?? "";
287 unit.className =
288 "pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 font-mono text-xs text-muted-foreground";
289 wrapper.append(input, unit);
290 return wrapper;
291}
292
293function createField(
294 labelText: string,
295 input: HTMLInputElement | HTMLSelectElement,
296): HTMLLabelElement {
297 const label = document.createElement("label");
298 label.className =
299 "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground";
300 const text = document.createElement("span");
301 text.textContent = labelText;
302 styleControl(input);
303 label.append(
304 text,
305 input instanceof HTMLInputElement && input.dataset.unit ? createUnitControl(input) : input,
306 );
307 return label;
308}
309
310function createStyleSection(): HTMLElement {
311 const section = document.createElement("section");
312 section.className = "grid gap-1 border-t border-border py-2";
313 return section;
314}
315
316function createUnitInput(unit: string, placeholder = "0"): HTMLInputElement {
317 const input = document.createElement("input");
318 input.type = "number";
319 input.placeholder = placeholder;
320 input.style.paddingRight = "30px";
321 input.dataset.unit = unit;
322 return input;
323}
324
325function pathFromPoints(points: ReadonlyArray<PreviewAnnotationPoint>): string {
326 if (points.length === 0) return "";
327 if (points.length === 1) return `M ${points[0]!.x} ${points[0]!.y} l 0.01 0.01`;
328 let path = `M ${points[0]!.x} ${points[0]!.y}`;
329 for (let index = 1; index < points.length - 1; index += 1) {
330 const current = points[index]!;
331 const next = points[index + 1]!;
332 path += ` Q ${current.x} ${current.y} ${(current.x + next.x) / 2} ${(current.y + next.y) / 2}`;
333 }
334 const last = points[points.length - 1]!;
335 path += ` L ${last.x} ${last.y}`;
336 return path;
337}
338
339function strokeBounds(
340 points: ReadonlyArray<PreviewAnnotationPoint>,
341 width: number,
342): PreviewAnnotationRect {
343 const xs = points.map((point) => point.x);
344 const ys = points.map((point) => point.y);
345 const padding = width + 3;
346 const left = Math.min(...xs) - padding;
347 const top = Math.min(...ys) - padding;
348 const right = Math.max(...xs) + padding;
349 const bottom = Math.max(...ys) + padding;
350 return { x: left, y: top, width: right - left, height: bottom - top };
351}
352
353function startAnnotation(): void {
354 activeSession?.teardown(false);
355 let finished = false;
356 const host = document.createElement("div");
357 host.setAttribute(OVERLAY_ATTRIBUTE, "");
358 host.style.cssText = `position:fixed;inset:0;z-index:${Z_INDEX_OVERLAY};pointer-events:none`;
359 applyAnnotationTheme(host, annotationTheme);
360 const shadowRoot = host.attachShadow({ mode: "closed" });
361 const themeStyle = document.createElement("style");
362 themeStyle.textContent = previewAnnotationStyles;
363 shadowRoot.appendChild(themeStyle);
364
365 const root = document.createElement("div");
366 root.setAttribute(OVERLAY_ATTRIBUTE, "");
367 root.className = "fixed inset-0 font-sans text-foreground";
368 root.style.cssText = "pointer-events:none";
369 const cursorStyle = document.createElement("style");
370 cursorStyle.setAttribute(OVERLAY_ATTRIBUTE, "");
371 cursorStyle.textContent = `html[data-t3code-annotation-tool] body, html[data-t3code-annotation-tool] body * { cursor: crosshair !important; } [${OVERLAY_ATTRIBUTE}], [${OVERLAY_ATTRIBUTE}] * { cursor: default !important; } [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-inner-spin-button, [${OVERLAY_ATTRIBUTE}] input[type=number]::-webkit-outer-spin-button { appearance:none; margin:0; }`;
372 document.documentElement.appendChild(cursorStyle);
373 shadowRoot.appendChild(root);
374
375 const hoverOutline = createBox(PRIMARY, PRIMARY_FILL);
376 const marqueeBox = createBox(PRIMARY, PRIMARY_FILL);
377 root.append(hoverOutline, marqueeBox);
378
379 const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
380 svg.setAttribute(OVERLAY_ATTRIBUTE, "");
381 svg.setAttribute("width", "100%");
382 svg.setAttribute("height", "100%");
383 svg.setAttribute("viewBox", `0 0 ${window.innerWidth} ${window.innerHeight}`);
384 svg.style.cssText = "position:fixed;inset:0;overflow:visible;pointer-events:none";
385 svg.style.zIndex = String(CONTENT_LAYER_Z_INDEX);
386 root.appendChild(svg);
387
388 const toolbar = document.createElement("div");
389 toolbar.setAttribute(OVERLAY_ATTRIBUTE, "");
390 toolbar.className =
391 "pointer-events-auto fixed top-2.5 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-lg border border-border bg-popover/95 p-1 text-popover-foreground shadow-lg backdrop-blur-xl";
392 toolbar.style.zIndex = String(CHROME_LAYER_Z_INDEX);
393 root.appendChild(toolbar);
394
395 const editor = document.createElement("div");
396 editor.setAttribute(OVERLAY_ATTRIBUTE, "");
397 editor.className =
398 "pointer-events-auto fixed hidden max-h-[calc(100vh-16px)] w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-xl border border-border bg-popover/96 text-popover-foreground shadow-2xl backdrop-blur-xl";
399 editor.style.zIndex = String(CHROME_LAYER_Z_INDEX);
400 root.appendChild(editor);
401
402 const composerRow = document.createElement("div");
403 composerRow.className = "flex items-start gap-2 p-2";
404
405 const adjust = createButton("", "Expand annotation editor");
406 adjust.setAttribute("aria-label", "Expand annotation editor");
407 adjust.setAttribute("aria-expanded", "false");
408 adjust.className +=
409 " h-8 w-8 shrink-0 bg-muted p-0 text-muted-foreground hover:bg-accent hover:text-accent-foreground";
410 adjust.innerHTML =
411 '<svg viewBox="0 0 20 20" width="15" height="15" aria-hidden="true"><path d="M4 5h12M4 10h12M4 15h12M7 3v4M13 8v4M9 13v4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
412 composerRow.appendChild(adjust);
413
414 const comment = document.createElement("textarea");
415 comment.placeholder = "Describe the change…";
416 comment.rows = 1;
417 comment.className =
418 "min-h-8 max-h-24 min-w-0 flex-1 resize-none overflow-y-hidden border-0 border-b border-b-transparent bg-transparent px-0 py-1.5 font-sans text-sm leading-5 text-foreground outline-none ring-0 placeholder:text-muted-foreground focus:border-b-primary focus:outline-none focus:ring-0";
419 composerRow.appendChild(comment);
420
421 const dragHandle = document.createElement("button");
422 dragHandle.type = "button";
423 dragHandle.textContent = "â ¿";
424 dragHandle.title = "Drag annotation editor";
425 dragHandle.className =
426 "hidden h-8 w-6 shrink-0 cursor-grab select-none border-0 bg-transparent p-0 font-sans text-lg font-bold leading-5 text-muted-foreground";
427 composerRow.appendChild(dragHandle);
428
429 const submit = createButton("Attach", "Attach annotation and screenshot");
430 submit.className +=
431 " h-8 shrink-0 border-primary bg-primary px-3 text-primary-foreground shadow-sm hover:bg-primary/90";
432 composerRow.appendChild(submit);
433 editor.appendChild(composerRow);
434
435 const stylePanel = document.createElement("div");
436 stylePanel.className =
437 "hidden max-h-[min(176px,calc(100vh-180px))] overflow-auto border-t border-border bg-muted/40 px-3";
438 editor.appendChild(stylePanel);
439
440 const selected = new Map<Element, SelectedElement>();
441 const regions: PreviewAnnotationRegionTarget[] = [];
442 const strokes: PreviewAnnotationStrokeTarget[] = [];
443 const styleChanges = new Map<string, PreviewAnnotationStyleChange>();
444 const toolButtons = new Map<AnnotationTool, HTMLButtonElement>();
445 let tool: AnnotationTool = "select";
446 let dragStart: PreviewAnnotationPoint | null = null;
447 let activeStroke: { target: PreviewAnnotationStrokeTarget; path: SVGPathElement } | null = null;
448 let pendingCapture = false;
449 let editorExpanded = false;
450 let editorWasShown = false;
451 let editorPosition: { left: number; top: number } | null = null;
452 let editorDrag: { pointerId: number; offsetX: number; offsetY: number } | null = null;
453 let editorLayoutFrame: number | null = null;
454
455 const resizeComment = (): void => {
456 const maxHeight = 96;
457 comment.style.height = "auto";
458 const nextHeight = Math.min(comment.scrollHeight, maxHeight);
459 comment.style.height = `${nextHeight}px`;
460 comment.style.overflowY = comment.scrollHeight > maxHeight ? "auto" : "hidden";
461 queueEditorLayout();
462 };
463 comment.addEventListener("input", resizeComment);
464
465 const updateStatus = (): void => {
466 const hasTargets = selected.size > 0 || regions.length > 0 || strokes.length > 0;
467 editor.style.display = hasTargets ? "flex" : "none";
468 submit.disabled = !hasTargets;
469 submit.style.opacity = hasTargets ? "1" : "0.45";
470 adjust.disabled = !hasTargets;
471 stylePanel.style.display = editorExpanded && selected.size > 0 ? "grid" : "none";
472 queueEditorLayout();
473 if (hasTargets && !editorWasShown) {
474 editorWasShown = true;
475 window.setTimeout(() => comment.focus({ preventScroll: true }), 0);
476 }
477 };
478
479 const refreshToolButtons = (): void => {
480 for (const [candidate, button] of toolButtons) {
481 const active = candidate === tool;
482 button.classList.toggle("bg-primary/10", active);
483 button.classList.toggle("text-primary", active);
484 button.classList.toggle("text-foreground", !active);
485 }
486 if (tool !== "select") hoverOutline.style.display = "none";
487 if (tool !== "marquee") marqueeBox.style.display = "none";
488 document.documentElement.setAttribute("data-t3code-annotation-tool", tool);
489 };
490
491 const removeSelected = (target: SelectedElement): void => {
492 if (target.element instanceof HTMLElement || target.element instanceof SVGElement) {
493 for (const [property, baseline] of target.baselineStyles) {
494 if (baseline) target.element.style.setProperty(property, baseline);
495 else target.element.style.removeProperty(property);
496 }
497 }
498 selected.delete(target.element);
499 target.outline.remove();
500 target.label.remove();
501 for (const [key, change] of styleChanges) {
502 if (change.targetId === target.id) styleChanges.delete(key);
503 }
504 updateStatus();
505 };
506
507 const addSelected = (element: Element): void => {
508 if (selected.has(element)) return;
509 const target: SelectedElement = {
510 id: nextId("element"),
511 element,
512 outline: createBox(PRIMARY, PRIMARY_FILL),
513 label: createLabel(),
514 baselineStyles: new Map(),
515 };
516 selected.set(element, target);
517 root.append(target.outline, target.label);
518 updateSelectedVisual(target);
519 updateStatus();
520 if (editorExpanded) {
521 stylePanel.style.display = "grid";
522 syncStyleControls();
523 }
524 };
525
526 const toggleSelected = (element: Element, additive: boolean): void => {
527 const existing = selected.get(element);
528 if (existing) {
529 removeSelected(existing);
530 return;
531 }
532 if (!additive) {
533 for (const target of Array.from(selected.values())) removeSelected(target);
534 }
535 addSelected(element);
536 };
537
538 const setStyleForSelected = (property: string, value: string): void => {
539 for (const target of selected.values()) {
540 if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement))
541 continue;
542 if (!target.baselineStyles.has(property)) {
543 target.baselineStyles.set(property, target.element.style.getPropertyValue(property));
544 }
545 const key = `${target.id}:${property}`;
546 const previousValue =
547 styleChanges.get(key)?.previousValue ??
548 getComputedStyle(target.element).getPropertyValue(property).trim();
549 target.element.style.setProperty(property, value, "important");
550 styleChanges.set(key, {
551 targetId: target.id,
552 selector: null,
553 property,
554 previousValue,
555 value,
556 });
557 updateSelectedVisual(target);
558 }
559 };
560
561 const textSection = createStyleSection();
562 const colorsSection = createStyleSection();
563 const bordersSection = createStyleSection();
564 const sizingSection = createStyleSection();
565 stylePanel.append(textSection, colorsSection, bordersSection, sizingSection);
566
567 const fontFamily = document.createElement("select");
568 for (const value of ["inherit", "system-ui", "sans-serif", "serif", "monospace"]) {
569 const option = document.createElement("option");
570 option.value = value;
571 option.textContent = value;
572 fontFamily.appendChild(option);
573 }
574 fontFamily.addEventListener("change", () => setStyleForSelected("font-family", fontFamily.value));
575 textSection.appendChild(createField("Font", fontFamily));
576
577 const fontSize = createUnitInput("px", "16");
578 fontSize.min = "1";
579 fontSize.max = "300";
580 fontSize.addEventListener("input", () => {
581 if (fontSize.value) setStyleForSelected("font-size", `${fontSize.value}px`);
582 });
583 textSection.appendChild(createField("Font size", fontSize));
584
585 const fontWeight = document.createElement("select");
586 for (const value of ["300", "400", "500", "600", "700", "800", "900"]) {
587 const option = document.createElement("option");
588 option.value = value;
589 option.textContent = value;
590 fontWeight.appendChild(option);
591 }
592 fontWeight.addEventListener("change", () => setStyleForSelected("font-weight", fontWeight.value));
593 textSection.appendChild(createField("Font weight", fontWeight));
594
595 const lineHeight = document.createElement("input");
596 lineHeight.type = "text";
597 lineHeight.placeholder = "normal / 1.4";
598 lineHeight.addEventListener("change", () => {
599 if (lineHeight.value.trim()) setStyleForSelected("line-height", lineHeight.value.trim());
600 });
601 textSection.appendChild(createField("Line height", lineHeight));
602
603 const createColorRow = (
604 labelText: string,
605 property: string,
606 section: HTMLElement,
607 ): { row: HTMLLabelElement; color: HTMLInputElement; text: HTMLInputElement } => {
608 const row = document.createElement("label");
609 row.className =
610 "grid min-h-7 grid-cols-[82px_minmax(0,1fr)] items-center gap-2 font-sans text-xs font-medium text-muted-foreground";
611 const label = document.createElement("span");
612 label.textContent = labelText;
613 const control = document.createElement("div");
614 control.className =
615 "grid h-7 grid-cols-[22px_minmax(0,1fr)] items-center gap-1 rounded-md border border-input bg-background px-1 shadow-xs";
616 const color = document.createElement("input");
617 color.type = "color";
618 color.setAttribute("aria-label", labelText);
619 color.style.cssText =
620 "width:20px;height:20px;padding:0;border:0;border-radius:5px;overflow:hidden;background:transparent;cursor:pointer";
621 const text = document.createElement("input");
622 text.type = "text";
623 text.setAttribute("aria-label", `${labelText} value`);
624 text.className =
625 "min-w-0 w-full border-0 bg-transparent font-mono text-xs text-foreground outline-none";
626 color.addEventListener("input", () => {
627 text.value = color.value;
628 setStyleForSelected(property, color.value);
629 });
630 text.addEventListener("change", () => {
631 const value = text.value.trim();
632 if (!value) return;
633 setStyleForSelected(property, value);
634 if (/^#[0-9a-f]{6}$/i.test(value)) color.value = value;
635 });
636 control.append(color, text);
637 row.append(label, control);
638 section.appendChild(row);
639 return { row, color, text };
640 };
641
642 const textColor = createColorRow("Text color", "color", colorsSection);
643 const backgroundColor = createColorRow("Background", "background-color", colorsSection);
644
645 const opacity = document.createElement("input");
646 opacity.type = "range";
647 opacity.min = "0";
648 opacity.max = "1";
649 opacity.step = "0.05";
650 opacity.value = "1";
651 opacity.style.accentColor = PRIMARY;
652 opacity.addEventListener("input", () => setStyleForSelected("opacity", opacity.value));
653 colorsSection.appendChild(createField("Opacity", opacity));
654
655 const radius = createUnitInput("px", "0");
656 radius.min = "0";
657 radius.max = "300";
658 radius.addEventListener("input", () => {
659 if (radius.value) setStyleForSelected("border-radius", `${radius.value}px`);
660 });
661 bordersSection.appendChild(createField("Radius", radius));
662
663 const borderColor = createColorRow("Border color", "border-color", bordersSection);
664
665 const borderWidth = createUnitInput("px", "0");
666 borderWidth.min = "0";
667 borderWidth.max = "100";
668 borderWidth.addEventListener("input", () => {
669 if (borderWidth.value) {
670 setStyleForSelected("border-style", "solid");
671 setStyleForSelected("border-width", `${borderWidth.value}px`);
672 }
673 });
674 bordersSection.appendChild(createField("Border width", borderWidth));
675
676 const dimensions = document.createElement("div");
677 dimensions.style.cssText =
678 "display:grid;grid-template-columns:82px minmax(0,1fr);gap:8px;align-items:center";
679 const dimensionLabel = document.createElement("div");
680 dimensionLabel.className = "grid gap-2 font-sans text-xs font-medium text-muted-foreground";
681 dimensionLabel.innerHTML = "<span>Width</span><span>Height</span>";
682 const dimensionControls = document.createElement("div");
683 dimensionControls.style.cssText = "position:relative;display:grid;gap:3px;padding-left:22px";
684 const widthInput = createUnitInput("px", "auto");
685 const heightInput = createUnitInput("px", "auto");
686 styleControl(widthInput);
687 styleControl(heightInput);
688 const aspectLock = createButton("", "Lock aspect ratio");
689 aspectLock.setAttribute("aria-pressed", "true");
690 aspectLock.style.cssText +=
691 ";position:absolute;left:0;top:50%;transform:translateY(-50%);width:18px;height:38px;padding:0";
692 aspectLock.className += " bg-primary/10 text-primary";
693 dimensionControls.append(
694 createUnitControl(widthInput),
695 createUnitControl(heightInput),
696 aspectLock,
697 );
698 dimensions.append(dimensionLabel, dimensionControls);
699 sizingSection.appendChild(dimensions);
700
701 let aspectLocked = true;
702 let aspectRatio = 1;
703 const refreshAspectButton = (): void => {
704 aspectLock.innerHTML = aspectLocked
705 ? '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="M8 6.5 9.5 5A3.5 3.5 0 0 1 14.5 10l-1.5 1.5M12 13.5 10.5 15A3.5 3.5 0 0 1 5.5 10L7 8.5M7.5 12.5l5-5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>'
706 : '<svg viewBox="0 0 20 20" width="14" height="14" aria-hidden="true"><path d="m6 6 8 8M8 6.5 9.5 5A3.5 3.5 0 0 1 14 9M12 13.5 10.5 15A3.5 3.5 0 0 1 6 11" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>';
707 aspectLock.setAttribute("aria-pressed", String(aspectLocked));
708 aspectLock.classList.toggle("bg-primary/10", aspectLocked);
709 aspectLock.classList.toggle("text-primary", aspectLocked);
710 aspectLock.classList.toggle("bg-muted", !aspectLocked);
711 aspectLock.classList.toggle("text-muted-foreground", !aspectLocked);
712 };
713 aspectLock.addEventListener("click", () => {
714 aspectLocked = !aspectLocked;
715 refreshAspectButton();
716 });
717 widthInput.addEventListener("input", () => {
718 const width = Number(widthInput.value);
719 if (!Number.isFinite(width) || width <= 0) return;
720 setStyleForSelected("width", `${width}px`);
721 if (aspectLocked && aspectRatio > 0) {
722 const height = Math.max(1, Math.round(width / aspectRatio));
723 heightInput.value = String(height);
724 setStyleForSelected("height", `${height}px`);
725 }
726 });
727 heightInput.addEventListener("input", () => {
728 const height = Number(heightInput.value);
729 if (!Number.isFinite(height) || height <= 0) return;
730 setStyleForSelected("height", `${height}px`);
731 if (aspectLocked && aspectRatio > 0) {
732 const width = Math.max(1, Math.round(height * aspectRatio));
733 widthInput.value = String(width);
734 setStyleForSelected("width", `${width}px`);
735 }
736 });
737 refreshAspectButton();
738
739 const addSpacingField = (
740 label: string,
741 property: string,
742 placeholder: string,
743 ): HTMLInputElement => {
744 const input = document.createElement("input");
745 input.type = "text";
746 input.placeholder = placeholder;
747 input.addEventListener("change", () => {
748 if (input.value.trim()) setStyleForSelected(property, input.value.trim());
749 });
750 sizingSection.appendChild(createField(label, input));
751 return input;
752 };
753 const padding = addSpacingField("Padding", "padding", "0 0 0 0");
754 const margin = addSpacingField("Margin", "margin", "0 0 0 0");
755 const gap = addSpacingField("Gap", "gap", "0px");
756
757 const syncStyleControls = (): void => {
758 const first = selected.values().next().value as SelectedElement | undefined;
759 if (!first) return;
760 const computed = getComputedStyle(first.element);
761 const rect = first.element.getBoundingClientRect();
762 aspectRatio = rect.height > 0 ? rect.width / rect.height : 1;
763 widthInput.value = String(Math.round(rect.width));
764 heightInput.value = String(Math.round(rect.height));
765 fontSize.value = String(Math.round(Number.parseFloat(computed.fontSize) || 16));
766 fontWeight.value = computed.fontWeight.match(/^[0-9]+$/) ? computed.fontWeight : "400";
767 lineHeight.value = computed.lineHeight;
768 fontFamily.value = Array.from(fontFamily.options).some(
769 (option) => option.value === computed.fontFamily,
770 )
771 ? computed.fontFamily
772 : "inherit";
773 textColor.text.value = computed.color;
774 backgroundColor.text.value = computed.backgroundColor;
775 borderColor.text.value = computed.borderColor;
776 opacity.value = computed.opacity;
777 radius.value = String(Math.round(Number.parseFloat(computed.borderRadius) || 0));
778 borderWidth.value = String(Math.round(Number.parseFloat(computed.borderWidth) || 0));
779 padding.value = computed.padding;
780 margin.value = computed.margin;
781 gap.value = computed.gap === "normal" ? "0px" : computed.gap;
782 };
783
784 const tools: ReadonlyArray<[AnnotationTool, string, string]> = [
785 ["select", "Select", "Select elements (V)"],
786 ["marquee", "Region", "Draw a region or marquee-select elements (R)"],
787 ["draw", "Draw", "Draw freehand (D)"],
788 ["erase", "Erase", "Remove an annotation target (E)"],
789 ];
790 for (const [candidate, label, title] of tools) {
791 const button = createButton(label, title);
792 button.className += " h-8 px-2.5 text-sm";
793 button.addEventListener("click", () => {
794 tool = candidate;
795 refreshToolButtons();
796 });
797 toolButtons.set(candidate, button);
798 toolbar.appendChild(button);
799 }
800
801 const clampEditorPosition = (left: number, top: number): { left: number; top: number } => {
802 const margin = 8;
803 const rect = editor.getBoundingClientRect();
804 return {
805 left: Math.min(
806 Math.max(margin, left),
807 Math.max(margin, window.innerWidth - rect.width - margin),
808 ),
809 top: Math.min(
810 Math.max(margin, top),
811 Math.max(margin, window.innerHeight - rect.height - margin),
812 ),
813 };
814 };
815
816 const applyEditorPosition = (position: { left: number; top: number }): void => {
817 const clamped = clampEditorPosition(position.left, position.top);
818 editor.style.left = `${clamped.left}px`;
819 editor.style.top = `${clamped.top}px`;
820 editor.style.right = "auto";
821 editor.style.bottom = "auto";
822 if (editorExpanded) editorPosition = clamped;
823 };
824
825 const getAnnotationBounds = (): PreviewAnnotationRect | null =>
826 unionRects(
827 [
828 ...Array.from(selected.values(), (target) =>
829 rectFromDomRect(target.element.getBoundingClientRect()),
830 ),
831 ...regions.map((region) => region.rect),
832 ...strokes.map((stroke) => stroke.bounds),
833 ],
834 0,
835 );
836
837 const positionCompactEditor = (): void => {
838 const bounds = getAnnotationBounds();
839 if (!bounds) return;
840 const editorRect = editor.getBoundingClientRect();
841 const gap = 8;
842 const candidates = [
843 { left: bounds.x + bounds.width + gap, top: bounds.y },
844 { left: bounds.x - editorRect.width - gap, top: bounds.y },
845 {
846 left: bounds.x + bounds.width - editorRect.width,
847 top: bounds.y + bounds.height + gap,
848 },
849 {
850 left: bounds.x + bounds.width - editorRect.width,
851 top: bounds.y - editorRect.height - gap,
852 },
853 ];
854 const overflow = (position: { left: number; top: number }): number =>
855 Math.max(0, -position.left) +
856 Math.max(0, -position.top) +
857 Math.max(0, position.left + editorRect.width - window.innerWidth) +
858 Math.max(0, position.top + editorRect.height - window.innerHeight);
859 const best = candidates.reduce((current, candidate) =>
860 overflow(candidate) < overflow(current) ? candidate : current,
861 );
862 applyEditorPosition(best);
863 };
864
865 function queueEditorLayout(): void {
866 if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame);
867 editorLayoutFrame = window.requestAnimationFrame(() => {
868 editorLayoutFrame = null;
869 if (editor.style.display === "none") return;
870 if (editorExpanded && editorPosition) applyEditorPosition(editorPosition);
871 else positionCompactEditor();
872 });
873 }
874
875 adjust.addEventListener("click", () => {
876 if (selected.size === 0) return;
877 if (!editorExpanded) {
878 const rect = editor.getBoundingClientRect();
879 editorExpanded = true;
880 editorPosition = { left: rect.left, top: rect.top };
881 stylePanel.style.display = selected.size > 0 ? "grid" : "none";
882 dragHandle.style.display = "block";
883 adjust.setAttribute("aria-expanded", "true");
884 adjust.title = "Collapse annotation editor";
885 adjust.setAttribute("aria-label", "Collapse annotation editor");
886 if (selected.size > 0) syncStyleControls();
887 } else {
888 editorExpanded = false;
889 editorPosition = null;
890 stylePanel.style.display = "none";
891 dragHandle.style.display = "none";
892 adjust.setAttribute("aria-expanded", "false");
893 adjust.title = "Expand annotation editor";
894 adjust.setAttribute("aria-label", "Expand annotation editor");
895 }
896 queueEditorLayout();
897 });
898
899 const onEditorPointerDown = (event: PointerEvent): void => {
900 if (event.button !== 0 || !editorExpanded) return;
901 const rect = editor.getBoundingClientRect();
902 editorDrag = {
903 pointerId: event.pointerId,
904 offsetX: event.clientX - rect.left,
905 offsetY: event.clientY - rect.top,
906 };
907 dragHandle.setPointerCapture(event.pointerId);
908 dragHandle.style.cursor = "grabbing";
909 event.preventDefault();
910 event.stopPropagation();
911 };
912
913 const onEditorPointerMove = (event: PointerEvent): void => {
914 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;
915 applyEditorPosition({
916 left: event.clientX - editorDrag.offsetX,
917 top: event.clientY - editorDrag.offsetY,
918 });
919 event.preventDefault();
920 event.stopPropagation();
921 };
922
923 const onEditorPointerUp = (event: PointerEvent): void => {
924 if (!editorDrag || editorDrag.pointerId !== event.pointerId) return;
925 editorDrag = null;
926 dragHandle.style.cursor = "grab";
927 if (dragHandle.hasPointerCapture(event.pointerId))
928 dragHandle.releasePointerCapture(event.pointerId);
929 event.preventDefault();
930 event.stopPropagation();
931 };
932 dragHandle.addEventListener("pointerdown", onEditorPointerDown);
933 dragHandle.addEventListener("pointermove", onEditorPointerMove);
934 dragHandle.addEventListener("pointerup", onEditorPointerUp);
935 dragHandle.addEventListener("pointercancel", onEditorPointerUp);
936
937 const repaint = (): void => {
938 for (const target of selected.values()) updateSelectedVisual(target);
939 queueEditorLayout();
940 };
941
942 const removeTargetAtPoint = (x: number, y: number): boolean => {
943 for (const target of Array.from(selected.values()).toReversed()) {
944 const rect = target.element.getBoundingClientRect();
945 if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
946 removeSelected(target);
947 return true;
948 }
949 }
950 const regionIndex = regions.findIndex(
951 (region) =>
952 x >= region.rect.x &&
953 x <= region.rect.x + region.rect.width &&
954 y >= region.rect.y &&
955 y <= region.rect.y + region.rect.height,
956 );
957 if (regionIndex >= 0) {
958 const [removed] = regions.splice(regionIndex, 1);
959 root.querySelector(`[data-region-id="${removed?.id}"]`)?.remove();
960 updateStatus();
961 return true;
962 }
963 const strokeIndex = strokes.findIndex(
964 (stroke) =>
965 x >= stroke.bounds.x &&
966 x <= stroke.bounds.x + stroke.bounds.width &&
967 y >= stroke.bounds.y &&
968 y <= stroke.bounds.y + stroke.bounds.height,
969 );
970 if (strokeIndex >= 0) {
971 const [removed] = strokes.splice(strokeIndex, 1);
972 svg.querySelector(`[data-stroke-id="${removed?.id}"]`)?.remove();
973 updateStatus();
974 return true;
975 }
976 return false;
977 };
978
979 const selectElementsInRect = (rect: PreviewAnnotationRect): number => {
980 const candidates = Array.from(document.querySelectorAll("body *"))
981 .filter((element) => !isAnnotationNode(element))
982 .map((element) => ({ element, rect: element.getBoundingClientRect() }))
983 .filter(({ rect: candidate }) => {
984 if (candidate.width < 2 || candidate.height < 2) return false;
985 return !(
986 candidate.right < rect.x ||
987 candidate.left > rect.x + rect.width ||
988 candidate.bottom < rect.y ||
989 candidate.top > rect.y + rect.height
990 );
991 })
992 .filter(({ element, rect: candidate }) => {
993 const centerX = candidate.left + candidate.width / 2;
994 const centerY = candidate.top + candidate.height / 2;
995 return (
996 centerX >= rect.x &&
997 centerX <= rect.x + rect.width &&
998 centerY >= rect.y &&
999 centerY <= rect.y + rect.height &&
1000 (element.children.length === 0 ||
1001 element instanceof HTMLButtonElement ||
1002 element instanceof HTMLAnchorElement ||
1003 element.getAttribute("role") === "button")
1004 );
1005 })
1006 .sort(
1007 (left, right) => left.rect.width * left.rect.height - right.rect.width * right.rect.height,
1008 )
1009 .slice(0, MAX_MARQUEE_ELEMENTS);
1010 for (const candidate of candidates) addSelected(candidate.element);
1011 return candidates.length;
1012 };
1013
1014 const clearHoverOutline = (): void => {
1015 hoverOutline.style.display = "none";
1016 };
1017
1018 const onPointerMove = (event: PointerEvent): void => {
1019 if (isAnnotationNode(event.target as Element)) {
1020 clearHoverOutline();
1021 return;
1022 }
1023 if (tool === "select" && dragStart === null) {
1024 const target = pickFromPoint(event.clientX, event.clientY);
1025 if (target) positionBox(hoverOutline, rectFromDomRect(target.getBoundingClientRect()));
1026 else clearHoverOutline();
1027 return;
1028 }
1029 clearHoverOutline();
1030 if (tool === "marquee" && dragStart) {
1031 positionBox(
1032 marqueeBox,
1033 normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY),
1034 );
1035 return;
1036 }
1037 if (tool === "draw" && activeStroke) {
1038 activeStroke.target.points = [
1039 ...activeStroke.target.points,
1040 { x: event.clientX, y: event.clientY },
1041 ];
1042 activeStroke.target.bounds = strokeBounds(
1043 activeStroke.target.points,
1044 activeStroke.target.width,
1045 );
1046 activeStroke.path.setAttribute("d", pathFromPoints(activeStroke.target.points));
1047 }
1048 };
1049
1050 const onPointerDown = (event: PointerEvent): void => {
1051 if (event.button !== 0 || isAnnotationNode(event.target as Element)) return;
1052 event.preventDefault();
1053 event.stopPropagation();
1054 if (tool === "select") {
1055 const target = pickFromPoint(event.clientX, event.clientY);
1056 if (target) toggleSelected(target, event.shiftKey);
1057 return;
1058 }
1059 if (tool === "erase") {
1060 removeTargetAtPoint(event.clientX, event.clientY);
1061 return;
1062 }
1063 dragStart = { x: event.clientX, y: event.clientY };
1064 if (tool === "draw") {
1065 const stroke: PreviewAnnotationStrokeTarget = {
1066 id: nextId("stroke"),
1067 color: annotationTheme?.primary ?? "#2563eb",
1068 width: 4,
1069 points: [dragStart],
1070 bounds: { x: dragStart.x, y: dragStart.y, width: 1, height: 1 },
1071 };
1072 const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1073 path.setAttribute(OVERLAY_ATTRIBUTE, "");
1074 path.setAttribute("data-stroke-id", stroke.id);
1075 path.setAttribute("fill", "none");
1076 path.setAttribute("stroke", stroke.color);
1077 path.setAttribute("stroke-width", String(stroke.width));
1078 path.setAttribute("stroke-linecap", "round");
1079 path.setAttribute("stroke-linejoin", "round");
1080 svg.appendChild(path);
1081 activeStroke = { target: stroke, path };
1082 }
1083 };
1084
1085 const onPointerUp = (event: PointerEvent): void => {
1086 if (!dragStart) return;
1087 event.preventDefault();
1088 event.stopPropagation();
1089 if (tool === "marquee") {
1090 const rect = normalizeRect(dragStart.x, dragStart.y, event.clientX, event.clientY);
1091 marqueeBox.style.display = "none";
1092 if (isUsableRect(rect)) {
1093 const found = selectElementsInRect(rect);
1094 if (found === 0) {
1095 const region: PreviewAnnotationRegionTarget = { id: nextId("region"), rect };
1096 regions.push(region);
1097 const regionBox = createBox(
1098 PRIMARY,
1099 "color-mix(in srgb, var(--t3-primary) 6%, transparent)",
1100 );
1101 regionBox.setAttribute("data-region-id", region.id);
1102 positionBox(regionBox, rect);
1103 root.appendChild(regionBox);
1104 }
1105 }
1106 } else if (tool === "draw" && activeStroke) {
1107 if (activeStroke.target.points.length > 1) strokes.push(activeStroke.target);
1108 else activeStroke.path.remove();
1109 activeStroke = null;
1110 }
1111 dragStart = null;
1112 updateStatus();
1113 };
1114
1115 const onClick = (event: MouseEvent): void => {
1116 if (isAnnotationNode(event.target as Element)) return;
1117 event.preventDefault();
1118 event.stopPropagation();
1119 };
1120
1121 const onPointerOut = (event: PointerEvent): void => {
1122 if (event.relatedTarget === null) clearHoverOutline();
1123 };
1124
1125 const onWindowBlur = (): void => {
1126 clearHoverOutline();
1127 };
1128
1129 const restoreStyles = (): void => {
1130 for (const target of selected.values()) {
1131 if (!(target.element instanceof HTMLElement || target.element instanceof SVGElement))
1132 continue;
1133 for (const [property, baseline] of target.baselineStyles) {
1134 if (baseline) target.element.style.setProperty(property, baseline);
1135 else target.element.style.removeProperty(property);
1136 }
1137 }
1138 };
1139
1140 const teardown = (notifyMain: boolean): void => {
1141 if (finished) return;
1142 finished = true;
1143 restoreStyles();
1144 window.removeEventListener("pointermove", onPointerMove, true);
1145 window.removeEventListener("pointerdown", onPointerDown, true);
1146 window.removeEventListener("pointerup", onPointerUp, true);
1147 window.removeEventListener("pointerout", onPointerOut, true);
1148 window.removeEventListener("click", onClick, true);
1149 window.removeEventListener("blur", onWindowBlur);
1150 window.removeEventListener("keydown", onKeyDown, true);
1151 window.removeEventListener("scroll", repaint, true);
1152 window.removeEventListener("resize", repaint);
1153 dragHandle.removeEventListener("pointerdown", onEditorPointerDown);
1154 dragHandle.removeEventListener("pointermove", onEditorPointerMove);
1155 dragHandle.removeEventListener("pointerup", onEditorPointerUp);
1156 dragHandle.removeEventListener("pointercancel", onEditorPointerUp);
1157 if (editorLayoutFrame !== null) window.cancelAnimationFrame(editorLayoutFrame);
1158 ipcRenderer.off(CANCEL_PICK_CHANNEL, onCancel);
1159 ipcRenderer.off(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1160 document.documentElement.removeAttribute("data-t3code-annotation-tool");
1161 cursorStyle.remove();
1162 host.remove();
1163 activeSession = null;
1164 if (notifyMain) ipcRenderer.send(ELEMENT_PICKED_CHANNEL, null);
1165 };
1166
1167 const onCancel = (): void => teardown(false);
1168 const onCaptured = (): void => teardown(false);
1169 const onKeyDown = (event: KeyboardEvent): void => {
1170 if (isAnnotationNode(event.target as Element) && event.key !== "Escape") return;
1171 if (event.key === "Escape") {
1172 event.preventDefault();
1173 event.stopPropagation();
1174 teardown(true);
1175 return;
1176 }
1177 if (event.key === "v") tool = "select";
1178 else if (event.key === "r") tool = "marquee";
1179 else if (event.key === "d") tool = "draw";
1180 else if (event.key === "e") tool = "erase";
1181 else return;
1182 refreshToolButtons();
1183 };
1184
1185 submit.addEventListener("click", () => {
1186 if (pendingCapture || (selected.size === 0 && regions.length === 0 && strokes.length === 0))
1187 return;
1188 pendingCapture = true;
1189 submit.disabled = true;
1190 submit.textContent = "Capturing…";
1191 void Promise.all(
1192 Array.from(selected.values()).map(async (target) => {
1193 const element = await captureElement(target.element);
1194 if (!element) return null;
1195 for (const change of styleChanges.values()) {
1196 if (change.targetId === target.id) change.selector = element.selector;
1197 }
1198 return {
1199 id: target.id,
1200 element,
1201 rect: rectFromDomRect(target.element.getBoundingClientRect()),
1202 };
1203 }),
1204 ).then((captured) => {
1205 const elements = captured.filter((target) => target !== null);
1206 const annotation: PreviewAnnotationPayload = {
1207 id: nextId("annotation"),
1208 pageUrl: location.href,
1209 pageTitle: document.title?.trim() || null,
1210 comment: comment.value.trim(),
1211 elements,
1212 regions: [...regions],
1213 strokes: [...strokes],
1214 styleChanges: Array.from(styleChanges.values()),
1215 screenshot: null,
1216 createdAt: new Date().toISOString(),
1217 };
1218 editor.style.display = "none";
1219 toolbar.style.display = "none";
1220 hoverOutline.style.display = "none";
1221 const screenshotRect = unionRects([
1222 ...elements.map((target) => target.rect),
1223 ...regions.map((region) => region.rect),
1224 ...strokes.map((stroke) => stroke.bounds),
1225 ]);
1226 ipcRenderer.send(ELEMENT_PICKED_CHANNEL, annotation, screenshotRect);
1227 });
1228 });
1229 comment.addEventListener("keydown", (event) => {
1230 if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return;
1231 event.preventDefault();
1232 submit.click();
1233 });
1234
1235 window.addEventListener("pointermove", onPointerMove, { capture: true, passive: false });
1236 window.addEventListener("pointerdown", onPointerDown, { capture: true, passive: false });
1237 window.addEventListener("pointerup", onPointerUp, { capture: true, passive: false });
1238 window.addEventListener("pointerout", onPointerOut, { capture: true, passive: true });
1239 window.addEventListener("click", onClick, { capture: true, passive: false });
1240 window.addEventListener("blur", onWindowBlur);
1241 window.addEventListener("keydown", onKeyDown, { capture: true });
1242 window.addEventListener("scroll", repaint, { capture: true, passive: true });
1243 window.addEventListener("resize", repaint, { passive: true });
1244 ipcRenderer.on(CANCEL_PICK_CHANNEL, onCancel);
1245 ipcRenderer.on(ANNOTATION_CAPTURED_CHANNEL, onCaptured);
1246 document.documentElement.appendChild(host);
1247 refreshToolButtons();
1248 updateStatus();
1249 activeSession = {
1250 teardown,
1251 applyTheme: (theme) => applyAnnotationTheme(host, theme),
1252 };
1253}
1254
1255ipcRenderer.on(START_PICK_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme | undefined) => {
1256 if (theme) annotationTheme = theme;
1257 startAnnotation();
1258});
1259ipcRenderer.on(ANNOTATION_THEME_CHANNEL, (_event, theme: DesktopPreviewAnnotationTheme) => {
1260 annotationTheme = theme;
1261 activeSession?.applyTheme(theme);
1262});
1263ipcRenderer.on(CANCEL_PICK_CHANNEL, () => activeSession?.teardown(false));
apps/desktop/src/preview/PickedElementPayload.test.ts created+201
......@@ -0,0 +1,201 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { isPickedElementPayload, isPreviewAnnotationPayload } from "./PickedElementPayload.ts";
4
5function validPayload(overrides?: Record<string, unknown>): Record<string, unknown> {
6 return {
7 pageUrl: "https://example.com/",
8 pageTitle: "Example",
9 tagName: "button",
10 selector: "button.submit",
11 htmlPreview: "<button>Save</button>",
12 componentName: "SubmitButton",
13 source: {
14 functionName: "SubmitButton",
15 fileName: "/repo/src/Button.tsx",
16 lineNumber: 12,
17 columnNumber: 5,
18 },
19 stack: [
20 {
21 functionName: "SubmitButton",
22 fileName: "/repo/src/Button.tsx",
23 lineNumber: 12,
24 columnNumber: 5,
25 },
26 ],
27 styles: ".submit { color: white; }",
28 pickedAt: "2026-05-03T18:00:00.000Z",
29 ...overrides,
30 };
31}
32
33describe("isPickedElementPayload", () => {
34 it("accepts a complete, well-typed payload", () => {
35 expect(isPickedElementPayload(validPayload())).toBe(true);
36 });
37
38 it("accepts nullable string fields when null", () => {
39 expect(
40 isPickedElementPayload(
41 validPayload({ pageTitle: null, selector: null, componentName: null, source: null }),
42 ),
43 ).toBe(true);
44 });
45
46 it("accepts an empty stack array", () => {
47 expect(isPickedElementPayload(validPayload({ stack: [] }))).toBe(true);
48 });
49
50 it("accepts stack frames with null fields", () => {
51 expect(
52 isPickedElementPayload(
53 validPayload({
54 stack: [
55 {
56 functionName: null,
57 fileName: null,
58 lineNumber: null,
59 columnNumber: null,
60 },
61 ],
62 }),
63 ),
64 ).toBe(true);
65 });
66
67 it("rejects null and primitive inputs", () => {
68 expect(isPickedElementPayload(null)).toBe(false);
69 expect(isPickedElementPayload(undefined)).toBe(false);
70 expect(isPickedElementPayload("string")).toBe(false);
71 expect(isPickedElementPayload(42)).toBe(false);
72 expect(isPickedElementPayload([])).toBe(false);
73 });
74
75 it.each<[string, Record<string, unknown>]>([
76 ["missing pageUrl", validPayload({ pageUrl: undefined })],
77 ["wrong-type pageUrl", validPayload({ pageUrl: 123 })],
78 ["missing tagName", validPayload({ tagName: undefined })],
79 ["missing htmlPreview", validPayload({ htmlPreview: undefined })],
80 ["missing styles", validPayload({ styles: undefined })],
81 ["missing pickedAt", validPayload({ pickedAt: undefined })],
82 ["wrong-type pageTitle", validPayload({ pageTitle: 99 })],
83 ["wrong-type selector", validPayload({ selector: 99 })],
84 ["wrong-type componentName", validPayload({ componentName: 99 })],
85 ])("rejects payloads with %s", (_label, value) => {
86 expect(isPickedElementPayload(value)).toBe(false);
87 });
88
89 it("rejects malformed source frames", () => {
90 expect(
91 isPickedElementPayload(
92 validPayload({
93 source: {
94 functionName: 0,
95 fileName: null,
96 lineNumber: null,
97 columnNumber: null,
98 },
99 }),
100 ),
101 ).toBe(false);
102 });
103
104 it("rejects non-finite numeric line/column numbers", () => {
105 expect(
106 isPickedElementPayload(
107 validPayload({
108 source: {
109 functionName: null,
110 fileName: null,
111 lineNumber: Number.POSITIVE_INFINITY,
112 columnNumber: null,
113 },
114 }),
115 ),
116 ).toBe(false);
117 expect(
118 isPickedElementPayload(
119 validPayload({
120 source: {
121 functionName: null,
122 fileName: null,
123 lineNumber: Number.NaN,
124 columnNumber: null,
125 },
126 }),
127 ),
128 ).toBe(false);
129 });
130
131 it("rejects malformed stack arrays", () => {
132 expect(isPickedElementPayload(validPayload({ stack: "not-an-array" }))).toBe(false);
133 expect(isPickedElementPayload(validPayload({ stack: [{ bogus: true }] }))).toBe(false);
134 });
135});
136
137function validAnnotation(overrides?: Record<string, unknown>): Record<string, unknown> {
138 return {
139 id: "annotation_1",
140 pageUrl: "https://example.com/",
141 pageTitle: "Example",
142 comment: "Make this clearer",
143 elements: [
144 {
145 id: "element_1",
146 element: validPayload(),
147 rect: { x: 10, y: 20, width: 100, height: 40 },
148 },
149 ],
150 regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }],
151 strokes: [
152 {
153 id: "stroke_1",
154 color: "#7c3aed",
155 width: 4,
156 points: [
157 { x: 10, y: 10 },
158 { x: 20, y: 20 },
159 ],
160 bounds: { x: 6, y: 6, width: 18, height: 18 },
161 },
162 ],
163 styleChanges: [
164 {
165 targetId: "element_1",
166 selector: "button.submit",
167 property: "opacity",
168 previousValue: "1",
169 value: "0.5",
170 },
171 ],
172 screenshot: null,
173 createdAt: "2026-06-11T00:00:00.000Z",
174 ...overrides,
175 };
176}
177
178describe("isPreviewAnnotationPayload", () => {
179 it("accepts a structured annotation draft before screenshot capture", () => {
180 expect(isPreviewAnnotationPayload(validAnnotation())).toBe(true);
181 });
182
183 it("rejects screenshots supplied by the guest preload", () => {
184 expect(isPreviewAnnotationPayload(validAnnotation({ screenshot: { dataUrl: "bad" } }))).toBe(
185 false,
186 );
187 });
188
189 it("rejects malformed geometry and nested element payloads", () => {
190 expect(
191 isPreviewAnnotationPayload(
192 validAnnotation({ regions: [{ id: "region_1", rect: { x: 0, y: 0, width: "wide" } }] }),
193 ),
194 ).toBe(false);
195 expect(
196 isPreviewAnnotationPayload(
197 validAnnotation({ elements: [{ id: "element_1", element: {}, rect: {} }] }),
198 ),
199 ).toBe(false);
200 });
201});
apps/desktop/src/preview/PickedElementPayload.ts created+146
......@@ -0,0 +1,146 @@
1/**
2 * Strict structural validator for `PickedElementPayload` messages received
3 * from the in-page picker preload (`apps/desktop/src/preview/PickPreload.ts`)
4 * via `wc.ipc`. Lives in its own electron-free module so the validator is
5 * trivially unit-testable.
6 *
7 * Validation must be tight: downstream `normalizeElementContextSelection`
8 * calls `.trim()` on incoming strings, so a malformed payload (preload bug,
9 * future schema mismatch, malicious page that intercepts the preload's IPC
10 * channel via prototype pollution) would otherwise throw deep in the
11 * renderer and the chip silently never appears.
12 */
13import type { PickedElementPayload, PreviewAnnotationPayload } from "@t3tools/contracts";
14
15function isStringOrNull(value: unknown): value is string | null {
16 return value === null || typeof value === "string";
17}
18
19function isFiniteNumberOrNull(value: unknown): value is number | null {
20 return value === null || (typeof value === "number" && Number.isFinite(value));
21}
22
23function isPickedStackFrame(value: unknown): boolean {
24 if (typeof value !== "object" || value === null) return false;
25 const frame = value as Record<string, unknown>;
26 return (
27 isStringOrNull(frame["functionName"]) &&
28 isStringOrNull(frame["fileName"]) &&
29 isFiniteNumberOrNull(frame["lineNumber"]) &&
30 isFiniteNumberOrNull(frame["columnNumber"])
31 );
32}
33
34export function isPickedElementPayload(value: unknown): value is PickedElementPayload {
35 if (typeof value !== "object" || value === null) return false;
36 const c = value as Record<string, unknown>;
37 if (typeof c["pageUrl"] !== "string") return false;
38 if (typeof c["tagName"] !== "string") return false;
39 if (typeof c["htmlPreview"] !== "string") return false;
40 if (typeof c["styles"] !== "string") return false;
41 if (typeof c["pickedAt"] !== "string") return false;
42 if (!isStringOrNull(c["pageTitle"])) return false;
43 if (!isStringOrNull(c["selector"])) return false;
44 if (!isStringOrNull(c["componentName"])) return false;
45 if (c["source"] !== null && !isPickedStackFrame(c["source"])) return false;
46 if (!Array.isArray(c["stack"])) return false;
47 if (!c["stack"].every(isPickedStackFrame)) return false;
48 return true;
49}
50
51function isRect(value: unknown): boolean {
52 if (typeof value !== "object" || value === null) return false;
53 const rect = value as Record<string, unknown>;
54 return ["x", "y", "width", "height"].every(
55 (key) => typeof rect[key] === "number" && Number.isFinite(rect[key]),
56 );
57}
58
59function isPoint(value: unknown): boolean {
60 if (typeof value !== "object" || value === null) return false;
61 const point = value as Record<string, unknown>;
62 return (
63 typeof point["x"] === "number" &&
64 Number.isFinite(point["x"]) &&
65 typeof point["y"] === "number" &&
66 Number.isFinite(point["y"])
67 );
68}
69
70export function isPreviewAnnotationPayload(value: unknown): value is PreviewAnnotationPayload {
71 if (typeof value !== "object" || value === null) return false;
72 const annotation = value as Record<string, unknown>;
73 if (typeof annotation["id"] !== "string") return false;
74 if (typeof annotation["pageUrl"] !== "string") return false;
75 if (!isStringOrNull(annotation["pageTitle"])) return false;
76 if (typeof annotation["comment"] !== "string") return false;
77 if (typeof annotation["createdAt"] !== "string") return false;
78 if (annotation["screenshot"] !== null) return false;
79
80 const elements = annotation["elements"];
81 if (!Array.isArray(elements)) return false;
82 if (
83 !elements.every((entry) => {
84 if (typeof entry !== "object" || entry === null) return false;
85 const target = entry as Record<string, unknown>;
86 return (
87 typeof target["id"] === "string" &&
88 isPickedElementPayload(target["element"]) &&
89 isRect(target["rect"])
90 );
91 })
92 ) {
93 return false;
94 }
95
96 const regions = annotation["regions"];
97 if (!Array.isArray(regions)) return false;
98 if (
99 !regions.every((entry) => {
100 if (typeof entry !== "object" || entry === null) return false;
101 const target = entry as Record<string, unknown>;
102 return typeof target["id"] === "string" && isRect(target["rect"]);
103 })
104 ) {
105 return false;
106 }
107
108 const strokes = annotation["strokes"];
109 if (!Array.isArray(strokes)) return false;
110 if (
111 !strokes.every((entry) => {
112 if (typeof entry !== "object" || entry === null) return false;
113 const target = entry as Record<string, unknown>;
114 return (
115 typeof target["id"] === "string" &&
116 typeof target["color"] === "string" &&
117 typeof target["width"] === "number" &&
118 Number.isFinite(target["width"]) &&
119 Array.isArray(target["points"]) &&
120 target["points"].every(isPoint) &&
121 isRect(target["bounds"])
122 );
123 })
124 ) {
125 return false;
126 }
127
128 const styleChanges = annotation["styleChanges"];
129 if (!Array.isArray(styleChanges)) return false;
130 if (
131 !styleChanges.every((entry) => {
132 if (typeof entry !== "object" || entry === null) return false;
133 const change = entry as Record<string, unknown>;
134 return (
135 typeof change["targetId"] === "string" &&
136 isStringOrNull(change["selector"]) &&
137 typeof change["property"] === "string" &&
138 typeof change["previousValue"] === "string" &&
139 typeof change["value"] === "string"
140 );
141 })
142 ) {
143 return false;
144 }
145 return true;
146}
apps/desktop/src/preview/PlaywrightInjectedRuntime.test.ts created+20
......@@ -0,0 +1,20 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 playwrightInjectedRuntimeInstallExpression,
5 playwrightInjectedRuntimeSource,
6} from "./PlaywrightInjectedRuntime.ts";
7
8describe("playwright injected runtime", () => {
9 it("extracts the pinned runtime from playwright-core", async () => {
10 const source = await playwrightInjectedRuntimeSource();
11 expect(source.length).toBeGreaterThan(100_000);
12 expect(source).toContain("InjectedScript");
13 });
14
15 it("builds an idempotent install expression", async () => {
16 const expression = await playwrightInjectedRuntimeInstallExpression();
17 expect(expression).toContain("__t3PlaywrightInjected");
18 expect(expression).toContain('testIdAttributeName":"data-testid');
19 });
20});
apps/desktop/src/preview/PlaywrightInjectedRuntime.ts created+49
......@@ -0,0 +1,49 @@
1// @effect-diagnostics nodeBuiltinImport:off
2import { createRequire } from "node:module";
3import { dirname, join } from "node:path";
4import { readFile } from "node:fs/promises";
5import { runInNewContext } from "node:vm";
6
7const require = createRequire(import.meta.url);
8let sourcePromise: Promise<string> | null = null;
9
10export const playwrightInjectedRuntimeSource = (): Promise<string> => {
11 sourcePromise ??= (async () => {
12 const packageJsonPath = require.resolve("playwright-core/package.json");
13 const coreBundle = await readFile(join(dirname(packageJsonPath), "lib/coreBundle.js"), "utf8");
14 const marker = "source3 = ";
15 const start = coreBundle.indexOf(marker);
16 if (start < 0) throw new Error("Playwright injected runtime marker was not found.");
17 const literalStart = start + marker.length;
18 const literalEnd = coreBundle.indexOf(";\n }\n});", literalStart);
19 if (literalEnd < 0) throw new Error("Playwright injected runtime terminator was not found.");
20 const literal = coreBundle.slice(literalStart, literalEnd);
21 const source = runInNewContext(literal, Object.create(null), { timeout: 1_000 });
22 if (typeof source !== "string" || source.length < 100_000) {
23 throw new Error("Playwright injected runtime extraction returned invalid source.");
24 }
25 return source;
26 })();
27 return sourcePromise;
28};
29
30export const playwrightInjectedRuntimeInstallExpression = async (): Promise<string> => {
31 const source = await playwrightInjectedRuntimeSource();
32 const options = {
33 isUnderTest: false,
34 sdkLanguage: "javascript",
35 testIdAttributeName: "data-testid",
36 stableRafCount: 1,
37 browserName: "chromium",
38 shouldPrependErrorPrefix: false,
39 isUtilityWorld: false,
40 customEngines: [],
41 };
42 return `(() => {
43 if (globalThis.__t3PlaywrightInjected) return true;
44 const module = { exports: {} };
45 ${source}
46 globalThis.__t3PlaywrightInjected = new (module.exports.InjectedScript())(globalThis, ${JSON.stringify(options)});
47 return true;
48 })()`;
49};
apps/desktop/src/preview/WebviewPreferences.test.ts created+78
......@@ -0,0 +1,78 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { PREVIEW_WEBVIEW_PREFERENCES } from "./WebviewPreferences.ts";
4
5/**
6 * Mirrors Electron's webview attribute parser closely enough to catch the
7 * regressions we've already hit:
8 *
9 * - whitespace inside the comma-separated list silently drops keys (so
10 * `" sandbox=true"` becomes an unknown key and Electron falls back to
11 * defaults — re-opening the Node-leak window we closed),
12 * - non-`true`/`false` values (`"yes"`, `"no"`, etc.) are kept as truthy
13 * strings and assigned to a boolean preference, which silently flips
14 * `contextIsolation=no` to ENABLED (then react-grab can't see the React
15 * DevTools hook and componentName resolution always returns null).
16 *
17 * The actual Electron parser does roughly:
18 *
19 * for (const pair of webpreferences.split(',')) {
20 * const [key, value] = pair.split('=');
21 * prefs[key] = value; // value left as a string
22 * }
23 *
24 * then later coerces booleans via `Boolean(value)`. Replicating that here
25 * keeps the test independent of Electron internals while still failing if
26 * we accidentally ship `"contextIsolation=no"` again.
27 */
28function parseWebPreferences(input: string): Record<string, unknown> {
29 const out: Record<string, unknown> = {};
30 for (const pair of input.split(",")) {
31 if (pair !== pair.trim()) {
32 // Electron's parser doesn't trim; surface the bug as undefined-key.
33 out[pair] = pair.split("=")[1];
34 continue;
35 }
36 const [key, value] = pair.split("=");
37 if (!key) continue;
38 out[key] = value;
39 }
40 return out;
41}
42
43describe("PREVIEW_WEBVIEW_PREFERENCES", () => {
44 const parsed = parseWebPreferences(PREVIEW_WEBVIEW_PREFERENCES);
45
46 it("contains exactly the three security-critical keys", () => {
47 expect(Object.keys(parsed).toSorted()).toEqual(
48 ["contextIsolation", "nodeIntegration", "sandbox"].toSorted(),
49 );
50 });
51
52 it("uses canonical JS-boolean string literals (not yes/no, on/off, 1/0)", () => {
53 // `value="no"` is a TRUTHY string when assigned to webPreferences.X — so
54 // `contextIsolation="no"` would silently leave isolation ENABLED. Lock
55 // the values to `"true"` / `"false"` so the parser does the right thing.
56 for (const value of Object.values(parsed)) {
57 expect(value).toMatch(/^(true|false)$/);
58 }
59 });
60
61 it("disables context isolation (so react-grab can see the page's React DevTools hook)", () => {
62 expect(parsed["contextIsolation"]).toBe("false");
63 });
64
65 it("keeps the renderer sandbox enabled (so the page cannot reach Node APIs)", () => {
66 expect(parsed["sandbox"]).toBe("true");
67 });
68
69 it("disables nodeIntegration (defense in depth — page never gets Node)", () => {
70 expect(parsed["nodeIntegration"]).toBe("false");
71 });
72
73 it("contains no whitespace (Electron's parser does not trim)", () => {
74 // Electron splits on `,` without trimming, so any whitespace would turn
75 // a key into an unknown one and silently drop the security flag.
76 expect(PREVIEW_WEBVIEW_PREFERENCES).not.toMatch(/\s/);
77 });
78});
apps/desktop/src/preview/WebviewPreferences.ts created+42
......@@ -0,0 +1,42 @@
1/**
2 * webPreferences override applied to every preview `<webview>` element via
3 * its `webpreferences="..."` attribute. Single source of truth so all guest
4 * surfaces inherit the same security posture.
5 *
6 * Lives in its own electron-free module so the value is unit-testable
7 * without importing `Manager.ts` (which transitively imports
8 * `electron` and blows up under vitest).
9 *
10 * - `contextIsolation=false`: the picker preload needs to share `globalThis`
11 * with the page so react-grab/bippy can read the React DevTools hook
12 * (`__REACT_DEVTOOLS_GLOBAL_HOOK__`) and resolve component names. Without
13 * this every pick comes back with `componentName: null` even on dev React
14 * apps.
15 * - `sandbox=true`: keeps the OS-level renderer sandbox enabled. Critical
16 * when paired with `contextIsolation=false` — without sandbox, the preload
17 * has full Node access (`require`, `fs`, `child_process`, ...) and that
18 * `require` would land on the page's shared `globalThis`, giving any
19 * third-party page in the preview full Node + IPC access to the host.
20 * In sandboxed mode Electron still synthesizes the `electron` module for
21 * the preload's `import { ipcRenderer }` line, but no Node globals leak.
22 * - `nodeIntegration=false`: pinned for clarity (the page itself never gets
23 * Node access).
24 *
25 * Format notes (locked down by `WebviewPreferences.test.ts`):
26 * - Whitespace-free. Electron's webpreferences parser splits on `,` and
27 * does not trim, so a leading space would turn a key into an unknown one
28 * and silently drop it.
29 * - Values are JS-boolean strings (`true`/`false`) — `yes`/`no` are not
30 * special-cased by the parser; `value="no"` becomes the truthy STRING
31 * `"no"` when assigned to a boolean webPreferences key. Most critically,
32 * `contextIsolation="no"` is truthy → contextIsolation stays ENABLED →
33 * react-grab can't see the React DevTools hook.
34 *
35 * Defense in depth: `apps/desktop/src/main.ts` also runs a
36 * `will-attach-webview` handler that force-sets `sandbox: true` and
37 * `nodeIntegration*: false` on the actual webPreferences object, gated on
38 * the preview partition, so even if this string is ever wrong, the
39 * security-critical flags can't regress on preview tabs.
40 */
41export const PREVIEW_WEBVIEW_PREFERENCES =
42 "contextIsolation=false,sandbox=true,nodeIntegration=false";
apps/desktop/src/window/DesktopWindow.test.ts+7
......@@ -29,6 +29,7 @@ import * as ElectronTheme from "../electron/ElectronTheme.ts";
2929import * as ElectronWindow from "../electron/ElectronWindow.ts";
3030import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
3131import * as DesktopWindow from "./DesktopWindow.ts";
32import * as PreviewManager from "../preview/Manager.ts";
3233
3334const environmentInput = {
3435 dirname: "/repo/apps/desktop/dist-electron",
......@@ -166,6 +167,12 @@ function makeTestLayer(input: {
166167 } satisfies ElectronShell.ElectronShellShape),
167168 electronThemeLayer,
168169 electronWindowLayer,
170 Layer.mock(PreviewManager.PreviewManager)({
171 getBrowserSession: () => Effect.succeed({} as Electron.Session),
172 setMainWindow: () => Effect.void,
173 isBrowserPartition: (partition) => partition.startsWith("persist:t3code-preview-"),
174 getBrowserPartition: () => "persist:t3code-preview-test",
175 }),
169176 ),
170177 ),
171178 );
apps/desktop/src/window/DesktopWindow.ts+9-6
......@@ -11,7 +11,7 @@ import * as DesktopAssets from "../app/DesktopAssets.ts";
1111import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
1212import * as DesktopObservability from "../app/DesktopObservability.ts";
1313import * as DesktopState from "../app/DesktopState.ts";
14import { previewViewManager } from "../preview-view-manager.ts";
14import * as PreviewManager from "../preview/Manager.ts";
1515import * as ElectronMenu from "../electron/ElectronMenu.ts";
1616import * as ElectronShell from "../electron/ElectronShell.ts";
1717import * as ElectronTheme from "../electron/ElectronTheme.ts";
......@@ -37,7 +37,8 @@ type DesktopWindowRuntimeServices =
3737 | ElectronMenu.ElectronMenu
3838 | ElectronShell.ElectronShell
3939 | ElectronTheme.ElectronTheme
40 | ElectronWindow.ElectronWindow;
40 | ElectronWindow.ElectronWindow
41 | PreviewManager.PreviewManager;
4142
4243export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError(
4344 "DesktopWindowDevServerUrlMissingError",
......@@ -49,7 +50,8 @@ export class DesktopWindowDevServerUrlMissingError extends Data.TaggedError(
4950
5051export type DesktopWindowError =
5152 | DesktopWindowDevServerUrlMissingError
52 | ElectronWindow.ElectronWindowCreateError;
53 | ElectronWindow.ElectronWindowCreateError
54 | PreviewManager.PreviewManagerError;
5355
5456export interface DesktopWindowShape {
5557 readonly createMain: Effect.Effect<Electron.BrowserWindow, DesktopWindowError>;
......@@ -163,6 +165,7 @@ const make = Effect.gen(function* () {
163165 const electronShell = yield* ElectronShell.ElectronShell;
164166 const electronTheme = yield* ElectronTheme.ElectronTheme;
165167 const electronWindow = yield* ElectronWindow.ElectronWindow;
168 const previewManager = yield* PreviewManager.PreviewManager;
166169 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
167170 const state = yield* DesktopState.DesktopState;
168171 const context = yield* Effect.context<DesktopWindowRuntimeServices>();
......@@ -171,7 +174,7 @@ const make = Effect.gen(function* () {
171174 const createWindow = Effect.fn("desktop.window.createWindow")(function* (
172175 backendHttpUrl: URL,
173176 ): Effect.fn.Return<Electron.BrowserWindow, DesktopWindowError> {
174 previewViewManager.getBrowserSession();
177 yield* previewManager.getBrowserSession();
175178 const applicationUrl = environment.isDevelopment
176179 ? yield* resolveDesktopDevServerUrl(environment)
177180 : backendHttpUrl.href;
......@@ -198,11 +201,11 @@ const make = Effect.gen(function* () {
198201 },
199202 });
200203
201 previewViewManager.setMainWindow(window);
204 yield* previewManager.setMainWindow(window);
202205 window.webContents.on("will-attach-webview", (event, webPreferences, params) => {
203206 if (
204207 typeof params.partition !== "string" ||
205 !previewViewManager.isBrowserPartition(params.partition)
208 !previewManager.isBrowserPartition(params.partition)
206209 ) {
207210 event.preventDefault();
208211 return;
apps/server/src/mcp/McpHttpServer.ts+130-110
......@@ -5,6 +5,7 @@ import * as Layer from "effect/Layer";
55import * as Option from "effect/Option";
66import * as Sink from "effect/Sink";
77import * as Stream from "effect/Stream";
8import type * as Types from "effect/Types";
89import { McpSchema, McpServer, Tool } from "effect/unstable/ai";
910import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
1011
......@@ -29,6 +30,20 @@ const unauthorized = HttpServerResponse.jsonUnsafe(
2930 },
3031);
3132
33type AuthenticatedHttpEffect = Effect.Effect<
34 HttpServerResponse.HttpServerResponse,
35 Types.unhandled,
36 McpInvocationContext.McpInvocationContext
37>;
38
39type McpAuthMiddleware = (
40 httpEffect: AuthenticatedHttpEffect,
41) => Effect.Effect<
42 HttpServerResponse.HttpServerResponse,
43 Types.unhandled,
44 HttpServerRequest.HttpServerRequest
45>;
46
3247export const normalizeMcpHttpResponse = (
3348 response: HttpServerResponse.HttpServerResponse,
3449): HttpServerResponse.HttpServerResponse => {
......@@ -41,27 +56,123 @@ export const normalizeMcpHttpResponse = (
4156 : response;
4257};
4358
59const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe(
60 Effect.map(
61 (registry): McpAuthMiddleware =>
62 Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) {
63 const request = yield* HttpServerRequest.HttpServerRequest;
64 const authorization = request.headers.authorization;
65 const token =
66 authorization?.startsWith("Bearer ") === true
67 ? authorization.slice("Bearer ".length).trim()
68 : "";
69 const invocation = yield* registry.resolve(token);
70 if (!invocation) return unauthorized;
71 return yield* httpEffect.pipe(
72 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
73 Effect.map(normalizeMcpHttpResponse),
74 );
75 }),
76 ),
77 Effect.withSpan("McpHttpServer.makeAuthMiddleware"),
78);
79
4480const McpAuthMiddlewareLive = HttpRouter.middleware<{
4581 provides: McpInvocationContext.McpInvocationContext;
46}>()(
47 Effect.gen(function* () {
48 const registry = yield* McpSessionRegistry.McpSessionRegistry;
49 return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) {
50 const request = yield* HttpServerRequest.HttpServerRequest;
51 const authorization = request.headers.authorization;
52 const token =
53 authorization?.startsWith("Bearer ") === true
54 ? authorization.slice("Bearer ".length).trim()
55 : "";
56 const invocation = yield* registry.resolve(token);
57 if (!invocation) return unauthorized;
58 return yield* httpEffect.pipe(
59 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
60 Effect.map(normalizeMcpHttpResponse),
61 );
82}>()(makeMcpAuthMiddleware).layer;
83
84const registerPreviewToolkit = Effect.fn("McpHttpServer.registerPreviewToolkit")(function* () {
85 const server = yield* McpServer.McpServer;
86 const built = yield* PreviewToolkit;
87 const handleTool = built.handle as unknown as (
88 name: keyof typeof built.tools,
89 payload: unknown,
90 ) => Effect.Effect<
91 Stream.Stream<{ readonly encodedResult: unknown }, Error>,
92 Error,
93 McpInvocationContext.McpInvocationContext
94 >;
95 for (const tool of Object.values(built.tools)) {
96 yield* server.addTool({
97 tool: new McpSchema.Tool({
98 name: tool.name,
99 description: Tool.getDescription(tool),
100 inputSchema: Tool.getJsonSchema(tool),
101 annotations: {
102 ...Context.getOption(tool.annotations, Tool.Title).pipe(
103 Option.map((title) => ({ title })),
104 Option.getOrUndefined,
105 ),
106 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
107 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
108 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
109 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
110 },
111 }),
112 annotations: tool.annotations,
113 handle: (payload) =>
114 handleTool(tool.name as keyof typeof built.tools, payload).pipe(
115 Stream.unwrap,
116 Stream.run(Sink.last()),
117 Effect.flatMap(Effect.fromOption),
118 Effect.matchCause({
119 onFailure: (cause) =>
120 new McpSchema.CallToolResult({
121 isError: true,
122 content: [{ type: "text", text: Cause.pretty(cause) }],
123 }),
124 onSuccess: (result) => {
125 if (tool.name === "preview_snapshot") {
126 const snapshot = result.encodedResult as {
127 readonly screenshot: {
128 readonly mimeType: "image/png";
129 readonly data: string;
130 readonly width: number;
131 readonly height: number;
132 };
133 readonly [key: string]: unknown;
134 };
135 const { screenshot, ...page } = snapshot;
136 const metadata = {
137 ...page,
138 screenshot: {
139 mimeType: screenshot.mimeType,
140 width: screenshot.width,
141 height: screenshot.height,
142 },
143 };
144 return new McpSchema.CallToolResult({
145 isError: false,
146 structuredContent: metadata,
147 content: [
148 { type: "text", text: JSON.stringify(metadata) },
149 {
150 type: "image",
151 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
152 mimeType: screenshot.mimeType,
153 },
154 ],
155 });
156 }
157 const encodedResultText = JSON.stringify(result.encodedResult) ?? "null";
158 return new McpSchema.CallToolResult({
159 isError: false,
160 structuredContent:
161 result.encodedResult !== null && typeof result.encodedResult === "object"
162 ? result.encodedResult
163 : undefined,
164 content: [{ type: "text", text: encodedResultText }],
165 });
166 },
167 }),
168 ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>,
62169 });
63 }),
64).layer;
170 }
171});
172
173export const PreviewToolkitRegistrationLive = Layer.effectDiscard(registerPreviewToolkit()).pipe(
174 Layer.provide(PreviewToolkitHandlersLive),
175);
65176
66177const McpTransportLive = McpServer.layerHttp({
67178 name: "T3 Code",
......@@ -69,98 +180,7 @@ const McpTransportLive = McpServer.layerHttp({
69180 path: "/mcp",
70181}).pipe(Layer.provide(McpAuthMiddlewareLive));
71182
72export const PreviewToolkitRegistrationLive = Layer.effectDiscard(
73 Effect.gen(function* () {
74 const server = yield* McpServer.McpServer;
75 const built = yield* PreviewToolkit;
76 const handleTool = built.handle as unknown as (
77 name: keyof typeof built.tools,
78 payload: unknown,
79 ) => Effect.Effect<
80 Stream.Stream<{ readonly encodedResult: unknown }, Error>,
81 Error,
82 McpInvocationContext.McpInvocationContext
83 >;
84 for (const tool of Object.values(built.tools)) {
85 yield* server.addTool({
86 tool: new McpSchema.Tool({
87 name: tool.name,
88 description: Tool.getDescription(tool),
89 inputSchema: Tool.getJsonSchema(tool),
90 annotations: {
91 ...Context.getOption(tool.annotations, Tool.Title).pipe(
92 Option.map((title) => ({ title })),
93 Option.getOrUndefined,
94 ),
95 readOnlyHint: Context.get(tool.annotations, Tool.Readonly),
96 destructiveHint: Context.get(tool.annotations, Tool.Destructive),
97 idempotentHint: Context.get(tool.annotations, Tool.Idempotent),
98 openWorldHint: Context.get(tool.annotations, Tool.OpenWorld),
99 },
100 }),
101 annotations: tool.annotations,
102 handle: (payload) =>
103 handleTool(tool.name as keyof typeof built.tools, payload).pipe(
104 Stream.unwrap,
105 Stream.run(Sink.last()),
106 Effect.flatMap(Effect.fromOption),
107 Effect.matchCause({
108 onFailure: (cause) =>
109 new McpSchema.CallToolResult({
110 isError: true,
111 content: [{ type: "text", text: Cause.pretty(cause) }],
112 }),
113 onSuccess: (result) => {
114 if (tool.name === "preview_snapshot") {
115 const snapshot = result.encodedResult as {
116 readonly screenshot: {
117 readonly mimeType: "image/png";
118 readonly data: string;
119 readonly width: number;
120 readonly height: number;
121 };
122 readonly [key: string]: unknown;
123 };
124 const { screenshot, ...page } = snapshot;
125 const metadata = {
126 ...page,
127 screenshot: {
128 mimeType: screenshot.mimeType,
129 width: screenshot.width,
130 height: screenshot.height,
131 },
132 };
133 return new McpSchema.CallToolResult({
134 isError: false,
135 structuredContent: metadata,
136 content: [
137 { type: "text", text: JSON.stringify(metadata) },
138 {
139 type: "image",
140 data: new Uint8Array(Buffer.from(screenshot.data, "base64")),
141 mimeType: screenshot.mimeType,
142 },
143 ],
144 });
145 }
146 const encodedResultText = JSON.stringify(result.encodedResult) ?? "null";
147 return new McpSchema.CallToolResult({
148 isError: false,
149 structuredContent:
150 result.encodedResult !== null && typeof result.encodedResult === "object"
151 ? result.encodedResult
152 : undefined,
153 content: [{ type: "text", text: encodedResultText }],
154 });
155 },
156 }),
157 ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>,
158 });
159 }
160 }),
161).pipe(Layer.provide(PreviewToolkitHandlersLive));
162
163export const layer = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe(
183export const layer = PreviewToolkitRegistrationLive.pipe(
164184 Layer.provideMerge(McpTransportLive),
165185 Layer.provide(PreviewAutomationBroker.layer),
166186);
apps/server/src/mcp/McpSessionRegistry.test.ts+11-9
......@@ -18,15 +18,17 @@ const fakeEnvironment = ServerEnvironment.of({
1818});
1919
2020const makeRegistry = (now: () => number) =>
21 McpSessionRegistry.makeForTest({
22 now,
23 idleTimeoutMs: 100,
24 maximumLifetimeMs: 1_000,
25 }).pipe(
26 Effect.provideService(HttpServer.HttpServer, fakeHttpServer),
27 Effect.provideService(ServerEnvironment, fakeEnvironment),
28 Effect.provide(NodeServices.layer),
29 );
21 McpSessionRegistry.__testing
22 .make({
23 now,
24 idleTimeoutMs: 100,
25 maximumLifetimeMs: 1_000,
26 })
27 .pipe(
28 Effect.provideService(HttpServer.HttpServer, fakeHttpServer),
29 Effect.provideService(ServerEnvironment, fakeEnvironment),
30 Effect.provide(NodeServices.layer),
31 );
3032
3133it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () =>
3234 Effect.gen(function* () {
apps/server/src/mcp/McpSessionRegistry.ts+5-2
......@@ -184,8 +184,6 @@ export const layer: Layer.Layer<
184184 ),
185185);
186186
187export const makeForTest = make;
188
189187export const issueActiveMcpCredential = (
190188 request: McpCredentialRequest,
191189): Effect.Effect<McpIssuedCredential | undefined> =>
......@@ -200,3 +198,8 @@ export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> =
200198
201199export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> =>
202200 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void;
201
202/** Exposed for tests. */
203export const __testing = {
204 make,
205};
apps/server/src/mcp/PreviewAutomationBroker.test.ts+3-3
......@@ -23,7 +23,7 @@ const scope = {
2323it.effect("routes a request to the focused owner and correlates its response", () =>
2424 Effect.scoped(
2525 Effect.gen(function* () {
26 const broker = yield* PreviewAutomationBroker.makeForTest;
26 const broker = yield* PreviewAutomationBroker.__testing.make();
2727 const requests = yield* broker.connect("client-1");
2828 yield* Stream.runForEach(requests, (request) =>
2929 broker.respond({
......@@ -56,7 +56,7 @@ it.effect("routes a request to the focused owner and correlates its response", (
5656
5757it.effect("rejects calls when no focused owner exists", () =>
5858 Effect.gen(function* () {
59 const broker = yield* PreviewAutomationBroker.makeForTest;
59 const broker = yield* PreviewAutomationBroker.__testing.make();
6060 const error = yield* broker
6161 .invoke<void>({ scope, operation: "status", input: {} })
6262 .pipe(Effect.flip);
......@@ -67,7 +67,7 @@ it.effect("rejects calls when no focused owner exists", () =>
6767it.effect("routes interactive commands to a hidden durable browser host", () =>
6868 Effect.scoped(
6969 Effect.gen(function* () {
70 const broker = yield* PreviewAutomationBroker.makeForTest;
70 const broker = yield* PreviewAutomationBroker.__testing.make();
7171 const requests = yield* broker.connect("client-hidden");
7272 yield* Stream.runForEach(requests, (request) =>
7373 broker.respond({ requestId: request.requestId, ok: true }),
apps/server/src/mcp/PreviewAutomationBroker.ts+12-13
......@@ -119,14 +119,12 @@ const makeResponseError = (
119119 }
120120};
121121
122const makeService = (): PreviewAutomationBrokerShape => {
123 const state = Effect.runSync(
124 Ref.make<BrokerState>({
125 clients: new Map(),
126 owners: new Map(),
127 pending: new Map(),
128 }),
129 );
122const make = Effect.fn("PreviewAutomationBroker.make")(function* () {
123 const state = yield* Ref.make<BrokerState>({
124 clients: new Map(),
125 owners: new Map(),
126 pending: new Map(),
127 });
130128 let requestSequence = 0;
131129
132130 const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* (
......@@ -297,10 +295,11 @@ const makeService = (): PreviewAutomationBrokerShape => {
297295 });
298296
299297 return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke });
300};
298});
301299
302const service = makeService();
303const make = Effect.succeed(service);
304export const layer = Layer.effect(PreviewAutomationBroker, make);
300export const layer = Layer.effect(PreviewAutomationBroker, make());
305301
306export const makeForTest = Effect.sync(makeService);
302/** Exposed for tests. */
303export const __testing = {
304 make,
305};
apps/server/src/preview/Manager.ts+2-2
......@@ -127,7 +127,7 @@ const buildIdleSnapshot = (input: {
127127 updatedAt: input.updatedAt,
128128});
129129
130const make = Effect.gen(function* () {
130const make = Effect.fn("PreviewManager.make")(function* () {
131131 const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState);
132132 // Unbounded PubSub is fine here — events are tiny and we don't want to
133133 // block publishers if a subscriber is slow. WS clients backpressure on
......@@ -359,4 +359,4 @@ const make = Effect.gen(function* () {
359359 } satisfies PreviewManagerShape;
360360});
361361
362export const layer = Layer.effect(PreviewManager, make);
362export const layer = Layer.effect(PreviewManager, make());
apps/server/src/preview/PortScanner.test.ts+71-42
......@@ -2,8 +2,9 @@ import * as net from "node:net";
22
33import { it as effectIt } from "@effect/vitest";
44import { ThreadId } from "@t3tools/contracts";
5import * as Net from "@t3tools/shared/Net";
56import { Effect, Layer } from "effect";
6import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
7import { describe, expect, it } from "vite-plus/test";
78
89import { ProcessRunner } from "../processRunner.ts";
910import * as PortScanner from "./PortScanner.ts";
......@@ -13,7 +14,60 @@ const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serv
1314const TestProcessRunner = Layer.succeed(ProcessRunner, {
1415 run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),
1516});
16const TestPortDiscoveryLive = PortScanner.layer.pipe(Layer.provide(TestProcessRunner));
17const TestPortDiscoveryLive = PortScanner.layer.pipe(
18 Layer.provide(Layer.mergeAll(TestProcessRunner, Net.layer)),
19);
20
21const openServer = (port: number): Effect.Effect<net.Server | null> =>
22 Effect.callback((resume) => {
23 const server = net.createServer();
24 server.once("error", () => {
25 resume(Effect.succeed(null));
26 });
27 server.listen(port, "127.0.0.1", () => {
28 resume(Effect.succeed(server));
29 });
30 return Effect.sync(() => {
31 server.close();
32 });
33 });
34
35const closeServer = (server: net.Server): Effect.Effect<void> =>
36 Effect.callback((resume) => {
37 server.close(() => resume(Effect.void));
38 });
39
40const windowsPlatform = Effect.acquireRelease(
41 Effect.sync(() => {
42 const originalPlatform = process.platform;
43 Object.defineProperty(process, "platform", { value: "win32", configurable: true });
44 return originalPlatform;
45 }),
46 (originalPlatform) =>
47 Effect.sync(() => {
48 Object.defineProperty(process, "platform", {
49 value: originalPlatform,
50 configurable: true,
51 });
52 }),
53);
54
55const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* (
56 ports: ReadonlyArray<number>,
57) {
58 for (const port of ports) {
59 const server = yield* openServer(port);
60 if (server !== null) return { port, server };
61 }
62 return yield* Effect.die(
63 new Error("No common development port was available for the preview scanner test"),
64 );
65});
66
67const commonDevServer = Effect.acquireRelease(
68 openCommonDevServer(PortScanner.COMMON_DEV_PORTS),
69 ({ server }) => closeServer(server),
70);
1771
1872describe("parsePortFromLsofName", () => {
1973 it("parses *:port", () => {
......@@ -180,51 +234,26 @@ describe("parseWindowsListenerOutput", () => {
180234 * path (TCP-probe fallback) by monkey-patching `process.platform` for the
181235 * duration of the test so we don't depend on `lsof` being installed.
182236 */
183describe("PortDiscovery integration (TCP probe fallback)", () => {
184 let originalPlatform: NodeJS.Platform;
185 let server: net.Server;
186 let port: number;
187
188 beforeEach(async () => {
189 originalPlatform = process.platform;
190 Object.defineProperty(process, "platform", { value: "win32", configurable: true });
191 for (const candidate of PortScanner.COMMON_DEV_PORTS) {
192 const candidateServer = net.createServer();
193 const listening = await new Promise<boolean>((resolve) => {
194 candidateServer.once("error", () => resolve(false));
195 candidateServer.listen(candidate, "127.0.0.1", () => resolve(true));
196 });
197 if (listening) {
198 server = candidateServer;
199 port = candidate;
200 return;
201 }
202 candidateServer.close();
203 }
204 throw new Error("No common development port was available for the preview scanner test");
205 });
206
207 afterEach(async () => {
208 Object.defineProperty(process, "platform", {
209 value: originalPlatform,
210 configurable: true,
211 });
212 await new Promise<void>((resolve) => server.close(() => resolve()));
213 });
214
215 effectIt.effect("scan() returns a server we just opened on a curated dev port", () =>
216 Effect.gen(function* () {
237effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => {
238 it.effect(
239 "scan() returns a server we just opened on a curated dev port",
240 Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () {
241 yield* windowsPlatform;
242 const { port } = yield* commonDevServer;
217243 const scanner = yield* PortScanner.PortDiscovery;
218244 const result = yield* scanner.scan();
219245 const found = result.find((server) => server.port === port);
220246 expect(found).toBeDefined();
221247 expect(found?.host).toBe("localhost");
222 }).pipe(Effect.provide(TestPortDiscoveryLive)),
248 }),
223249 );
224250
225 effectIt.effect("retain() drives an immediate broadcast to subscribers", () => {
226 const received: number[] = [];
227 return Effect.gen(function* () {
251 it.effect(
252 "retain() drives an immediate broadcast to subscribers",
253 Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () {
254 yield* windowsPlatform;
255 const { port } = yield* commonDevServer;
256 const received: number[] = [];
228257 const scanner = yield* PortScanner.PortDiscovery;
229258 const unsubscribe = yield* scanner.subscribe((servers) =>
230259 Effect.sync(() => {
......@@ -235,6 +264,6 @@ describe("PortDiscovery integration (TCP probe fallback)", () => {
235264 unsubscribe();
236265 release();
237266 expect(received).toContain(port);
238 }).pipe(Effect.provide(TestPortDiscoveryLive));
239 });
267 }),
268 );
240269});
apps/server/src/preview/PortScanner.ts+41-51
......@@ -5,15 +5,14 @@
55 * stable line-prefixed field format; this is the only `lsof` flag set we rely
66 * on).
77 *
8 * Windows / lsof missing: TCP-connects to a curated list of common dev ports
9 * on 127.0.0.1.
8 * Windows / lsof missing: checks a curated list of common dev ports through
9 * the shared Net service.
1010 *
1111 * Polling is reference-counted via `retain()`. A single layer-scoped fiber
1212 * polls forever, but each tick is a no-op when the retain count is zero.
1313 */
14import * as net from "node:net";
15
1614import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";
15import * as Net from "@t3tools/shared/Net";
1716import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
1817import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect";
1918
......@@ -45,7 +44,6 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([
4544]);
4645
4746const POLL_INTERVAL = Duration.seconds(3);
48const TCP_PROBE_TIMEOUT_MS = 200;
4947const LSOF_TIMEOUT_MS = 5_000;
5048const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;
5149
......@@ -148,40 +146,6 @@ const parseWindowsListenerOutput = (
148146 return [...seen.values()].toSorted((left, right) => left.port - right.port);
149147};
150148
151const probeTcpPort = (port: number): Promise<boolean> =>
152 new Promise((resolve) => {
153 const socket = new net.Socket();
154 let settled = false;
155 const finish = (ok: boolean) => {
156 if (settled) return;
157 settled = true;
158 socket.destroy();
159 resolve(ok);
160 };
161 socket.setTimeout(TCP_PROBE_TIMEOUT_MS);
162 socket.once("timeout", () => finish(false));
163 socket.once("error", () => finish(false));
164 socket.once("connect", () => finish(true));
165 socket.connect({ host: "127.0.0.1", port });
166 });
167
168const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> =>
169 Effect.promise(async () => {
170 const results = await Promise.all(
171 COMMON_DEV_PORTS.map(async (port) => ({ port, listening: await probeTcpPort(port) })),
172 );
173 return results
174 .filter((r) => r.listening)
175 .map<DiscoveredLocalServer>((r) => ({
176 host: "localhost",
177 port: r.port,
178 url: `http://localhost:${r.port}`,
179 processName: null,
180 pid: null,
181 terminal: null,
182 }));
183 });
184
185149const serversEqual = (
186150 left: ReadonlyArray<DiscoveredLocalServer>,
187151 right: ReadonlyArray<DiscoveredLocalServer>,
......@@ -206,7 +170,8 @@ const serversEqual = (
206170 return true;
207171};
208172
209const make = Effect.gen(function* () {
173const make = Effect.fn("PortDiscovery.make")(function* () {
174 const net = yield* Net.NetService;
210175 const processRunner = yield* ProcessRunner;
211176 const stateRef = yield* Ref.make<ScannerState>({
212177 lastSnapshot: [],
......@@ -224,6 +189,30 @@ const make = Effect.gen(function* () {
224189 // be a synchronous side-effect-only function.
225190 let retainCount = 0;
226191
192 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {
193 const results = yield* Effect.forEach(
194 COMMON_DEV_PORTS,
195 (port) =>
196 net.isPortAvailableOnLoopback(port).pipe(
197 Effect.map((available) => ({
198 port,
199 listening: !available,
200 })),
201 ),
202 { concurrency: "unbounded" },
203 );
204 return results
205 .filter((result) => result.listening)
206 .map<DiscoveredLocalServer>((result) => ({
207 host: "localhost",
208 port: result.port,
209 url: `http://localhost:${result.port}`,
210 processName: null,
211 pid: null,
212 terminal: null,
213 }));
214 });
215
227216 const scanOnce = Effect.fn("PortDiscovery.scan")(function* () {
228217 const terminalByProcessId = new Map<number, TerminalProcessOwner>();
229218 for (const registration of terminalProcesses.values()) {
......@@ -268,14 +257,15 @@ const make = Effect.gen(function* () {
268257 const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> =>
269258 Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true });
270259
271 const pollTick = Effect.gen(function* () {
272 if (retainCount <= 0) return;
273 const next = yield* scanOnce();
274 const state = yield* Ref.get(stateRef);
275 if (serversEqual(state.lastSnapshot, next)) return;
276 yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next }));
277 yield* broadcast(next);
278 }).pipe(
260 const pollTick = Effect.fn("PortDiscovery.pollTick")(
261 function* () {
262 if (retainCount <= 0) return;
263 const next = yield* scanOnce();
264 const state = yield* Ref.get(stateRef);
265 if (serversEqual(state.lastSnapshot, next)) return;
266 yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next }));
267 yield* broadcast(next);
268 },
279269 Effect.catchCause((cause: Cause.Cause<never>) =>
280270 Effect.logWarning("preview port scan failed", Cause.pretty(cause)),
281271 ),
......@@ -283,7 +273,7 @@ const make = Effect.gen(function* () {
283273
284274 // Single layer-scoped polling fiber. Ticks are no-ops when no client is
285275 // currently retained, so the cost is one Ref.get every POLL_INTERVAL.
286 yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
276 yield* Effect.forkScoped(pollTick().pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL))));
287277
288278 const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () {
289279 const wasIdle = retainCount === 0;
......@@ -291,7 +281,7 @@ const make = Effect.gen(function* () {
291281 if (wasIdle) {
292282 // Run an immediate scan + broadcast so the new retainer doesn't have
293283 // to wait up to POLL_INTERVAL for the first emission.
294 yield* pollTick;
284 yield* pollTick();
295285 }
296286 let released = false;
297287 return () => {
......@@ -349,7 +339,7 @@ const make = Effect.gen(function* () {
349339 } satisfies PortDiscoveryShape;
350340});
351341
352export const layer = Layer.effect(PortDiscovery, make);
342export const layer = Layer.effect(PortDiscovery, make());
353343
354344/** Exposed for tests. */
355345export const __testing = {
packages/contracts/src/ipc.ts+252-1
......@@ -66,7 +66,7 @@ import type {
6666 PreviewReportStatusInput,
6767 PreviewSessionSnapshot,
6868} from "./preview.ts";
69import type {
69import {
7070 PreviewAutomationClickInput,
7171 PreviewAutomationEvaluateInput,
7272 PreviewAutomationOwner,
......@@ -455,6 +455,42 @@ export interface DesktopPreviewTabState {
455455 updatedAt: string;
456456}
457457
458export const DesktopPreviewTabIdSchema = Schema.String.check(Schema.isTrimmed()).check(
459 Schema.isNonEmpty(),
460);
461
462export const DesktopPreviewNavStatusSchema = Schema.Union([
463 Schema.Struct({ kind: Schema.Literal("Idle") }),
464 Schema.Struct({
465 kind: Schema.Literal("Loading"),
466 url: Schema.String,
467 title: Schema.String,
468 }),
469 Schema.Struct({
470 kind: Schema.Literal("Success"),
471 url: Schema.String,
472 title: Schema.String,
473 }),
474 Schema.Struct({
475 kind: Schema.Literal("LoadFailed"),
476 url: Schema.String,
477 title: Schema.String,
478 code: Schema.Number,
479 description: Schema.String,
480 }),
481]);
482
483export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> = Schema.Struct({
484 tabId: DesktopPreviewTabIdSchema,
485 webContentsId: Schema.NullOr(Schema.Int),
486 navStatus: DesktopPreviewNavStatusSchema,
487 canGoBack: Schema.Boolean,
488 canGoForward: Schema.Boolean,
489 zoomFactor: Schema.Number,
490 controller: Schema.Literals(["human", "agent", "none"]),
491 updatedAt: Schema.String,
492});
493
458494export interface DesktopPreviewPointerEvent {
459495 tabId: string;
460496 phase: "move" | "click";
......@@ -464,6 +500,16 @@ export interface DesktopPreviewPointerEvent {
464500 createdAt: string;
465501}
466502
503export const DesktopPreviewPointerEventSchema: Schema.Codec<DesktopPreviewPointerEvent> =
504 Schema.Struct({
505 tabId: DesktopPreviewTabIdSchema,
506 phase: Schema.Literals(["move", "click"]),
507 x: Schema.Number,
508 y: Schema.Number,
509 sequence: Schema.Int,
510 createdAt: Schema.String,
511 });
512
467513/**
468514 * Static config a renderer needs to mount a preview `<webview>`. Returned
469515 * atomically by `DesktopPreviewBridge.getPreviewConfig()` so the renderer
......@@ -487,6 +533,13 @@ export interface DesktopPreviewWebviewConfig {
487533 preloadUrl: string | null;
488534}
489535
536export const DesktopPreviewWebviewConfigSchema: Schema.Codec<DesktopPreviewWebviewConfig> =
537 Schema.Struct({
538 partition: Schema.String,
539 webPreferences: Schema.String,
540 preloadUrl: Schema.NullOr(Schema.String),
541 });
542
490543export interface DesktopPreviewAnnotationTheme {
491544 colorScheme: "light" | "dark";
492545 radius: string;
......@@ -507,6 +560,27 @@ export interface DesktopPreviewAnnotationTheme {
507560 fontMono: string;
508561}
509562
563export const DesktopPreviewAnnotationThemeSchema: Schema.Codec<DesktopPreviewAnnotationTheme> =
564 Schema.Struct({
565 colorScheme: Schema.Literals(["light", "dark"]),
566 radius: Schema.String,
567 background: Schema.String,
568 foreground: Schema.String,
569 popover: Schema.String,
570 popoverForeground: Schema.String,
571 primary: Schema.String,
572 primaryForeground: Schema.String,
573 muted: Schema.String,
574 mutedForeground: Schema.String,
575 accent: Schema.String,
576 accentForeground: Schema.String,
577 border: Schema.String,
578 input: Schema.String,
579 ring: Schema.String,
580 fontSans: Schema.String,
581 fontMono: Schema.String,
582 });
583
510584export interface DesktopPreviewRecordingFrame {
511585 tabId: string;
512586 data: string;
......@@ -515,6 +589,15 @@ export interface DesktopPreviewRecordingFrame {
515589 receivedAt: string;
516590}
517591
592export const DesktopPreviewRecordingFrameSchema: Schema.Codec<DesktopPreviewRecordingFrame> =
593 Schema.Struct({
594 tabId: DesktopPreviewTabIdSchema,
595 data: Schema.String,
596 width: Schema.Number,
597 height: Schema.Number,
598 receivedAt: Schema.String,
599 });
600
518601export interface DesktopPreviewRecordingArtifact {
519602 id: string;
520603 tabId: string;
......@@ -524,6 +607,16 @@ export interface DesktopPreviewRecordingArtifact {
524607 createdAt: string;
525608}
526609
610export const DesktopPreviewRecordingArtifactSchema: Schema.Codec<DesktopPreviewRecordingArtifact> =
611 Schema.Struct({
612 id: Schema.String,
613 tabId: DesktopPreviewTabIdSchema,
614 path: Schema.String,
615 mimeType: Schema.String,
616 sizeBytes: Schema.Int,
617 createdAt: Schema.String,
618 });
619
527620export interface DesktopPreviewScreenshotArtifact {
528621 id: string;
529622 tabId: string;
......@@ -533,6 +626,16 @@ export interface DesktopPreviewScreenshotArtifact {
533626 createdAt: string;
534627}
535628
629export const DesktopPreviewScreenshotArtifactSchema: Schema.Codec<DesktopPreviewScreenshotArtifact> =
630 Schema.Struct({
631 id: Schema.String,
632 tabId: DesktopPreviewTabIdSchema,
633 path: Schema.String,
634 mimeType: Schema.Literal("image/png"),
635 sizeBytes: Schema.Int,
636 createdAt: Schema.String,
637 });
638
536639/**
537640 * Single stack frame captured by react-grab's `getElementContext`. We surface
538641 * the source file/line so coding agents can jump straight to the JSX that
......@@ -545,6 +648,13 @@ export interface PickedElementStackFrame {
545648 columnNumber: number | null;
546649}
547650
651export const PickedElementStackFrameSchema: Schema.Codec<PickedElementStackFrame> = Schema.Struct({
652 functionName: Schema.NullOr(Schema.String),
653 fileName: Schema.NullOr(Schema.String),
654 lineNumber: Schema.NullOr(Schema.Number),
655 columnNumber: Schema.NullOr(Schema.Number),
656});
657
548658/**
549659 * A successful element pick from the preview webview. All fields are
550660 * best-effort — pages that don't ship a React fiber tree (or aren't running
......@@ -574,6 +684,19 @@ export interface PickedElementPayload {
574684 pickedAt: string;
575685}
576686
687export const PickedElementPayloadSchema: Schema.Codec<PickedElementPayload> = Schema.Struct({
688 pageUrl: Schema.String,
689 pageTitle: Schema.NullOr(Schema.String),
690 tagName: Schema.String,
691 selector: Schema.NullOr(Schema.String),
692 htmlPreview: Schema.String,
693 componentName: Schema.NullOr(Schema.String),
694 source: Schema.NullOr(PickedElementStackFrameSchema),
695 stack: Schema.Array(PickedElementStackFrameSchema),
696 styles: Schema.String,
697 pickedAt: Schema.String,
698});
699
577700export interface PreviewAnnotationRect {
578701 x: number;
579702 y: number;
......@@ -581,22 +704,47 @@ export interface PreviewAnnotationRect {
581704 height: number;
582705}
583706
707export const PreviewAnnotationRectSchema: Schema.Codec<PreviewAnnotationRect> = Schema.Struct({
708 x: Schema.Number,
709 y: Schema.Number,
710 width: Schema.Number,
711 height: Schema.Number,
712});
713
584714export interface PreviewAnnotationPoint {
585715 x: number;
586716 y: number;
587717}
588718
719export const PreviewAnnotationPointSchema: Schema.Codec<PreviewAnnotationPoint> = Schema.Struct({
720 x: Schema.Number,
721 y: Schema.Number,
722});
723
589724export interface PreviewAnnotationElementTarget {
590725 id: string;
591726 element: PickedElementPayload;
592727 rect: PreviewAnnotationRect;
593728}
594729
730export const PreviewAnnotationElementTargetSchema: Schema.Codec<PreviewAnnotationElementTarget> =
731 Schema.Struct({
732 id: Schema.String,
733 element: PickedElementPayloadSchema,
734 rect: PreviewAnnotationRectSchema,
735 });
736
595737export interface PreviewAnnotationRegionTarget {
596738 id: string;
597739 rect: PreviewAnnotationRect;
598740}
599741
742export const PreviewAnnotationRegionTargetSchema: Schema.Codec<PreviewAnnotationRegionTarget> =
743 Schema.Struct({
744 id: Schema.String,
745 rect: PreviewAnnotationRectSchema,
746 });
747
600748export interface PreviewAnnotationStrokeTarget {
601749 id: string;
602750 color: string;
......@@ -605,6 +753,15 @@ export interface PreviewAnnotationStrokeTarget {
605753 bounds: PreviewAnnotationRect;
606754}
607755
756export const PreviewAnnotationStrokeTargetSchema: Schema.Codec<PreviewAnnotationStrokeTarget> =
757 Schema.Struct({
758 id: Schema.String,
759 color: Schema.String,
760 width: Schema.Number,
761 points: Schema.Array(PreviewAnnotationPointSchema),
762 bounds: PreviewAnnotationRectSchema,
763 });
764
608765export interface PreviewAnnotationStyleChange {
609766 targetId: string;
610767 selector: string | null;
......@@ -613,6 +770,15 @@ export interface PreviewAnnotationStyleChange {
613770 value: string;
614771}
615772
773export const PreviewAnnotationStyleChangeSchema: Schema.Codec<PreviewAnnotationStyleChange> =
774 Schema.Struct({
775 targetId: Schema.String,
776 selector: Schema.NullOr(Schema.String),
777 property: Schema.String,
778 previousValue: Schema.String,
779 value: Schema.String,
780 });
781
616782export interface PreviewAnnotationScreenshot {
617783 dataUrl: string;
618784 width: number;
......@@ -620,6 +786,14 @@ export interface PreviewAnnotationScreenshot {
620786 cropRect: PreviewAnnotationRect;
621787}
622788
789export const PreviewAnnotationScreenshotSchema: Schema.Codec<PreviewAnnotationScreenshot> =
790 Schema.Struct({
791 dataUrl: Schema.String,
792 width: Schema.Number,
793 height: Schema.Number,
794 cropRect: PreviewAnnotationRectSchema,
795 });
796
623797/**
624798 * A submitted preview annotation. One annotation may reference multiple DOM
625799 * elements, freeform regions, and ink strokes. The desktop main process adds
......@@ -638,6 +812,83 @@ export interface PreviewAnnotationPayload {
638812 createdAt: string;
639813}
640814
815export const PreviewAnnotationPayloadSchema: Schema.Codec<PreviewAnnotationPayload> = Schema.Struct(
816 {
817 id: Schema.String,
818 pageUrl: Schema.String,
819 pageTitle: Schema.NullOr(Schema.String),
820 comment: Schema.String,
821 elements: Schema.Array(PreviewAnnotationElementTargetSchema),
822 regions: Schema.Array(PreviewAnnotationRegionTargetSchema),
823 strokes: Schema.Array(PreviewAnnotationStrokeTargetSchema),
824 styleChanges: Schema.Array(PreviewAnnotationStyleChangeSchema),
825 screenshot: Schema.NullOr(PreviewAnnotationScreenshotSchema),
826 createdAt: Schema.String,
827 },
828);
829
830export const DesktopPreviewTabInputSchema = Schema.Struct({
831 tabId: DesktopPreviewTabIdSchema,
832});
833
834export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({
835 tabId: DesktopPreviewTabIdSchema,
836 webContentsId: Schema.Int.check(Schema.isGreaterThan(0)),
837});
838
839export const DesktopPreviewNavigateInputSchema = Schema.Struct({
840 tabId: DesktopPreviewTabIdSchema,
841 url: Schema.String,
842});
843
844export const DesktopPreviewConfigInputSchema = Schema.Struct({
845 environmentId: EnvironmentId,
846});
847
848export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({
849 theme: DesktopPreviewAnnotationThemeSchema,
850});
851
852export const DesktopPreviewArtifactInputSchema = Schema.Struct({
853 path: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()),
854});
855
856export const DesktopPreviewRecordingSaveInputSchema = Schema.Struct({
857 tabId: DesktopPreviewTabIdSchema,
858 mimeType: Schema.String.check(Schema.isTrimmed()).check(Schema.isNonEmpty()),
859 data: Schema.Uint8Array,
860});
861
862export const DesktopPreviewAutomationClickInputSchema = Schema.Struct({
863 tabId: DesktopPreviewTabIdSchema,
864 input: PreviewAutomationClickInput,
865});
866
867export const DesktopPreviewAutomationTypeInputSchema = Schema.Struct({
868 tabId: DesktopPreviewTabIdSchema,
869 input: PreviewAutomationTypeInput,
870});
871
872export const DesktopPreviewAutomationPressInputSchema = Schema.Struct({
873 tabId: DesktopPreviewTabIdSchema,
874 input: PreviewAutomationPressInput,
875});
876
877export const DesktopPreviewAutomationScrollInputSchema = Schema.Struct({
878 tabId: DesktopPreviewTabIdSchema,
879 input: PreviewAutomationScrollInput,
880});
881
882export const DesktopPreviewAutomationEvaluateInputSchema = Schema.Struct({
883 tabId: DesktopPreviewTabIdSchema,
884 input: PreviewAutomationEvaluateInput,
885});
886
887export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({
888 tabId: DesktopPreviewTabIdSchema,
889 input: PreviewAutomationWaitForInput,
890});
891
641892export interface DesktopBridge {
642893 getAppBranding: () => DesktopAppBranding | null;
643894 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;