authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-23 23:35:40+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-23 23:35:40+02:00
log79fe11bc1c586029dfe052df25f4b2d6227b3b33
tree9e6c10955834f8aa785432d1908e65728f13bedd
parentb3e51317e404109ebc41fe1c9e487d5d73367e76
signature Signed by PGP key B5690EEEBB952194

Add preview color scheme controls and simplify project grouping (#4385)

Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

16 files changed, 288 insertions(+), 3 deletions(-)

apps/desktop/src/ipc/channels.ts+1
...@@ -50,6 +50,7 @@ export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in";...@@ -50,6 +50,7 @@ export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in";
50export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";50export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";
51export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";51export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
52export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";52export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
53export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";
53export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";54export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
54export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";55export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
55export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";56export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
apps/desktop/src/ipc/methods/preview.ts+11
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 DesktopPreviewRecordingSaveInputSchema,13 DesktopPreviewRecordingSaveInputSchema,
14 DesktopPreviewRegisterWebviewInputSchema,14 DesktopPreviewRegisterWebviewInputSchema,
15 DesktopPreviewScreenshotArtifactSchema,15 DesktopPreviewScreenshotArtifactSchema,
16 DesktopPreviewSetColorSchemeInputSchema,
16 DesktopPreviewTabInputSchema,17 DesktopPreviewTabInputSchema,
17 DesktopPreviewWebviewConfigSchema,18 DesktopPreviewWebviewConfigSchema,
18 PreviewAnnotationPayloadSchema,19 PreviewAnnotationPayloadSchema,
...@@ -138,6 +139,15 @@ export const hardReload = tabMethod(...@@ -138,6 +139,15 @@ export const hardReload = tabMethod(
138 "desktop.ipc.preview.hardReload",139 "desktop.ipc.preview.hardReload",
139 (manager, tabId) => manager.hardReload(tabId),140 (manager, tabId) => manager.hardReload(tabId),
140);141);
142export const setColorScheme = DesktopIpc.makeIpcMethod({
143 channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL,
144 payload: DesktopPreviewSetColorSchemeInputSchema,
145 result: Schema.Void,
146 handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* ({ tabId, colorScheme }) {
147 const manager = yield* PreviewManager.PreviewManager;
148 yield* manager.setColorScheme(tabId, colorScheme);
149 }),
150});
141export const openDevTools = tabMethod(151export const openDevTools = tabMethod(
142 IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL,152 IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL,
143 "desktop.ipc.preview.openDevTools",153 "desktop.ipc.preview.openDevTools",
...@@ -346,6 +356,7 @@ export const methods = [...@@ -346,6 +356,7 @@ export const methods = [
346 zoomOut,356 zoomOut,
347 resetZoom,357 resetZoom,
348 hardReload,358 hardReload,
359 setColorScheme,
349 openDevTools,360 openDevTools,
350 clearCookies,361 clearCookies,
351 clearCache,362 clearCache,
apps/desktop/src/preload.ts+2
...@@ -160,6 +160,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -160,6 +160,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
160 zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),160 zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),
161 resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }),161 resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }),
162 hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),162 hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),
163 setColorScheme: (tabId, colorScheme) =>
164 ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),
163 openDevTools: (tabId) =>165 openDevTools: (tabId) =>
164 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),166 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
165 clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),167 clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
apps/desktop/src/preview/Manager.test.ts+73
...@@ -365,6 +365,79 @@ describe("PreviewManager", () => {...@@ -365,6 +365,79 @@ describe("PreviewManager", () => {
365 ),365 ),
366 );366 );
367367
368 effectIt.effect("emulates prefers-color-scheme and re-applies it across webview swaps", () =>
369 withManager((manager) =>
370 Effect.gen(function* () {
371 const makeWebContents = (id: number) => {
372 const sendCommand = vi.fn(async () => undefined);
373 return {
374 sendCommand,
375 wc: {
376 id,
377 isDestroyed: () => false,
378 isDevToolsOpened: () => false,
379 getType: () => "webview",
380 getURL: () => "https://example.com",
381 getTitle: () => "Example",
382 isLoading: () => false,
383 getZoomFactor: () => 1,
384 setZoomFactor: vi.fn(),
385 on: vi.fn(),
386 off: vi.fn(),
387 ipc: { on: vi.fn(), off: vi.fn() },
388 send: webviewSend,
389 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
390 setWindowOpenHandler: vi.fn(),
391 debugger: {
392 isAttached: () => false,
393 attach: vi.fn(),
394 sendCommand,
395 on: vi.fn(),
396 off: vi.fn(),
397 },
398 } as never,
399 };
400 };
401 const first = makeWebContents(42);
402 fromId.mockReturnValue(first.wc);
403 const states: PreviewManager.PreviewTabState[] = [];
404
405 yield* manager.subscribeStateChanges((_tabId, state) =>
406 Effect.sync(() => {
407 states.push(state);
408 }),
409 );
410 yield* manager.createTab("tab_scheme");
411 yield* manager.registerWebview("tab_scheme", 42);
412 yield* Effect.yieldNow;
413
414 yield* manager.setColorScheme("tab_scheme", "dark");
415
416 expect(first.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
417 features: [{ name: "prefers-color-scheme", value: "dark" }],
418 });
419 expect(states.at(-1)?.colorScheme).toBe("dark");
420
421 const replacement = makeWebContents(43);
422 fromId.mockReturnValue(replacement.wc);
423 yield* manager.registerWebview("tab_scheme", 43);
424 yield* Effect.yieldNow;
425
426 expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
427 features: [{ name: "prefers-color-scheme", value: "dark" }],
428 });
429 expect(states.at(-1)?.colorScheme).toBe("dark");
430
431 yield* manager.setColorScheme("tab_scheme", "system");
432
433 expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
434 features: [{ name: "prefers-color-scheme", value: "" }],
435 });
436 expect(states.at(-1)?.colorScheme).toBe("system");
437 }),
438 ),
439 );
440
368 effectIt.effect("keeps a main-frame load failure visible until a retry starts", () =>441 effectIt.effect("keeps a main-frame load failure visible until a retry starts", () =>
369 withManager((manager) =>442 withManager((manager) =>
370 Effect.gen(function* () {443 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+72-2
...@@ -7,6 +7,7 @@...@@ -7,6 +7,7 @@
7 */7 */
8import type {8import type {
9 DesktopPreviewAnnotationTheme,9 DesktopPreviewAnnotationTheme,
10 DesktopPreviewColorScheme,
10 DesktopPreviewPointerEvent,11 DesktopPreviewPointerEvent,
11 PreviewAnnotationPayload,12 PreviewAnnotationPayload,
12 PreviewAnnotationRect,13 PreviewAnnotationRect,
...@@ -84,6 +85,7 @@ export interface PreviewTabState {...@@ -84,6 +85,7 @@ export interface PreviewTabState {
84 canGoBack: boolean;85 canGoBack: boolean;
85 canGoForward: boolean;86 canGoForward: boolean;
86 zoomFactor: number;87 zoomFactor: number;
88 colorScheme: DesktopPreviewColorScheme;
87 controller: "human" | "agent" | "none";89 controller: "human" | "agent" | "none";
88 updatedAt: string;90 updatedAt: string;
89}91}
...@@ -1288,6 +1290,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1288,6 +1290,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1288 canGoBack: false,1290 canGoBack: false,
1289 canGoForward: false,1291 canGoForward: false,
1290 zoomFactor: DEFAULT_ZOOM_FACTOR,1292 zoomFactor: DEFAULT_ZOOM_FACTOR,
1293 colorScheme: "system",
1291 controller: "none",1294 controller: "none",
1292 updatedAt,1295 updatedAt,
1293 };1296 };
...@@ -1320,6 +1323,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1320,6 +1323,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1320 canGoBack: false,1323 canGoBack: false,
1321 canGoForward: false,1324 canGoForward: false,
1322 zoomFactor: DEFAULT_ZOOM_FACTOR,1325 zoomFactor: DEFAULT_ZOOM_FACTOR,
1326 colorScheme: "system",
1323 controller: "none",1327 controller: "none",
1324 updatedAt,1328 updatedAt,
1325 };1329 };
...@@ -1386,7 +1390,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1386,7 +1390,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1386 wc.getZoomFactor(),1390 wc.getZoomFactor(),
1387 );1391 );
1388 yield* attachListeners(tabId, wc);1392 yield* attachListeners(tabId, wc);
1389 runFork(ensureControlSession(wc).pipe(Effect.ignore));1393 runFork(restoreControlSession(tabId, wc));
1390 const registeredAt = yield* currentIso;1394 const registeredAt = yield* currentIso;
1391 const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => {1395 const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
1392 const current = tabs.get(tabId);1396 const current = tabs.get(tabId);
...@@ -1457,6 +1461,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1457,6 +1461,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1457 canGoBack: current?.canGoBack ?? false,1461 canGoBack: current?.canGoBack ?? false,
1458 canGoForward: current?.canGoForward ?? false,1462 canGoForward: current?.canGoForward ?? false,
1459 zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR,1463 zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR,
1464 colorScheme: current?.colorScheme ?? "system",
1460 controller: current?.controller ?? "none",1465 controller: current?.controller ?? "none",
1461 updatedAt,1466 updatedAt,
1462 };1467 };
...@@ -1525,7 +1530,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1525,7 +1530,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1525 yield* detachControlSession(wc.id);1530 yield* detachControlSession(wc.id);
1526 yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => {1531 yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => {
1527 wc.once("devtools-closed", () => {1532 wc.once("devtools-closed", () => {
1528 if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore));1533 if (!wc.isDestroyed()) runFork(restoreControlSession(tabId, wc));
1529 });1534 });
1530 wc.openDevTools({ mode: "detach" });1535 wc.openDevTools({ mode: "detach" });
1531 });1536 });
...@@ -1684,6 +1689,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1684,6 +1689,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1684 yield* update(tabId, { zoomFactor: next });1689 yield* update(tabId, { zoomFactor: next });
1685 });1690 });
16861691
1692 // Emulated media lives on the CDP debugger session, not the WebContents, so
1693 // it is lost whenever the session detaches (webview swap, DevTools
1694 // open/close) and must be re-applied after every (re)attach.
1695 const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* (
1696 tabId: string,
1697 wc: Electron.WebContents,
1698 colorScheme: DesktopPreviewColorScheme,
1699 ) {
1700 yield* ensureControlSession(wc);
1701 yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () =>
1702 wc.debugger.sendCommand("Emulation.setEmulatedMedia", {
1703 features: [
1704 {
1705 name: "prefers-color-scheme",
1706 // An empty value clears the override so the page follows the OS.
1707 value: colorScheme === "system" ? "" : colorScheme,
1708 },
1709 ],
1710 }),
1711 );
1712 });
1713
1714 // Re-establish the control session after a detach, restoring any
1715 // color-scheme override the tab carries. The scheme is read after the
1716 // session attaches so a concurrent setColorScheme is not overwritten with
1717 // a stale snapshot.
1718 const restoreControlSession = (tabId: string, wc: Electron.WebContents) =>
1719 ensureControlSession(wc).pipe(
1720 Effect.andThen(SynchronizedRef.get(tabsRef)),
1721 Effect.flatMap((tabs) => {
1722 const colorScheme = tabs.get(tabId)?.colorScheme ?? "system";
1723 return colorScheme === "system" ? Effect.void : applyColorScheme(tabId, wc, colorScheme);
1724 }),
1725 Effect.ignore,
1726 );
1727
1728 const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* (
1729 tabId: string,
1730 colorScheme: DesktopPreviewColorScheme,
1731 ) {
1732 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
1733 if (!tab) {
1734 return yield* new PreviewTabNotFoundError({ tabId });
1735 }
1736 if (tab.colorScheme !== colorScheme) {
1737 // Record the choice even when the CDP call below can't run yet (no
1738 // webview, DevTools holding the debugger) — it is re-applied on the
1739 // next control-session (re)attach.
1740 yield* update(tabId, { colorScheme });
1741 }
1742 // Re-read after the update: registerWebview may have swapped the guest
1743 // in the meantime and the override must land on the current one.
1744 const webContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId;
1745 if (webContentsId == null) return;
1746 const wc = webContents.fromId(webContentsId);
1747 if (!wc || wc.isDestroyed()) return;
1748 yield* applyColorScheme(tabId, wc, colorScheme);
1749 });
1750
1687 const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (1751 const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (
1688 tabId: string,1752 tabId: string,
1689 ) {1753 ) {
...@@ -2526,6 +2590,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2526,6 +2590,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2526 revealArtifact,2590 revealArtifact,
2527 saveRecording,2591 saveRecording,
2528 setAnnotationTheme,2592 setAnnotationTheme,
2593 setColorScheme,
2529 setMainWindow,2594 setMainWindow,
2530 startRecording,2595 startRecording,
2531 stopRecording,2596 stopRecording,
...@@ -2830,6 +2895,10 @@ export class PreviewManager extends Context.Service<...@@ -2830,6 +2895,10 @@ export class PreviewManager extends Context.Service<
2830 readonly zoomOut: (tabId: string) => Effect.Effect<void, PreviewManagerError>;2895 readonly zoomOut: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
2831 readonly resetZoom: (tabId: string) => Effect.Effect<void, PreviewManagerError>;2896 readonly resetZoom: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
2832 readonly hardReload: (tabId: string) => Effect.Effect<void, PreviewManagerError>;2897 readonly hardReload: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
2898 readonly setColorScheme: (
2899 tabId: string,
2900 colorScheme: DesktopPreviewColorScheme,
2901 ) => Effect.Effect<void, PreviewManagerError>;
2833 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;2902 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
2834 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;2903 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
2835 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;2904 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
...@@ -2921,6 +2990,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -2921,6 +2990,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
2921 zoomOut: operations.zoomOut,2990 zoomOut: operations.zoomOut,
2922 resetZoom: operations.resetZoom,2991 resetZoom: operations.resetZoom,
2923 hardReload: operations.hardReload,2992 hardReload: operations.hardReload,
2993 setColorScheme: operations.setColorScheme,
2924 openDevTools: operations.openDevTools,2994 openDevTools: operations.openDevTools,
2925 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {2995 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
2926 yield* browserSession2996 yield* browserSession
apps/server/src/mcp/toolkits/preview/handlers.ts+3
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 PreviewAutomationRecordingArtifact,4 PreviewAutomationRecordingArtifact,
5 PreviewAutomationRecordingStatus,5 PreviewAutomationRecordingStatus,
6 PreviewAutomationResizeResult,6 PreviewAutomationResizeResult,
7 PreviewAutomationSetColorSchemeResult,
7 PreviewAutomationSnapshot,8 PreviewAutomationSnapshot,
8 PreviewAutomationStatus,9 PreviewAutomationStatus,
9 PreviewTabId,10 PreviewTabId,
...@@ -58,6 +59,8 @@ const handlers = {...@@ -58,6 +59,8 @@ const handlers = {
58 invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs),59 invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
59 preview_resize: (input) =>60 preview_resize: (input) =>
60 invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),61 invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),
62 preview_set_appearance: (input) =>
63 invokeTargeted<PreviewAutomationSetColorSchemeResult>("setColorScheme", input),
61 preview_snapshot: (input) => invokeTargeted<PreviewAutomationSnapshot>("snapshot", input ?? {}),64 preview_snapshot: (input) => invokeTargeted<PreviewAutomationSnapshot>("snapshot", input ?? {}),
62 preview_click: (input) =>65 preview_click: (input) =>
63 invokeTargeted<void>("click", input, input.timeoutMs).pipe(Effect.as(null)),66 invokeTargeted<void>("click", input, input.timeoutMs).pipe(Effect.as(null)),
apps/server/src/mcp/toolkits/preview/tools.ts+17
...@@ -10,6 +10,8 @@ import {...@@ -10,6 +10,8 @@ import {
10 PreviewAutomationResizeInput,10 PreviewAutomationResizeInput,
11 PreviewAutomationResizeResult,11 PreviewAutomationResizeResult,
12 PreviewAutomationScrollInput,12 PreviewAutomationScrollInput,
13 PreviewAutomationSetColorSchemeInput,
14 PreviewAutomationSetColorSchemeResult,
13 PreviewAutomationSnapshot,15 PreviewAutomationSnapshot,
14 PreviewAutomationStatus,16 PreviewAutomationStatus,
15 PreviewAutomationTabTargetInput,17 PreviewAutomationTabTargetInput,
...@@ -86,6 +88,19 @@ export const PreviewResizeTool = safeBrowserTool(...@@ -86,6 +88,19 @@ export const PreviewResizeTool = safeBrowserTool(
86 .annotate(Tool.Idempotent, true),88 .annotate(Tool.Idempotent, true),
87);89);
8890
91export const PreviewSetAppearanceTool = safeBrowserTool(
92 Tool.make("preview_set_appearance", {
93 description:
94 "Emulate prefers-color-scheme in a collaborative browser tab, optionally selected by tabId. Use {colorScheme:'dark'} or {colorScheme:'light'} to preview the page in that appearance, and {colorScheme:'system'} to clear the override and follow the OS appearance.",
95 parameters: PreviewAutomationSetColorSchemeInput,
96 success: PreviewAutomationSetColorSchemeResult,
97 failure: PreviewAutomationError,
98 dependencies,
99 })
100 .annotate(Tool.Title, "Set preview appearance")
101 .annotate(Tool.Idempotent, true),
102);
103
89export const PreviewSnapshotTool = readonlyBrowserTool(104export const PreviewSnapshotTool = readonlyBrowserTool(
90 Tool.make("preview_snapshot", {105 Tool.make("preview_snapshot", {
91 description:106 description:
...@@ -189,6 +204,7 @@ export const PreviewToolkit = Toolkit.make(...@@ -189,6 +204,7 @@ export const PreviewToolkit = Toolkit.make(
189 PreviewOpenTool,204 PreviewOpenTool,
190 PreviewNavigateTool,205 PreviewNavigateTool,
191 PreviewResizeTool,206 PreviewResizeTool,
207 PreviewSetAppearanceTool,
192 PreviewSnapshotTool,208 PreviewSnapshotTool,
193 PreviewClickTool,209 PreviewClickTool,
194 PreviewTypeTool,210 PreviewTypeTool,
...@@ -205,6 +221,7 @@ export const PreviewStandardToolkit = Toolkit.make(...@@ -205,6 +221,7 @@ export const PreviewStandardToolkit = Toolkit.make(
205 PreviewOpenTool,221 PreviewOpenTool,
206 PreviewNavigateTool,222 PreviewNavigateTool,
207 PreviewResizeTool,223 PreviewResizeTool,
224 PreviewSetAppearanceTool,
208 PreviewClickTool,225 PreviewClickTool,
209 PreviewTypeTool,226 PreviewTypeTool,
210 PreviewPressTool,227 PreviewPressTool,
apps/web/src/components/preview/PreviewAutomationHosts.tsx+11
...@@ -10,6 +10,8 @@ import {...@@ -10,6 +10,8 @@ import {
10 type PreviewAutomationOpenInput,10 type PreviewAutomationOpenInput,
11 type PreviewAutomationResizeInput,11 type PreviewAutomationResizeInput,
12 type PreviewAutomationResizeResult,12 type PreviewAutomationResizeResult,
13 type PreviewAutomationSetColorSchemeInput,
14 type PreviewAutomationSetColorSchemeResult,
13 type PreviewAutomationHost as PreviewAutomationHostState,15 type PreviewAutomationHost as PreviewAutomationHostState,
14 type PreviewAutomationRequest,16 type PreviewAutomationRequest,
15 type PreviewAutomationStatus,17 type PreviewAutomationStatus,
...@@ -451,6 +453,15 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -451,6 +453,15 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
451 viewport,453 viewport,
452 } satisfies PreviewAutomationResizeResult;454 } satisfies PreviewAutomationResizeResult;
453 }455 }
456 case "setColorScheme": {
457 const ready = await requireReadyTab();
458 const input = request.input as PreviewAutomationSetColorSchemeInput;
459 await ready.bridge.setColorScheme(ready.tabId, input.colorScheme);
460 return {
461 tabId: ready.tabId,
462 colorScheme: input.colorScheme,
463 } satisfies PreviewAutomationSetColorSchemeResult;
464 }
454 case "snapshot": {465 case "snapshot": {
455 const ready = await requireReadyTab();466 const ready = await requireReadyTab();
456 return await ready.bridge.automation.snapshot(ready.tabId);467 return await ready.bridge.automation.snapshot(ready.tabId);
apps/web/src/components/preview/PreviewMoreMenu.tsx+45-1
...@@ -1,13 +1,34 @@...@@ -1,13 +1,34 @@
1"use client";1"use client";
22
3import type { DesktopPreviewColorScheme } from "@t3tools/contracts";
3import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";4import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";
45
5import { Button } from "~/components/ui/button";6import { Button } from "~/components/ui/button";
6import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu";7import {
8 Menu,
9 MenuItem,
10 MenuPopup,
11 MenuRadioGroup,
12 MenuRadioItem,
13 MenuSeparator,
14 MenuSub,
15 MenuSubPopup,
16 MenuSubTrigger,
17 MenuTrigger,
18} from "~/components/ui/menu";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";19import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
820
9import { previewBridge } from "./previewBridge";21import { previewBridge } from "./previewBridge";
1022
23const COLOR_SCHEME_OPTIONS: ReadonlyArray<{
24 value: DesktopPreviewColorScheme;
25 label: string;
26}> = [
27 { value: "system", label: "System" },
28 { value: "light", label: "Light" },
29 { value: "dark", label: "Dark" },
30];
31
11interface Props {32interface Props {
12 /** Active preview tab id. Tab-targeting actions are disabled without it. */33 /** Active preview tab id. Tab-targeting actions are disabled without it. */
13 tabId: string | null;34 tabId: string | null;
...@@ -19,6 +40,8 @@ interface Props {...@@ -19,6 +40,8 @@ interface Props {
19 hasWebContents: boolean;40 hasWebContents: boolean;
20 /** Current zoom factor as a number (1.0 = 100%). */41 /** Current zoom factor as a number (1.0 = 100%). */
21 zoomFactor: number;42 zoomFactor: number;
43 /** Emulated `prefers-color-scheme` for the guest page. */
44 colorScheme: DesktopPreviewColorScheme;
22 /** Fixed viewport modes expose the device toolbar and resize rails. */45 /** Fixed viewport modes expose the device toolbar and resize rails. */
23 deviceToolbarVisible: boolean;46 deviceToolbarVisible: boolean;
24 /** Switches between fill-panel mode and a fixed responsive viewport. */47 /** Switches between fill-panel mode and a fixed responsive viewport. */
...@@ -34,6 +57,7 @@ export function PreviewMoreMenu({...@@ -34,6 +57,7 @@ export function PreviewMoreMenu({
34 tabId,57 tabId,
35 hasWebContents,58 hasWebContents,
36 zoomFactor,59 zoomFactor,
60 colorScheme,
37 deviceToolbarVisible,61 deviceToolbarVisible,
38 onToggleDeviceToolbar,62 onToggleDeviceToolbar,
39}: Props) {63}: Props) {
...@@ -72,6 +96,26 @@ export function PreviewMoreMenu({...@@ -72,6 +96,26 @@ export function PreviewMoreMenu({
72 <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}>96 <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}>
73 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}97 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}
74 </MenuItem>98 </MenuItem>
99 <MenuSub>
100 <MenuSubTrigger disabled={tabDisabled}>Appearance</MenuSubTrigger>
101 <MenuSubPopup className="min-w-32">
102 <MenuRadioGroup
103 value={colorScheme}
104 onValueChange={(value) => {
105 if (!tabId) return;
106 void bridge
107 .setColorScheme(tabId, value as DesktopPreviewColorScheme)
108 .catch(() => undefined);
109 }}
110 >
111 {COLOR_SCHEME_OPTIONS.map((option) => (
112 <MenuRadioItem key={option.value} value={option.value}>
113 {option.label}
114 </MenuRadioItem>
115 ))}
116 </MenuRadioGroup>
117 </MenuSubPopup>
118 </MenuSub>
75 <MenuSeparator />119 <MenuSeparator />
76 {/*120 {/*
77 Zoom row: label + inline control cluster. `closeOnClick=false`121 Zoom row: label + inline control cluster. `closeOnClick=false`
apps/web/src/components/preview/PreviewView.test.tsx+1
...@@ -40,6 +40,7 @@ vi.mock("~/previewStateStore", () => ({...@@ -40,6 +40,7 @@ vi.mock("~/previewStateStore", () => ({
40 canGoForward: false,40 canGoForward: false,
41 loading: false,41 loading: false,
42 zoomFactor: 1,42 zoomFactor: 1,
43 colorScheme: "system",
43 controller: "none",44 controller: "none",
44 },45 },
45 },46 },
apps/web/src/components/preview/PreviewView.tsx+1
...@@ -610,6 +610,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,...@@ -610,6 +610,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
610 tabId={tabId}610 tabId={tabId}
611 hasWebContents={desktopOverlay !== null}611 hasWebContents={desktopOverlay !== null}
612 zoomFactor={desktopOverlay?.zoomFactor ?? 1}612 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
613 colorScheme={desktopOverlay?.colorScheme ?? "system"}
613 deviceToolbarVisible={viewport._tag !== "fill"}614 deviceToolbarVisible={viewport._tag !== "fill"}
614 onToggleDeviceToolbar={handleToggleDeviceToolbar}615 onToggleDeviceToolbar={handleToggleDeviceToolbar}
615 />616 />
apps/web/src/components/preview/usePreviewBridge.ts+1
...@@ -77,6 +77,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl...@@ -77,6 +77,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl
77 canGoForward: state.canGoForward,77 canGoForward: state.canGoForward,
78 loading: state.navStatus.kind === "Loading",78 loading: state.navStatus.kind === "Loading",
79 zoomFactor: state.zoomFactor,79 zoomFactor: state.zoomFactor,
80 colorScheme: state.colorScheme,
80 controller: state.controller,81 controller: state.controller,
81 };82 };
82}83}
apps/web/src/previewStateStore.test.ts+3
...@@ -295,6 +295,7 @@ describe("previewStateStore (single-tab)", () => {...@@ -295,6 +295,7 @@ describe("previewStateStore (single-tab)", () => {
295 canGoForward: false,295 canGoForward: false,
296 loading: false,296 loading: false,
297 zoomFactor: 1,297 zoomFactor: 1,
298 colorScheme: "system",
298 controller: "none",299 controller: "none",
299 });300 });
300 const state = readThreadPreviewState(ref);301 const state = readThreadPreviewState(ref);
...@@ -312,6 +313,7 @@ describe("previewStateStore (single-tab)", () => {...@@ -312,6 +313,7 @@ describe("previewStateStore (single-tab)", () => {
312 canGoForward: false,313 canGoForward: false,
313 loading: false,314 loading: false,
314 zoomFactor: 1,315 zoomFactor: 1,
316 colorScheme: "system",
315 controller: "none",317 controller: "none",
316 });318 });
317 setActivePreviewTab(ref, first.tabId);319 setActivePreviewTab(ref, first.tabId);
...@@ -357,6 +359,7 @@ describe("previewStateStore (single-tab)", () => {...@@ -357,6 +359,7 @@ describe("previewStateStore (single-tab)", () => {
357 canGoForward: false,359 canGoForward: false,
358 loading: false,360 loading: false,
359 zoomFactor: 1,361 zoomFactor: 1,
362 colorScheme: "system",
360 controller: "none",363 controller: "none",
361 });364 });
362365
apps/web/src/previewStateStore.ts+2
...@@ -8,6 +8,7 @@...@@ -8,6 +8,7 @@
8import { useAtomValue } from "@effect/atom-react";8import { useAtomValue } from "@effect/atom-react";
9import { scopedThreadKey } from "@t3tools/client-runtime/environment";9import { scopedThreadKey } from "@t3tools/client-runtime/environment";
10import {10import {
11 type DesktopPreviewColorScheme,
11 type PreviewEvent,12 type PreviewEvent,
12 type PreviewSessionSnapshot,13 type PreviewSessionSnapshot,
13 type ScopedThreadRef,14 type ScopedThreadRef,
...@@ -22,6 +23,7 @@ export interface DesktopPreviewOverlay {...@@ -22,6 +23,7 @@ export interface DesktopPreviewOverlay {
22 canGoForward: boolean;23 canGoForward: boolean;
23 loading: boolean;24 loading: boolean;
24 zoomFactor: number;25 zoomFactor: number;
26 colorScheme: DesktopPreviewColorScheme;
25 controller: "human" | "agent" | "none";27 controller: "human" | "agent" | "none";
26}28}
2729
packages/contracts/src/ipc.ts+21
...@@ -510,6 +510,15 @@ export type DesktopPreviewNavStatus =...@@ -510,6 +510,15 @@ export type DesktopPreviewNavStatus =
510 description: string;510 description: string;
511 };511 };
512512
513/**
514 * Emulated `prefers-color-scheme` for the guest page. "system" clears the
515 * override so the page follows the OS appearance.
516 */
517export type DesktopPreviewColorScheme = "system" | "light" | "dark";
518
519export const DesktopPreviewColorSchemeSchema: Schema.Codec<DesktopPreviewColorScheme> =
520 Schema.Literals(["system", "light", "dark"]);
521
513export interface DesktopPreviewTabState {522export interface DesktopPreviewTabState {
514 tabId: string;523 tabId: string;
515 webContentsId: number | null;524 webContentsId: number | null;
...@@ -518,6 +527,7 @@ export interface DesktopPreviewTabState {...@@ -518,6 +527,7 @@ export interface DesktopPreviewTabState {
518 canGoForward: boolean;527 canGoForward: boolean;
519 /** Current zoom factor (1.0 = 100%). */528 /** Current zoom factor (1.0 = 100%). */
520 zoomFactor: number;529 zoomFactor: number;
530 colorScheme: DesktopPreviewColorScheme;
521 controller: "human" | "agent" | "none";531 controller: "human" | "agent" | "none";
522 updatedAt: string;532 updatedAt: string;
523}533}
...@@ -554,6 +564,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState>...@@ -554,6 +564,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState>
554 canGoBack: Schema.Boolean,564 canGoBack: Schema.Boolean,
555 canGoForward: Schema.Boolean,565 canGoForward: Schema.Boolean,
556 zoomFactor: Schema.Number,566 zoomFactor: Schema.Number,
567 colorScheme: DesktopPreviewColorSchemeSchema,
557 controller: Schema.Literals(["human", "agent", "none"]),568 controller: Schema.Literals(["human", "agent", "none"]),
558 updatedAt: Schema.String,569 updatedAt: Schema.String,
559});570});
...@@ -912,6 +923,11 @@ export const DesktopPreviewConfigInputSchema = Schema.Struct({...@@ -912,6 +923,11 @@ export const DesktopPreviewConfigInputSchema = Schema.Struct({
912 environmentId: EnvironmentId,923 environmentId: EnvironmentId,
913});924});
914925
926export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({
927 tabId: DesktopPreviewTabIdSchema,
928 colorScheme: DesktopPreviewColorSchemeSchema,
929});
930
915export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({931export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({
916 theme: DesktopPreviewAnnotationThemeSchema,932 theme: DesktopPreviewAnnotationThemeSchema,
917});933});
...@@ -1034,6 +1050,11 @@ export interface DesktopPreviewBridge {...@@ -1034,6 +1050,11 @@ export interface DesktopPreviewBridge {
1034 resetZoom: (tabId: string) => Promise<void>;1050 resetZoom: (tabId: string) => Promise<void>;
1035 /** Reload bypassing the HTTP cache. */1051 /** Reload bypassing the HTTP cache. */
1036 hardReload: (tabId: string) => Promise<void>;1052 hardReload: (tabId: string) => Promise<void>;
1053 /**
1054 * Emulate `prefers-color-scheme` on the guest page ("system" clears the
1055 * override). Persists per tab and is re-applied across webview swaps.
1056 */
1057 setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise<void>;
1037 /** Open the guest webview's DevTools (detached). */1058 /** Open the guest webview's DevTools (detached). */
1038 openDevTools: (tabId: string) => Promise<void>;1059 openDevTools: (tabId: string) => Promise<void>;
1039 /** Drop cookies + storage data for the preview partition (all tabs). */1060 /** Drop cookies + storage data for the preview partition (all tabs). */
packages/contracts/src/previewAutomation.ts+24
...@@ -45,6 +45,7 @@ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [...@@ -45,6 +45,7 @@ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [
45export const PREVIEW_AUTOMATION_OPERATIONS = [45export const PREVIEW_AUTOMATION_OPERATIONS = [
46 ...PREVIEW_AUTOMATION_V1_OPERATIONS,46 ...PREVIEW_AUTOMATION_V1_OPERATIONS,
47 "resize",47 "resize",
48 "setColorScheme",
48] as const;49] as const;
4950
50export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS);51export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS);
...@@ -254,6 +255,29 @@ export const PreviewAutomationResizeResult = Schema.Struct({...@@ -254,6 +255,29 @@ export const PreviewAutomationResizeResult = Schema.Struct({
254});255});
255export type PreviewAutomationResizeResult = typeof PreviewAutomationResizeResult.Type;256export type PreviewAutomationResizeResult = typeof PreviewAutomationResizeResult.Type;
256257
258/** Mirrors DesktopPreviewColorScheme; declared here to keep this module free of ipc.ts imports. */
259export const PreviewAutomationColorScheme = Schema.Literals(["system", "light", "dark"]);
260export type PreviewAutomationColorScheme = typeof PreviewAutomationColorScheme.Type;
261
262export const PreviewAutomationSetColorSchemeInput = Schema.Struct({
263 ...PreviewAutomationTabTargetFields,
264 colorScheme: PreviewAutomationColorScheme.annotate({
265 description:
266 "Emulated prefers-color-scheme for the page: light, dark, or system to follow the OS appearance.",
267 }),
268}).annotate({
269 description:
270 "Emulates prefers-color-scheme in the active browser tab without changing the OS or app theme.",
271});
272export type PreviewAutomationSetColorSchemeInput = typeof PreviewAutomationSetColorSchemeInput.Type;
273
274export const PreviewAutomationSetColorSchemeResult = Schema.Struct({
275 tabId: PreviewTabId,
276 colorScheme: PreviewAutomationColorScheme,
277});
278export type PreviewAutomationSetColorSchemeResult =
279 typeof PreviewAutomationSetColorSchemeResult.Type;
280
257const Locator = TrimmedNonEmptyString.annotate({281const Locator = TrimmedNonEmptyString.annotate({
258 description:282 description:
259 "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",283 "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",