| author | |
| committer | |
| log | 79fe11bc1c586029dfe052df25f4b2d6227b3b33 |
| tree | 9e6c10955834f8aa785432d1908e65728f13bedd |
| parent | b3e51317e404109ebc41fe1c9e487d5d73367e76 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 50 | export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; |
| 51 | 51 | export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; |
| 52 | 52 | export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; |
| 53 | export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; | |
| 53 | 54 | export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; |
| 54 | 55 | export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; |
| 55 | 56 | export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; |
apps/desktop/src/ipc/methods/preview.ts+11| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | DesktopPreviewRecordingSaveInputSchema, |
| 14 | 14 | DesktopPreviewRegisterWebviewInputSchema, |
| 15 | 15 | DesktopPreviewScreenshotArtifactSchema, |
| 16 | DesktopPreviewSetColorSchemeInputSchema, | |
| 16 | 17 | DesktopPreviewTabInputSchema, |
| 17 | 18 | DesktopPreviewWebviewConfigSchema, |
| 18 | 19 | PreviewAnnotationPayloadSchema, |
| ... | ... | @@ -138,6 +139,15 @@ export const hardReload = tabMethod( |
| 138 | 139 | "desktop.ipc.preview.hardReload", |
| 139 | 140 | (manager, tabId) => manager.hardReload(tabId), |
| 140 | 141 | ); |
| 142 | export 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 | }); | |
| 141 | 151 | export const openDevTools = tabMethod( |
| 142 | 152 | IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, |
| 143 | 153 | "desktop.ipc.preview.openDevTools", |
| ... | ... | @@ -346,6 +356,7 @@ export const methods = [ |
| 346 | 356 | zoomOut, |
| 347 | 357 | resetZoom, |
| 348 | 358 | hardReload, |
| 359 | setColorScheme, | |
| 349 | 360 | openDevTools, |
| 350 | 361 | clearCookies, |
| 351 | 362 | clearCache, |
apps/desktop/src/preload.ts+2| ... | ... | @@ -160,6 +160,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 160 | 160 | zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), |
| 161 | 161 | resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), |
| 162 | 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 | 165 | openDevTools: (tabId) => |
| 164 | 166 | ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), |
| 165 | 167 | clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), |
apps/desktop/src/preview/Manager.test.ts+73| ... | ... | @@ -365,6 +365,79 @@ describe("PreviewManager", () => { |
| 365 | 365 | ), |
| 366 | 366 | ); |
| 367 | 367 | |
| 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 | 441 | effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => |
| 369 | 442 | withManager((manager) => |
| 370 | 443 | Effect.gen(function* () { |
apps/desktop/src/preview/Manager.ts+72-2| ... | ... | @@ -7,6 +7,7 @@ |
| 7 | 7 | */ |
| 8 | 8 | import type { |
| 9 | 9 | DesktopPreviewAnnotationTheme, |
| 10 | DesktopPreviewColorScheme, | |
| 10 | 11 | DesktopPreviewPointerEvent, |
| 11 | 12 | PreviewAnnotationPayload, |
| 12 | 13 | PreviewAnnotationRect, |
| ... | ... | @@ -84,6 +85,7 @@ export interface PreviewTabState { |
| 84 | 85 | canGoBack: boolean; |
| 85 | 86 | canGoForward: boolean; |
| 86 | 87 | zoomFactor: number; |
| 88 | colorScheme: DesktopPreviewColorScheme; | |
| 87 | 89 | controller: "human" | "agent" | "none"; |
| 88 | 90 | updatedAt: string; |
| 89 | 91 | } |
| ... | ... | @@ -1288,6 +1290,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1288 | 1290 | canGoBack: false, |
| 1289 | 1291 | canGoForward: false, |
| 1290 | 1292 | zoomFactor: DEFAULT_ZOOM_FACTOR, |
| 1293 | colorScheme: "system", | |
| 1291 | 1294 | controller: "none", |
| 1292 | 1295 | updatedAt, |
| 1293 | 1296 | }; |
| ... | ... | @@ -1320,6 +1323,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1320 | 1323 | canGoBack: false, |
| 1321 | 1324 | canGoForward: false, |
| 1322 | 1325 | zoomFactor: DEFAULT_ZOOM_FACTOR, |
| 1326 | colorScheme: "system", | |
| 1323 | 1327 | controller: "none", |
| 1324 | 1328 | updatedAt, |
| 1325 | 1329 | }; |
| ... | ... | @@ -1386,7 +1390,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1386 | 1390 | wc.getZoomFactor(), |
| 1387 | 1391 | ); |
| 1388 | 1392 | yield* attachListeners(tabId, wc); |
| 1389 | runFork(ensureControlSession(wc).pipe(Effect.ignore)); | |
| 1393 | runFork(restoreControlSession(tabId, wc)); | |
| 1390 | 1394 | const registeredAt = yield* currentIso; |
| 1391 | 1395 | const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { |
| 1392 | 1396 | const current = tabs.get(tabId); |
| ... | ... | @@ -1457,6 +1461,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1457 | 1461 | canGoBack: current?.canGoBack ?? false, |
| 1458 | 1462 | canGoForward: current?.canGoForward ?? false, |
| 1459 | 1463 | zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, |
| 1464 | colorScheme: current?.colorScheme ?? "system", | |
| 1460 | 1465 | controller: current?.controller ?? "none", |
| 1461 | 1466 | updatedAt, |
| 1462 | 1467 | }; |
| ... | ... | @@ -1525,7 +1530,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1525 | 1530 | yield* detachControlSession(wc.id); |
| 1526 | 1531 | yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { |
| 1527 | 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 | 1535 | wc.openDevTools({ mode: "detach" }); |
| 1531 | 1536 | }); |
| ... | ... | @@ -1684,6 +1689,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1684 | 1689 | yield* update(tabId, { zoomFactor: next }); |
| 1685 | 1690 | }); |
| 1686 | 1691 | |
| 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 | 1751 | const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( |
| 1688 | 1752 | tabId: string, |
| 1689 | 1753 | ) { |
| ... | ... | @@ -2526,6 +2590,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 2526 | 2590 | revealArtifact, |
| 2527 | 2591 | saveRecording, |
| 2528 | 2592 | setAnnotationTheme, |
| 2593 | setColorScheme, | |
| 2529 | 2594 | setMainWindow, |
| 2530 | 2595 | startRecording, |
| 2531 | 2596 | stopRecording, |
| ... | ... | @@ -2830,6 +2895,10 @@ export class PreviewManager extends Context.Service< |
| 2830 | 2895 | readonly zoomOut: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 2831 | 2896 | readonly resetZoom: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 2832 | 2897 | readonly hardReload: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 2898 | readonly setColorScheme: ( | |
| 2899 | tabId: string, | |
| 2900 | colorScheme: DesktopPreviewColorScheme, | |
| 2901 | ) => Effect.Effect<void, PreviewManagerError>; | |
| 2833 | 2902 | readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>; |
| 2834 | 2903 | readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>; |
| 2835 | 2904 | readonly clearCache: () => Effect.Effect<void, PreviewManagerError>; |
| ... | ... | @@ -2921,6 +2990,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 2921 | 2990 | zoomOut: operations.zoomOut, |
| 2922 | 2991 | resetZoom: operations.resetZoom, |
| 2923 | 2992 | hardReload: operations.hardReload, |
| 2993 | setColorScheme: operations.setColorScheme, | |
| 2924 | 2994 | openDevTools: operations.openDevTools, |
| 2925 | 2995 | clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { |
| 2926 | 2996 | yield* browserSession |
apps/server/src/mcp/toolkits/preview/handlers.ts+3| ... | ... | @@ -4,6 +4,7 @@ import type { |
| 4 | 4 | PreviewAutomationRecordingArtifact, |
| 5 | 5 | PreviewAutomationRecordingStatus, |
| 6 | 6 | PreviewAutomationResizeResult, |
| 7 | PreviewAutomationSetColorSchemeResult, | |
| 7 | 8 | PreviewAutomationSnapshot, |
| 8 | 9 | PreviewAutomationStatus, |
| 9 | 10 | PreviewTabId, |
| ... | ... | @@ -58,6 +59,8 @@ const handlers = { |
| 58 | 59 | invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs), |
| 59 | 60 | preview_resize: (input) => |
| 60 | 61 | invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs), |
| 62 | preview_set_appearance: (input) => | |
| 63 | invokeTargeted<PreviewAutomationSetColorSchemeResult>("setColorScheme", input), | |
| 61 | 64 | preview_snapshot: (input) => invokeTargeted<PreviewAutomationSnapshot>("snapshot", input ?? {}), |
| 62 | 65 | preview_click: (input) => |
| 63 | 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 | 10 | PreviewAutomationResizeInput, |
| 11 | 11 | PreviewAutomationResizeResult, |
| 12 | 12 | PreviewAutomationScrollInput, |
| 13 | PreviewAutomationSetColorSchemeInput, | |
| 14 | PreviewAutomationSetColorSchemeResult, | |
| 13 | 15 | PreviewAutomationSnapshot, |
| 14 | 16 | PreviewAutomationStatus, |
| 15 | 17 | PreviewAutomationTabTargetInput, |
| ... | ... | @@ -86,6 +88,19 @@ export const PreviewResizeTool = safeBrowserTool( |
| 86 | 88 | .annotate(Tool.Idempotent, true), |
| 87 | 89 | ); |
| 88 | 90 | |
| 91 | export 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 | ||
| 89 | 104 | export const PreviewSnapshotTool = readonlyBrowserTool( |
| 90 | 105 | Tool.make("preview_snapshot", { |
| 91 | 106 | description: |
| ... | ... | @@ -189,6 +204,7 @@ export const PreviewToolkit = Toolkit.make( |
| 189 | 204 | PreviewOpenTool, |
| 190 | 205 | PreviewNavigateTool, |
| 191 | 206 | PreviewResizeTool, |
| 207 | PreviewSetAppearanceTool, | |
| 192 | 208 | PreviewSnapshotTool, |
| 193 | 209 | PreviewClickTool, |
| 194 | 210 | PreviewTypeTool, |
| ... | ... | @@ -205,6 +221,7 @@ export const PreviewStandardToolkit = Toolkit.make( |
| 205 | 221 | PreviewOpenTool, |
| 206 | 222 | PreviewNavigateTool, |
| 207 | 223 | PreviewResizeTool, |
| 224 | PreviewSetAppearanceTool, | |
| 208 | 225 | PreviewClickTool, |
| 209 | 226 | PreviewTypeTool, |
| 210 | 227 | PreviewPressTool, |
apps/web/src/components/preview/PreviewAutomationHosts.tsx+11| ... | ... | @@ -10,6 +10,8 @@ import { |
| 10 | 10 | type PreviewAutomationOpenInput, |
| 11 | 11 | type PreviewAutomationResizeInput, |
| 12 | 12 | type PreviewAutomationResizeResult, |
| 13 | type PreviewAutomationSetColorSchemeInput, | |
| 14 | type PreviewAutomationSetColorSchemeResult, | |
| 13 | 15 | type PreviewAutomationHost as PreviewAutomationHostState, |
| 14 | 16 | type PreviewAutomationRequest, |
| 15 | 17 | type PreviewAutomationStatus, |
| ... | ... | @@ -451,6 +453,15 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 451 | 453 | viewport, |
| 452 | 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 | 465 | case "snapshot": { |
| 455 | 466 | const ready = await requireReadyTab(); |
| 456 | 467 | return await ready.bridge.automation.snapshot(ready.tabId); |
apps/web/src/components/preview/PreviewMoreMenu.tsx+45-1| ... | ... | @@ -1,13 +1,34 @@ |
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | import type { DesktopPreviewColorScheme } from "@t3tools/contracts"; | |
| 3 | 4 | import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; |
| 4 | 5 | |
| 5 | 6 | import { Button } from "~/components/ui/button"; |
| 6 | import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu"; | |
| 7 | import { | |
| 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"; | |
| 7 | 19 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 8 | 20 | |
| 9 | 21 | import { previewBridge } from "./previewBridge"; |
| 10 | 22 | |
| 23 | const 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 | ||
| 11 | 32 | interface Props { |
| 12 | 33 | /** Active preview tab id. Tab-targeting actions are disabled without it. */ |
| 13 | 34 | tabId: string | null; |
| ... | ... | @@ -19,6 +40,8 @@ interface Props { |
| 19 | 40 | hasWebContents: boolean; |
| 20 | 41 | /** Current zoom factor as a number (1.0 = 100%). */ |
| 21 | 42 | zoomFactor: number; |
| 43 | /** Emulated `prefers-color-scheme` for the guest page. */ | |
| 44 | colorScheme: DesktopPreviewColorScheme; | |
| 22 | 45 | /** Fixed viewport modes expose the device toolbar and resize rails. */ |
| 23 | 46 | deviceToolbarVisible: boolean; |
| 24 | 47 | /** Switches between fill-panel mode and a fixed responsive viewport. */ |
| ... | ... | @@ -34,6 +57,7 @@ export function PreviewMoreMenu({ |
| 34 | 57 | tabId, |
| 35 | 58 | hasWebContents, |
| 36 | 59 | zoomFactor, |
| 60 | colorScheme, | |
| 37 | 61 | deviceToolbarVisible, |
| 38 | 62 | onToggleDeviceToolbar, |
| 39 | 63 | }: Props) { |
| ... | ... | @@ -72,6 +96,26 @@ export function PreviewMoreMenu({ |
| 72 | 96 | <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}> |
| 73 | 97 | {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} |
| 74 | 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 | 119 | <MenuSeparator /> |
| 76 | 120 | {/* |
| 77 | 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 | 40 | canGoForward: false, |
| 41 | 41 | loading: false, |
| 42 | 42 | zoomFactor: 1, |
| 43 | colorScheme: "system", | |
| 43 | 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 | 610 | tabId={tabId} |
| 611 | 611 | hasWebContents={desktopOverlay !== null} |
| 612 | 612 | zoomFactor={desktopOverlay?.zoomFactor ?? 1} |
| 613 | colorScheme={desktopOverlay?.colorScheme ?? "system"} | |
| 613 | 614 | deviceToolbarVisible={viewport._tag !== "fill"} |
| 614 | 615 | onToggleDeviceToolbar={handleToggleDeviceToolbar} |
| 615 | 616 | /> |
apps/web/src/components/preview/usePreviewBridge.ts+1| ... | ... | @@ -77,6 +77,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl |
| 77 | 77 | canGoForward: state.canGoForward, |
| 78 | 78 | loading: state.navStatus.kind === "Loading", |
| 79 | 79 | zoomFactor: state.zoomFactor, |
| 80 | colorScheme: state.colorScheme, | |
| 80 | 81 | controller: state.controller, |
| 81 | 82 | }; |
| 82 | 83 | } |
apps/web/src/previewStateStore.test.ts+3| ... | ... | @@ -295,6 +295,7 @@ describe("previewStateStore (single-tab)", () => { |
| 295 | 295 | canGoForward: false, |
| 296 | 296 | loading: false, |
| 297 | 297 | zoomFactor: 1, |
| 298 | colorScheme: "system", | |
| 298 | 299 | controller: "none", |
| 299 | 300 | }); |
| 300 | 301 | const state = readThreadPreviewState(ref); |
| ... | ... | @@ -312,6 +313,7 @@ describe("previewStateStore (single-tab)", () => { |
| 312 | 313 | canGoForward: false, |
| 313 | 314 | loading: false, |
| 314 | 315 | zoomFactor: 1, |
| 316 | colorScheme: "system", | |
| 315 | 317 | controller: "none", |
| 316 | 318 | }); |
| 317 | 319 | setActivePreviewTab(ref, first.tabId); |
| ... | ... | @@ -357,6 +359,7 @@ describe("previewStateStore (single-tab)", () => { |
| 357 | 359 | canGoForward: false, |
| 358 | 360 | loading: false, |
| 359 | 361 | zoomFactor: 1, |
| 362 | colorScheme: "system", | |
| 360 | 363 | controller: "none", |
| 361 | 364 | }); |
| 362 | 365 |
apps/web/src/previewStateStore.ts+2| ... | ... | @@ -8,6 +8,7 @@ |
| 8 | 8 | import { useAtomValue } from "@effect/atom-react"; |
| 9 | 9 | import { scopedThreadKey } from "@t3tools/client-runtime/environment"; |
| 10 | 10 | import { |
| 11 | type DesktopPreviewColorScheme, | |
| 11 | 12 | type PreviewEvent, |
| 12 | 13 | type PreviewSessionSnapshot, |
| 13 | 14 | type ScopedThreadRef, |
| ... | ... | @@ -22,6 +23,7 @@ export interface DesktopPreviewOverlay { |
| 22 | 23 | canGoForward: boolean; |
| 23 | 24 | loading: boolean; |
| 24 | 25 | zoomFactor: number; |
| 26 | colorScheme: DesktopPreviewColorScheme; | |
| 25 | 27 | controller: "human" | "agent" | "none"; |
| 26 | 28 | } |
| 27 | 29 |
packages/contracts/src/ipc.ts+21| ... | ... | @@ -510,6 +510,15 @@ export type DesktopPreviewNavStatus = |
| 510 | 510 | description: string; |
| 511 | 511 | }; |
| 512 | 512 | |
| 513 | /** | |
| 514 | * Emulated `prefers-color-scheme` for the guest page. "system" clears the | |
| 515 | * override so the page follows the OS appearance. | |
| 516 | */ | |
| 517 | export type DesktopPreviewColorScheme = "system" | "light" | "dark"; | |
| 518 | ||
| 519 | export const DesktopPreviewColorSchemeSchema: Schema.Codec<DesktopPreviewColorScheme> = | |
| 520 | Schema.Literals(["system", "light", "dark"]); | |
| 521 | ||
| 513 | 522 | export interface DesktopPreviewTabState { |
| 514 | 523 | tabId: string; |
| 515 | 524 | webContentsId: number | null; |
| ... | ... | @@ -518,6 +527,7 @@ export interface DesktopPreviewTabState { |
| 518 | 527 | canGoForward: boolean; |
| 519 | 528 | /** Current zoom factor (1.0 = 100%). */ |
| 520 | 529 | zoomFactor: number; |
| 530 | colorScheme: DesktopPreviewColorScheme; | |
| 521 | 531 | controller: "human" | "agent" | "none"; |
| 522 | 532 | updatedAt: string; |
| 523 | 533 | } |
| ... | ... | @@ -554,6 +564,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState> |
| 554 | 564 | canGoBack: Schema.Boolean, |
| 555 | 565 | canGoForward: Schema.Boolean, |
| 556 | 566 | zoomFactor: Schema.Number, |
| 567 | colorScheme: DesktopPreviewColorSchemeSchema, | |
| 557 | 568 | controller: Schema.Literals(["human", "agent", "none"]), |
| 558 | 569 | updatedAt: Schema.String, |
| 559 | 570 | }); |
| ... | ... | @@ -912,6 +923,11 @@ export const DesktopPreviewConfigInputSchema = Schema.Struct({ |
| 912 | 923 | environmentId: EnvironmentId, |
| 913 | 924 | }); |
| 914 | 925 | |
| 926 | export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ | |
| 927 | tabId: DesktopPreviewTabIdSchema, | |
| 928 | colorScheme: DesktopPreviewColorSchemeSchema, | |
| 929 | }); | |
| 930 | ||
| 915 | 931 | export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ |
| 916 | 932 | theme: DesktopPreviewAnnotationThemeSchema, |
| 917 | 933 | }); |
| ... | ... | @@ -1034,6 +1050,11 @@ export interface DesktopPreviewBridge { |
| 1034 | 1050 | resetZoom: (tabId: string) => Promise<void>; |
| 1035 | 1051 | /** Reload bypassing the HTTP cache. */ |
| 1036 | 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 | 1058 | /** Open the guest webview's DevTools (detached). */ |
| 1038 | 1059 | openDevTools: (tabId: string) => Promise<void>; |
| 1039 | 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 | 45 | export const PREVIEW_AUTOMATION_OPERATIONS = [ |
| 46 | 46 | ...PREVIEW_AUTOMATION_V1_OPERATIONS, |
| 47 | 47 | "resize", |
| 48 | "setColorScheme", | |
| 48 | 49 | ] as const; |
| 49 | 50 | |
| 50 | 51 | export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS); |
| ... | ... | @@ -254,6 +255,29 @@ export const PreviewAutomationResizeResult = Schema.Struct({ |
| 254 | 255 | }); |
| 255 | 256 | export type PreviewAutomationResizeResult = typeof PreviewAutomationResizeResult.Type; |
| 256 | 257 | |
| 258 | /** Mirrors DesktopPreviewColorScheme; declared here to keep this module free of ipc.ts imports. */ | |
| 259 | export const PreviewAutomationColorScheme = Schema.Literals(["system", "light", "dark"]); | |
| 260 | export type PreviewAutomationColorScheme = typeof PreviewAutomationColorScheme.Type; | |
| 261 | ||
| 262 | export 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 | }); | |
| 272 | export type PreviewAutomationSetColorSchemeInput = typeof PreviewAutomationSetColorSchemeInput.Type; | |
| 273 | ||
| 274 | export const PreviewAutomationSetColorSchemeResult = Schema.Struct({ | |
| 275 | tabId: PreviewTabId, | |
| 276 | colorScheme: PreviewAutomationColorScheme, | |
| 277 | }); | |
| 278 | export type PreviewAutomationSetColorSchemeResult = | |
| 279 | typeof PreviewAutomationSetColorSchemeResult.Type; | |
| 280 | ||
| 257 | 281 | const Locator = TrimmedNonEmptyString.annotate({ |
| 258 | 282 | description: |
| 259 | 283 | "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", |