Skip to content

Commit 473c11e

Browse files
juliusmarmingecodexclaude
authored andcommitted
Add preview color scheme controls and simplify project grouping (pingdotgg#4385)
Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> (cherry picked from commit 79fe11b)
1 parent d1e028b commit 473c11e

16 files changed

Lines changed: 288 additions & 3 deletions

File tree

apps/desktop/src/ipc/channels.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in";
5454
export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out";
5555
export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom";
5656
export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload";
57+
export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme";
5758
export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools";
5859
export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies";
5960
export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache";

apps/desktop/src/ipc/methods/preview.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
);
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+
});
141151
export 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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
164164
zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }),
165165
resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }),
166166
hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }),
167+
setColorScheme: (tabId, colorScheme) =>
168+
ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }),
167169
openDevTools: (tabId) =>
168170
ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }),
169171
clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL),

apps/desktop/src/preview/Manager.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
import 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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

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+
89104
export 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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,
@@ -457,6 +459,15 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
457459
viewport,
458460
} satisfies PreviewAutomationResizeResult;
459461
}
462+
case "setColorScheme": {
463+
const ready = await requireReadyTab();
464+
const input = request.input as PreviewAutomationSetColorSchemeInput;
465+
await ready.bridge.setColorScheme(ready.tabId, input.colorScheme);
466+
return {
467+
tabId: ready.tabId,
468+
colorScheme: input.colorScheme,
469+
} satisfies PreviewAutomationSetColorSchemeResult;
470+
}
460471
case "snapshot": {
461472
const ready = await requireReadyTab();
462473
return await ready.bridge.automation.snapshot(ready.tabId);

0 commit comments

Comments
 (0)