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";
5050export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";
5151export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
5252export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
53export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";
5354export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
5455export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
5556export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";
apps/desktop/src/ipc/methods/preview.ts+11
......@@ -13,6 +13,7 @@ import {
1313 DesktopPreviewRecordingSaveInputSchema,
1414 DesktopPreviewRegisterWebviewInputSchema,
1515 DesktopPreviewScreenshotArtifactSchema,
16 DesktopPreviewSetColorSchemeInputSchema,
1617 DesktopPreviewTabInputSchema,
1718 DesktopPreviewWebviewConfigSchema,
1819 PreviewAnnotationPayloadSchema,
......@@ -138,6 +139,15 @@ export const hardReload = tabMethod(
138139 "desktop.ipc.preview.hardReload",
139140 (manager, tabId) => manager.hardReload(tabId),
140141);
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});
141151export const openDevTools = tabMethod(
142152 IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL,
143153 "desktop.ipc.preview.openDevTools",
......@@ -346,6 +356,7 @@ export const methods = [
346356 zoomOut,
347357 resetZoom,
348358 hardReload,
359 setColorScheme,
349360 openDevTools,
350361 clearCookies,
351362 clearCache,
apps/desktop/src/preload.ts+2
......@@ -160,6 +160,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
160160 zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),
161161 resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }),
162162 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 }),
163165 openDevTools: (tabId) =>
164166 ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
165167 clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),
apps/desktop/src/preview/Manager.test.ts+73
......@@ -365,6 +365,79 @@ describe("PreviewManager", () => {
365365 ),
366366 );
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
368441 effectIt.effect("keeps a main-frame load failure visible until a retry starts", () =>
369442 withManager((manager) =>
370443 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+72-2
......@@ -7,6 +7,7 @@
77 */
88import type {
99 DesktopPreviewAnnotationTheme,
10 DesktopPreviewColorScheme,
1011 DesktopPreviewPointerEvent,
1112 PreviewAnnotationPayload,
1213 PreviewAnnotationRect,
......@@ -84,6 +85,7 @@ export interface PreviewTabState {
8485 canGoBack: boolean;
8586 canGoForward: boolean;
8687 zoomFactor: number;
88 colorScheme: DesktopPreviewColorScheme;
8789 controller: "human" | "agent" | "none";
8890 updatedAt: string;
8991}
......@@ -1288,6 +1290,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12881290 canGoBack: false,
12891291 canGoForward: false,
12901292 zoomFactor: DEFAULT_ZOOM_FACTOR,
1293 colorScheme: "system",
12911294 controller: "none",
12921295 updatedAt,
12931296 };
......@@ -1320,6 +1323,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13201323 canGoBack: false,
13211324 canGoForward: false,
13221325 zoomFactor: DEFAULT_ZOOM_FACTOR,
1326 colorScheme: "system",
13231327 controller: "none",
13241328 updatedAt,
13251329 };
......@@ -1386,7 +1390,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
13861390 wc.getZoomFactor(),
13871391 );
13881392 yield* attachListeners(tabId, wc);
1389 runFork(ensureControlSession(wc).pipe(Effect.ignore));
1393 runFork(restoreControlSession(tabId, wc));
13901394 const registeredAt = yield* currentIso;
13911395 const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => {
13921396 const current = tabs.get(tabId);
......@@ -1457,6 +1461,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
14571461 canGoBack: current?.canGoBack ?? false,
14581462 canGoForward: current?.canGoForward ?? false,
14591463 zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR,
1464 colorScheme: current?.colorScheme ?? "system",
14601465 controller: current?.controller ?? "none",
14611466 updatedAt,
14621467 };
......@@ -1525,7 +1530,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15251530 yield* detachControlSession(wc.id);
15261531 yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => {
15271532 wc.once("devtools-closed", () => {
1528 if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore));
1533 if (!wc.isDestroyed()) runFork(restoreControlSession(tabId, wc));
15291534 });
15301535 wc.openDevTools({ mode: "detach" });
15311536 });
......@@ -1684,6 +1689,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16841689 yield* update(tabId, { zoomFactor: next });
16851690 });
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
16871751 const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* (
16881752 tabId: string,
16891753 ) {
......@@ -2526,6 +2590,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
25262590 revealArtifact,
25272591 saveRecording,
25282592 setAnnotationTheme,
2593 setColorScheme,
25292594 setMainWindow,
25302595 startRecording,
25312596 stopRecording,
......@@ -2830,6 +2895,10 @@ export class PreviewManager extends Context.Service<
28302895 readonly zoomOut: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
28312896 readonly resetZoom: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
28322897 readonly hardReload: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
2898 readonly setColorScheme: (
2899 tabId: string,
2900 colorScheme: DesktopPreviewColorScheme,
2901 ) => Effect.Effect<void, PreviewManagerError>;
28332902 readonly openDevTools: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
28342903 readonly clearCookies: () => Effect.Effect<void, PreviewManagerError>;
28352904 readonly clearCache: () => Effect.Effect<void, PreviewManagerError>;
......@@ -2921,6 +2990,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
29212990 zoomOut: operations.zoomOut,
29222991 resetZoom: operations.resetZoom,
29232992 hardReload: operations.hardReload,
2993 setColorScheme: operations.setColorScheme,
29242994 openDevTools: operations.openDevTools,
29252995 clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () {
29262996 yield* browserSession
apps/server/src/mcp/toolkits/preview/handlers.ts+3
......@@ -4,6 +4,7 @@ import type {
44 PreviewAutomationRecordingArtifact,
55 PreviewAutomationRecordingStatus,
66 PreviewAutomationResizeResult,
7 PreviewAutomationSetColorSchemeResult,
78 PreviewAutomationSnapshot,
89 PreviewAutomationStatus,
910 PreviewTabId,
......@@ -58,6 +59,8 @@ const handlers = {
5859 invokeTargeted<PreviewAutomationStatus>("navigate", input, input.timeoutMs),
5960 preview_resize: (input) =>
6061 invokeTargeted<PreviewAutomationResizeResult>("resize", input, input.timeoutMs),
62 preview_set_appearance: (input) =>
63 invokeTargeted<PreviewAutomationSetColorSchemeResult>("setColorScheme", input),
6164 preview_snapshot: (input) => invokeTargeted<PreviewAutomationSnapshot>("snapshot", input ?? {}),
6265 preview_click: (input) =>
6366 invokeTargeted<void>("click", input, input.timeoutMs).pipe(Effect.as(null)),
apps/server/src/mcp/toolkits/preview/tools.ts+17
......@@ -10,6 +10,8 @@ import {
1010 PreviewAutomationResizeInput,
1111 PreviewAutomationResizeResult,
1212 PreviewAutomationScrollInput,
13 PreviewAutomationSetColorSchemeInput,
14 PreviewAutomationSetColorSchemeResult,
1315 PreviewAutomationSnapshot,
1416 PreviewAutomationStatus,
1517 PreviewAutomationTabTargetInput,
......@@ -86,6 +88,19 @@ export const PreviewResizeTool = safeBrowserTool(
8688 .annotate(Tool.Idempotent, true),
8789);
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
89104export const PreviewSnapshotTool = readonlyBrowserTool(
90105 Tool.make("preview_snapshot", {
91106 description:
......@@ -189,6 +204,7 @@ export const PreviewToolkit = Toolkit.make(
189204 PreviewOpenTool,
190205 PreviewNavigateTool,
191206 PreviewResizeTool,
207 PreviewSetAppearanceTool,
192208 PreviewSnapshotTool,
193209 PreviewClickTool,
194210 PreviewTypeTool,
......@@ -205,6 +221,7 @@ export const PreviewStandardToolkit = Toolkit.make(
205221 PreviewOpenTool,
206222 PreviewNavigateTool,
207223 PreviewResizeTool,
224 PreviewSetAppearanceTool,
208225 PreviewClickTool,
209226 PreviewTypeTool,
210227 PreviewPressTool,
apps/web/src/components/preview/PreviewAutomationHosts.tsx+11
......@@ -10,6 +10,8 @@ import {
1010 type PreviewAutomationOpenInput,
1111 type PreviewAutomationResizeInput,
1212 type PreviewAutomationResizeResult,
13 type PreviewAutomationSetColorSchemeInput,
14 type PreviewAutomationSetColorSchemeResult,
1315 type PreviewAutomationHost as PreviewAutomationHostState,
1416 type PreviewAutomationRequest,
1517 type PreviewAutomationStatus,
......@@ -451,6 +453,15 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
451453 viewport,
452454 } satisfies PreviewAutomationResizeResult;
453455 }
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 }
454465 case "snapshot": {
455466 const ready = await requireReadyTab();
456467 return await ready.bridge.automation.snapshot(ready.tabId);
apps/web/src/components/preview/PreviewMoreMenu.tsx+45-1
......@@ -1,13 +1,34 @@
11"use client";
22
3import type { DesktopPreviewColorScheme } from "@t3tools/contracts";
34import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";
45
56import { 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";
719import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
820
921import { 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
1132interface Props {
1233 /** Active preview tab id. Tab-targeting actions are disabled without it. */
1334 tabId: string | null;
......@@ -19,6 +40,8 @@ interface Props {
1940 hasWebContents: boolean;
2041 /** Current zoom factor as a number (1.0 = 100%). */
2142 zoomFactor: number;
43 /** Emulated `prefers-color-scheme` for the guest page. */
44 colorScheme: DesktopPreviewColorScheme;
2245 /** Fixed viewport modes expose the device toolbar and resize rails. */
2346 deviceToolbarVisible: boolean;
2447 /** Switches between fill-panel mode and a fixed responsive viewport. */
......@@ -34,6 +57,7 @@ export function PreviewMoreMenu({
3457 tabId,
3558 hasWebContents,
3659 zoomFactor,
60 colorScheme,
3761 deviceToolbarVisible,
3862 onToggleDeviceToolbar,
3963}: Props) {
......@@ -72,6 +96,26 @@ export function PreviewMoreMenu({
7296 <MenuItem onClick={onToggleDeviceToolbar} disabled={tabDisabled}>
7397 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}
7498 </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>
75119 <MenuSeparator />
76120 {/*
77121 Zoom row: label + inline control cluster. `closeOnClick=false`
apps/web/src/components/preview/PreviewView.test.tsx+1
......@@ -40,6 +40,7 @@ vi.mock("~/previewStateStore", () => ({
4040 canGoForward: false,
4141 loading: false,
4242 zoomFactor: 1,
43 colorScheme: "system",
4344 controller: "none",
4445 },
4546 },
apps/web/src/components/preview/PreviewView.tsx+1
......@@ -610,6 +610,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls,
610610 tabId={tabId}
611611 hasWebContents={desktopOverlay !== null}
612612 zoomFactor={desktopOverlay?.zoomFactor ?? 1}
613 colorScheme={desktopOverlay?.colorScheme ?? "system"}
613614 deviceToolbarVisible={viewport._tag !== "fill"}
614615 onToggleDeviceToolbar={handleToggleDeviceToolbar}
615616 />
apps/web/src/components/preview/usePreviewBridge.ts+1
......@@ -77,6 +77,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl
7777 canGoForward: state.canGoForward,
7878 loading: state.navStatus.kind === "Loading",
7979 zoomFactor: state.zoomFactor,
80 colorScheme: state.colorScheme,
8081 controller: state.controller,
8182 };
8283}
apps/web/src/previewStateStore.test.ts+3
......@@ -295,6 +295,7 @@ describe("previewStateStore (single-tab)", () => {
295295 canGoForward: false,
296296 loading: false,
297297 zoomFactor: 1,
298 colorScheme: "system",
298299 controller: "none",
299300 });
300301 const state = readThreadPreviewState(ref);
......@@ -312,6 +313,7 @@ describe("previewStateStore (single-tab)", () => {
312313 canGoForward: false,
313314 loading: false,
314315 zoomFactor: 1,
316 colorScheme: "system",
315317 controller: "none",
316318 });
317319 setActivePreviewTab(ref, first.tabId);
......@@ -357,6 +359,7 @@ describe("previewStateStore (single-tab)", () => {
357359 canGoForward: false,
358360 loading: false,
359361 zoomFactor: 1,
362 colorScheme: "system",
360363 controller: "none",
361364 });
362365
apps/web/src/previewStateStore.ts+2
......@@ -8,6 +8,7 @@
88import { useAtomValue } from "@effect/atom-react";
99import { scopedThreadKey } from "@t3tools/client-runtime/environment";
1010import {
11 type DesktopPreviewColorScheme,
1112 type PreviewEvent,
1213 type PreviewSessionSnapshot,
1314 type ScopedThreadRef,
......@@ -22,6 +23,7 @@ export interface DesktopPreviewOverlay {
2223 canGoForward: boolean;
2324 loading: boolean;
2425 zoomFactor: number;
26 colorScheme: DesktopPreviewColorScheme;
2527 controller: "human" | "agent" | "none";
2628}
2729
packages/contracts/src/ipc.ts+21
......@@ -510,6 +510,15 @@ export type DesktopPreviewNavStatus =
510510 description: string;
511511 };
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
513522export interface DesktopPreviewTabState {
514523 tabId: string;
515524 webContentsId: number | null;
......@@ -518,6 +527,7 @@ export interface DesktopPreviewTabState {
518527 canGoForward: boolean;
519528 /** Current zoom factor (1.0 = 100%). */
520529 zoomFactor: number;
530 colorScheme: DesktopPreviewColorScheme;
521531 controller: "human" | "agent" | "none";
522532 updatedAt: string;
523533}
......@@ -554,6 +564,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec<DesktopPreviewTabState>
554564 canGoBack: Schema.Boolean,
555565 canGoForward: Schema.Boolean,
556566 zoomFactor: Schema.Number,
567 colorScheme: DesktopPreviewColorSchemeSchema,
557568 controller: Schema.Literals(["human", "agent", "none"]),
558569 updatedAt: Schema.String,
559570});
......@@ -912,6 +923,11 @@ export const DesktopPreviewConfigInputSchema = Schema.Struct({
912923 environmentId: EnvironmentId,
913924});
914925
926export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({
927 tabId: DesktopPreviewTabIdSchema,
928 colorScheme: DesktopPreviewColorSchemeSchema,
929});
930
915931export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({
916932 theme: DesktopPreviewAnnotationThemeSchema,
917933});
......@@ -1034,6 +1050,11 @@ export interface DesktopPreviewBridge {
10341050 resetZoom: (tabId: string) => Promise<void>;
10351051 /** Reload bypassing the HTTP cache. */
10361052 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>;
10371058 /** Open the guest webview's DevTools (detached). */
10381059 openDevTools: (tabId: string) => Promise<void>;
10391060 /** 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 = [
4545export const PREVIEW_AUTOMATION_OPERATIONS = [
4646 ...PREVIEW_AUTOMATION_V1_OPERATIONS,
4747 "resize",
48 "setColorScheme",
4849] as const;
4950
5051export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS);
......@@ -254,6 +255,29 @@ export const PreviewAutomationResizeResult = Schema.Struct({
254255});
255256export 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
257281const Locator = TrimmedNonEmptyString.annotate({
258282 description:
259283 "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.",