diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0d5c79cf55a..95c725130e5 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -50,6 +50,7 @@ export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in"; export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; +export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 2abf53ac284..28405288f6c 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -13,6 +13,7 @@ import { DesktopPreviewRecordingSaveInputSchema, DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, + DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, PreviewAnnotationPayloadSchema, @@ -138,6 +139,15 @@ export const hardReload = tabMethod( "desktop.ipc.preview.hardReload", (manager, tabId) => manager.hardReload(tabId), ); +export const setColorScheme = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, + payload: DesktopPreviewSetColorSchemeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* ({ tabId, colorScheme }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setColorScheme(tabId, colorScheme); + }), +}); export const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -346,6 +356,7 @@ export const methods = [ zoomOut, resetZoom, hardReload, + setColorScheme, openDevTools, clearCookies, clearCache, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f92cfcf2fa..228114fd1d1 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -160,6 +160,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), + setColorScheme: (tabId, colorScheme) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6e2df118c34..f1215ee7b60 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -365,6 +365,79 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("emulates prefers-color-scheme and re-applies it across webview swaps", () => + withManager((manager) => + Effect.gen(function* () { + const makeWebContents = (id: number) => { + const sendCommand = vi.fn(async () => undefined); + return { + sendCommand, + wc: { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never, + }; + }; + const first = makeWebContents(42); + fromId.mockReturnValue(first.wc); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_scheme"); + yield* manager.registerWebview("tab_scheme", 42); + yield* Effect.yieldNow; + + yield* manager.setColorScheme("tab_scheme", "dark"); + + expect(first.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + expect(states.at(-1)?.colorScheme).toBe("dark"); + + const replacement = makeWebContents(43); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_scheme", 43); + yield* Effect.yieldNow; + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + expect(states.at(-1)?.colorScheme).toBe("dark"); + + yield* manager.setColorScheme("tab_scheme", "system"); + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "" }], + }); + expect(states.at(-1)?.colorScheme).toBe("system"); + }), + ), + ); + effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 2d0360ef72c..6c942d4ccb9 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -7,6 +7,7 @@ */ import type { DesktopPreviewAnnotationTheme, + DesktopPreviewColorScheme, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -84,6 +85,7 @@ export interface PreviewTabState { canGoBack: boolean; canGoForward: boolean; zoomFactor: number; + colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; updatedAt: string; } @@ -1288,6 +1290,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, + colorScheme: "system", controller: "none", updatedAt, }; @@ -1320,6 +1323,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, + colorScheme: "system", controller: "none", updatedAt, }; @@ -1386,7 +1390,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.getZoomFactor(), ); yield* attachListeners(tabId, wc); - runFork(ensureControlSession(wc).pipe(Effect.ignore)); + runFork(restoreControlSession(tabId, wc)); const registeredAt = yield* currentIso; const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); @@ -1457,6 +1461,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoBack: current?.canGoBack ?? false, canGoForward: current?.canGoForward ?? false, zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, + colorScheme: current?.colorScheme ?? "system", controller: current?.controller ?? "none", updatedAt, }; @@ -1525,7 +1530,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* detachControlSession(wc.id); yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { wc.once("devtools-closed", () => { - if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); + if (!wc.isDestroyed()) runFork(restoreControlSession(tabId, wc)); }); wc.openDevTools({ mode: "detach" }); }); @@ -1684,6 +1689,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* update(tabId, { zoomFactor: next }); }); + // Emulated media lives on the CDP debugger session, not the WebContents, so + // it is lost whenever the session detaches (webview swap, DevTools + // open/close) and must be re-applied after every (re)attach. + const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* ( + tabId: string, + wc: Electron.WebContents, + colorScheme: DesktopPreviewColorScheme, + ) { + yield* ensureControlSession(wc); + yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => + wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + features: [ + { + name: "prefers-color-scheme", + // An empty value clears the override so the page follows the OS. + value: colorScheme === "system" ? "" : colorScheme, + }, + ], + }), + ); + }); + + // Re-establish the control session after a detach, restoring any + // color-scheme override the tab carries. The scheme is read after the + // session attaches so a concurrent setColorScheme is not overwritten with + // a stale snapshot. + const restoreControlSession = (tabId: string, wc: Electron.WebContents) => + ensureControlSession(wc).pipe( + Effect.andThen(SynchronizedRef.get(tabsRef)), + Effect.flatMap((tabs) => { + const colorScheme = tabs.get(tabId)?.colorScheme ?? "system"; + return colorScheme === "system" ? Effect.void : applyColorScheme(tabId, wc, colorScheme); + }), + Effect.ignore, + ); + + const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( + tabId: string, + colorScheme: DesktopPreviewColorScheme, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + if (tab.colorScheme !== colorScheme) { + // Record the choice even when the CDP call below can't run yet (no + // webview, DevTools holding the debugger) — it is re-applied on the + // next control-session (re)attach. + yield* update(tabId, { colorScheme }); + } + // Re-read after the update: registerWebview may have swapped the guest + // in the meantime and the override must land on the current one. + const webContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId; + if (webContentsId == null) return; + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return; + yield* applyColorScheme(tabId, wc, colorScheme); + }); + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( tabId: string, ) { @@ -2526,6 +2590,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function revealArtifact, saveRecording, setAnnotationTheme, + setColorScheme, setMainWindow, startRecording, stopRecording, @@ -2830,6 +2895,10 @@ export class PreviewManager extends Context.Service< readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; + readonly setColorScheme: ( + tabId: string, + colorScheme: DesktopPreviewColorScheme, + ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: () => Effect.Effect; readonly clearCache: () => Effect.Effect; @@ -2921,6 +2990,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, hardReload: operations.hardReload, + setColorScheme: operations.setColorScheme, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { yield* browserSession diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 64d6ba02b1d..8c4651dc1cf 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -4,6 +4,7 @@ import type { PreviewAutomationRecordingArtifact, PreviewAutomationRecordingStatus, PreviewAutomationResizeResult, + PreviewAutomationSetColorSchemeResult, PreviewAutomationSnapshot, PreviewAutomationStatus, PreviewTabId, @@ -58,6 +59,8 @@ const handlers = { invokeTargeted("navigate", input, input.timeoutMs), preview_resize: (input) => invokeTargeted("resize", input, input.timeoutMs), + preview_set_appearance: (input) => + invokeTargeted("setColorScheme", input), preview_snapshot: (input) => invokeTargeted("snapshot", input ?? {}), preview_click: (input) => invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as(null)), diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index c729fc20ece..d2527fdfb39 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -10,6 +10,8 @@ import { PreviewAutomationResizeInput, PreviewAutomationResizeResult, PreviewAutomationScrollInput, + PreviewAutomationSetColorSchemeInput, + PreviewAutomationSetColorSchemeResult, PreviewAutomationSnapshot, PreviewAutomationStatus, PreviewAutomationTabTargetInput, @@ -86,6 +88,19 @@ export const PreviewResizeTool = safeBrowserTool( .annotate(Tool.Idempotent, true), ); +export const PreviewSetAppearanceTool = safeBrowserTool( + Tool.make("preview_set_appearance", { + description: + "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.", + parameters: PreviewAutomationSetColorSchemeInput, + success: PreviewAutomationSetColorSchemeResult, + failure: PreviewAutomationError, + dependencies, + }) + .annotate(Tool.Title, "Set preview appearance") + .annotate(Tool.Idempotent, true), +); + export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: @@ -189,6 +204,7 @@ export const PreviewToolkit = Toolkit.make( PreviewOpenTool, PreviewNavigateTool, PreviewResizeTool, + PreviewSetAppearanceTool, PreviewSnapshotTool, PreviewClickTool, PreviewTypeTool, @@ -205,6 +221,7 @@ export const PreviewStandardToolkit = Toolkit.make( PreviewOpenTool, PreviewNavigateTool, PreviewResizeTool, + PreviewSetAppearanceTool, PreviewClickTool, PreviewTypeTool, PreviewPressTool, diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 06954b73f37..27383b29f19 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -10,6 +10,8 @@ import { type PreviewAutomationOpenInput, type PreviewAutomationResizeInput, type PreviewAutomationResizeResult, + type PreviewAutomationSetColorSchemeInput, + type PreviewAutomationSetColorSchemeResult, type PreviewAutomationHost as PreviewAutomationHostState, type PreviewAutomationRequest, type PreviewAutomationStatus, @@ -451,6 +453,15 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) viewport, } satisfies PreviewAutomationResizeResult; } + case "setColorScheme": { + const ready = await requireReadyTab(); + const input = request.input as PreviewAutomationSetColorSchemeInput; + await ready.bridge.setColorScheme(ready.tabId, input.colorScheme); + return { + tabId: ready.tabId, + colorScheme: input.colorScheme, + } satisfies PreviewAutomationSetColorSchemeResult; + } case "snapshot": { const ready = await requireReadyTab(); return await ready.bridge.automation.snapshot(ready.tabId); diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 13ddcf57e9e..28fc2e22232 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -1,13 +1,34 @@ "use client"; +import type { DesktopPreviewColorScheme } from "@t3tools/contracts"; import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; import { Button } from "~/components/ui/button"; -import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu"; +import { + Menu, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "~/components/ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { previewBridge } from "./previewBridge"; +const COLOR_SCHEME_OPTIONS: ReadonlyArray<{ + value: DesktopPreviewColorScheme; + label: string; +}> = [ + { value: "system", label: "System" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, +]; + interface Props { /** Active preview tab id. Tab-targeting actions are disabled without it. */ tabId: string | null; @@ -19,6 +40,8 @@ interface Props { hasWebContents: boolean; /** Current zoom factor as a number (1.0 = 100%). */ zoomFactor: number; + /** Emulated `prefers-color-scheme` for the guest page. */ + colorScheme: DesktopPreviewColorScheme; /** Fixed viewport modes expose the device toolbar and resize rails. */ deviceToolbarVisible: boolean; /** Switches between fill-panel mode and a fixed responsive viewport. */ @@ -34,6 +57,7 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor, + colorScheme, deviceToolbarVisible, onToggleDeviceToolbar, }: Props) { @@ -72,6 +96,26 @@ export function PreviewMoreMenu({ {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} + + Appearance + + { + if (!tabId) return; + void bridge + .setColorScheme(tabId, value as DesktopPreviewColorScheme) + .catch(() => undefined); + }} + > + {COLOR_SCHEME_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + {/* Zoom row: label + inline control cluster. `closeOnClick=false` diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index c0d503c7599..61111025fa4 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -40,6 +40,7 @@ vi.mock("~/previewStateStore", () => ({ canGoForward: false, loading: false, zoomFactor: 1, + colorScheme: "system", controller: "none", }, }, diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 5f7deeb0fcd..daff913a6d5 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -610,6 +610,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, tabId={tabId} hasWebContents={desktopOverlay !== null} zoomFactor={desktopOverlay?.zoomFactor ?? 1} + colorScheme={desktopOverlay?.colorScheme ?? "system"} deviceToolbarVisible={viewport._tag !== "fill"} onToggleDeviceToolbar={handleToggleDeviceToolbar} /> diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 8794ff8b487..22d17f968ba 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -77,6 +77,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl canGoForward: state.canGoForward, loading: state.navStatus.kind === "Loading", zoomFactor: state.zoomFactor, + colorScheme: state.colorScheme, controller: state.controller, }; } diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index c908e23f9ba..f4a3eb732ba 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -295,6 +295,7 @@ describe("previewStateStore (single-tab)", () => { canGoForward: false, loading: false, zoomFactor: 1, + colorScheme: "system", controller: "none", }); const state = readThreadPreviewState(ref); @@ -312,6 +313,7 @@ describe("previewStateStore (single-tab)", () => { canGoForward: false, loading: false, zoomFactor: 1, + colorScheme: "system", controller: "none", }); setActivePreviewTab(ref, first.tabId); @@ -357,6 +359,7 @@ describe("previewStateStore (single-tab)", () => { canGoForward: false, loading: false, zoomFactor: 1, + colorScheme: "system", controller: "none", }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index e44a464ed24..34553d6f80b 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -8,6 +8,7 @@ import { useAtomValue } from "@effect/atom-react"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { + type DesktopPreviewColorScheme, type PreviewEvent, type PreviewSessionSnapshot, type ScopedThreadRef, @@ -22,6 +23,7 @@ export interface DesktopPreviewOverlay { canGoForward: boolean; loading: boolean; zoomFactor: number; + colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 6c853ca1631..4b1676d7926 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -510,6 +510,15 @@ export type DesktopPreviewNavStatus = description: string; }; +/** + * Emulated `prefers-color-scheme` for the guest page. "system" clears the + * override so the page follows the OS appearance. + */ +export type DesktopPreviewColorScheme = "system" | "light" | "dark"; + +export const DesktopPreviewColorSchemeSchema: Schema.Codec = + Schema.Literals(["system", "light", "dark"]); + export interface DesktopPreviewTabState { tabId: string; webContentsId: number | null; @@ -518,6 +527,7 @@ export interface DesktopPreviewTabState { canGoForward: boolean; /** Current zoom factor (1.0 = 100%). */ zoomFactor: number; + colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; updatedAt: string; } @@ -554,6 +564,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec canGoBack: Schema.Boolean, canGoForward: Schema.Boolean, zoomFactor: Schema.Number, + colorScheme: DesktopPreviewColorSchemeSchema, controller: Schema.Literals(["human", "agent", "none"]), updatedAt: Schema.String, }); @@ -912,6 +923,11 @@ export const DesktopPreviewConfigInputSchema = Schema.Struct({ environmentId: EnvironmentId, }); +export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + colorScheme: DesktopPreviewColorSchemeSchema, +}); + export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ theme: DesktopPreviewAnnotationThemeSchema, }); @@ -1034,6 +1050,11 @@ export interface DesktopPreviewBridge { resetZoom: (tabId: string) => Promise; /** Reload bypassing the HTTP cache. */ hardReload: (tabId: string) => Promise; + /** + * Emulate `prefers-color-scheme` on the guest page ("system" clears the + * override). Persists per tab and is re-applied across webview swaps. + */ + setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; /** Open the guest webview's DevTools (detached). */ openDevTools: (tabId: string) => Promise; /** Drop cookies + storage data for the preview partition (all tabs). */ diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 6431dd0dcfd..f05623cbc99 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -45,6 +45,7 @@ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [ export const PREVIEW_AUTOMATION_OPERATIONS = [ ...PREVIEW_AUTOMATION_V1_OPERATIONS, "resize", + "setColorScheme", ] as const; export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS); @@ -254,6 +255,29 @@ export const PreviewAutomationResizeResult = Schema.Struct({ }); export type PreviewAutomationResizeResult = typeof PreviewAutomationResizeResult.Type; +/** Mirrors DesktopPreviewColorScheme; declared here to keep this module free of ipc.ts imports. */ +export const PreviewAutomationColorScheme = Schema.Literals(["system", "light", "dark"]); +export type PreviewAutomationColorScheme = typeof PreviewAutomationColorScheme.Type; + +export const PreviewAutomationSetColorSchemeInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, + colorScheme: PreviewAutomationColorScheme.annotate({ + description: + "Emulated prefers-color-scheme for the page: light, dark, or system to follow the OS appearance.", + }), +}).annotate({ + description: + "Emulates prefers-color-scheme in the active browser tab without changing the OS or app theme.", +}); +export type PreviewAutomationSetColorSchemeInput = typeof PreviewAutomationSetColorSchemeInput.Type; + +export const PreviewAutomationSetColorSchemeResult = Schema.Struct({ + tabId: PreviewTabId, + colorScheme: PreviewAutomationColorScheme, +}); +export type PreviewAutomationSetColorSchemeResult = + typeof PreviewAutomationSetColorSchemeResult.Type; + const Locator = TrimmedNonEmptyString.annotate({ description: "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",