Skip to content

Commit 0a7a096

Browse files
jonathanKingstonJonathan Kingston
andauthored
Match transparent canvas backgrounds to the active theme (#2700)
Transparent inline canvases were composited against different surfaces: the visible webview exposed Copse's active theme, while the hidden agent mirror defaulted to white. A dark-theme canvas could therefore disagree with its preview even though both loaded the same artifact bytes. This change samples Copse's resolved body background (including UI tint) and uses it as the hidden BrowserWindow's compositor backdrop. The visible webview host explicitly uses the same base theme surface. Artifact CSS remains authoritative, so an artifact that paints its own background still overrides the default; a fully transparent sampled color falls back to the resolved boot theme. The focused Electron regression emits both a transparent artifact and one with an explicit background. It checks the preview pixel against the active tinted theme, checks the open canvas host against the app surface, verifies the explicit artifact color wins, and saves the transparent dark-theme state for review. Validation: - `pnpm test -- canvas-agent-mirror` — 11 passed - `pnpm run build` — passed - Typecheck, type-aware lint, formatting, demo-site consistency, and dead-code analysis — passed after the final edits - `pnpm run check:oracle` — 262 specs live, 15 invariants passed - `pnpm run check:e2e-syntax` — 289 files parsed cleanly The full `pnpm run check` could not complete its unit phase in this command sandbox: unrelated watcher tests receive `EMFILE`, fixed `/tmp` paths receive `EPERM`, and a later durable rerun stalled behind the inaccessible orphaned test process. The focused local Electron spec also stopped before Copse launched because Chromium could not create its extension-unpack temp directory. CI should run `tests/e2e/canvas-background-parity.e2e.ts` and publish the visual evidence. Co-Authored-By: Copse <noreply@copse.dev> Copse-Models: acp:codex-acp#gpt-5.6-sol, acp:claude-acp#claude-fable-5-1[1m] --------- Co-authored-by: Jonathan Kingston <KingstonMailBox@gmail.com>
1 parent d455e23 commit 0a7a096

7 files changed

Lines changed: 329 additions & 17 deletions

File tree

‎src/main/index.ts‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { attachBrowserGuestContextMenu } from './windows/browser-context-menu.ts'
3030
import { applyAppIcon } from './app-icon.ts'
3131
import type { LLMMessage, StreamChunk } from '@shared/types'
32+
import { THEME_BACKGROUND } from '@shared/theme.ts'
3233
import {
3334
assertPrimaryMainWindow,
3435
beginMainWindowQuit,
@@ -37,6 +38,7 @@ import {
3738
getMainWindow,
3839
getRestorableMainWindowRecords,
3940
} from './windows/create-main-window.ts'
41+
import { readBootTheme } from './windows/boot-theme.ts'
4042
import { setShellOutputSink } from './services/exec/shell-output-context.ts'
4143
import { setSecretCipher } from './services/storage/secret-cipher.ts'
4244
import { createKeyringCipher, createMigratingCipher } from './services/storage/keyring-cipher.ts'
@@ -301,9 +303,39 @@ setCanvasArtefactSink((artefact) => {
301303
})
302304
})
303305

306+
async function currentCanvasBackgroundColor(): Promise<string> {
307+
const fallback = THEME_BACKGROUND[readBootTheme()]
308+
const win = getMainWindow()
309+
if (!win || win.isDestroyed()) return fallback
310+
try {
311+
const value: unknown = await win.webContents.executeJavaScript(
312+
`(() => {
313+
const canvas = document.createElement('canvas')
314+
canvas.width = 1
315+
canvas.height = 1
316+
const context = canvas.getContext('2d')
317+
if (!context) return ''
318+
context.fillStyle = getComputedStyle(document.body).backgroundColor
319+
context.fillRect(0, 0, 1, 1)
320+
const [red, green, blue, alpha] = context.getImageData(0, 0, 1, 1).data
321+
if (alpha === 0) return ''
322+
return 'rgba(' + [red, green, blue, alpha / 255].join(', ') + ')'
323+
})()`,
324+
true,
325+
)
326+
return typeof value === 'string' && value.trim() ? value : fallback
327+
} catch {
328+
return fallback
329+
}
330+
}
331+
304332
// Load every artefact into the headless agent session as well, so the model can
305333
// snapshot and screenshot the canvas it just rendered instead of working blind.
306-
setCanvasArtefactMirror((artefact) => mirrorArtefactToAgent(artefact, getBrowserSession()))
334+
// The preview window is otherwise white by default, while the visible webview
335+
// exposes Copse's theme through a transparent artefact.
336+
setCanvasArtefactMirror(async (artefact) =>
337+
mirrorArtefactToAgent(artefact, getBrowserSession(), await currentCanvasBackgroundColor()),
338+
)
307339

308340
setContextEstimateRefreshSink(() => {
309341
const win = getMainWindow()

‎src/main/services/browser/session-manager.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ export interface NavigateResult {
4747
url: string
4848
}
4949

50+
export interface BrowserNavigateOptions {
51+
newTab?: boolean | undefined
52+
viewId?: string | undefined
53+
/** Backdrop used when a page leaves its root transparent. */
54+
backgroundColor?: string | undefined
55+
}
56+
5057
export interface TabInfo {
5158
viewId: string
5259
title: string
@@ -69,7 +76,7 @@ export class BrowserSessionManager {
6976
private lastActiveId: string | null = null
7077
private counter = 0
7178

72-
private createTab(): Tab {
79+
private createTab(backgroundColor?: string): Tab {
7380
if (this.tabs.length >= MAX_TABS) {
7481
throw new Error(`browser tab limit reached (${String(MAX_TABS)}); close a tab first`)
7582
}
@@ -79,6 +86,7 @@ export class BrowserSessionManager {
7986
show: false,
8087
width: DEFAULT_WIDTH,
8188
height: DEFAULT_HEIGHT,
89+
...(backgroundColor ? { backgroundColor } : {}),
8290
webPreferences: {
8391
// Dedicated agent browser profile, isolated from the user's interactive
8492
// browser pane, so automation never inherits the user's logged-in
@@ -116,12 +124,10 @@ export class BrowserSessionManager {
116124
return this.createTab()
117125
}
118126

119-
async navigate(
120-
url: string,
121-
opts?: { newTab?: boolean | undefined; viewId?: string | undefined },
122-
): Promise<NavigateResult> {
123-
const tab = opts?.newTab ? this.createTab() : this.resolveTab(opts?.viewId)
127+
async navigate(url: string, opts?: BrowserNavigateOptions): Promise<NavigateResult> {
128+
const tab = opts?.newTab ? this.createTab(opts.backgroundColor) : this.resolveTab(opts?.viewId)
124129
this.lastActiveId = tab.id
130+
if (opts?.backgroundColor) tab.window.setBackgroundColor(opts.backgroundColor)
125131
try {
126132
await tab.window.webContents.loadURL(url)
127133
} catch (err) {

‎src/main/services/canvas-agent-mirror.test.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ function artefact(overrides: Partial<CanvasArtefact> = {}): CanvasArtefact {
1010

1111
interface Call {
1212
url: string
13-
opts?: { newTab?: boolean | undefined; viewId?: string | undefined } | undefined
13+
opts?:
14+
| {
15+
newTab?: boolean | undefined
16+
viewId?: string | undefined
17+
backgroundColor?: string | undefined
18+
}
19+
| undefined
1420
}
1521

1622
function session(
@@ -45,15 +51,28 @@ describe('mirrorArtefactToAgent', () => {
4551
assert.match(s.calls[0].url, /^data:text\/html;charset=utf-8;base64,/)
4652
})
4753

54+
it('uses the live canvas background when capturing transparent artefacts', async () => {
55+
const s = session()
56+
await mirrorArtefactToAgent(artefact(), s, 'rgb(17, 29, 23)')
57+
58+
assert.deepEqual(s.calls[0]?.opts, {
59+
newTab: true,
60+
backgroundColor: 'rgb(17, 29, 23)',
61+
})
62+
})
63+
4864
it('reuses the tab for a re-render of the same title', async () => {
4965
const s = session()
50-
await mirrorArtefactToAgent(artefact(), s)
51-
await mirrorArtefactToAgent(artefact({ body: '<h1>v2</h1>' }), s)
66+
await mirrorArtefactToAgent(artefact(), s, 'rgb(17, 29, 23)')
67+
await mirrorArtefactToAgent(artefact({ body: '<h1>v2</h1>' }), s, 'rgb(17, 29, 23)')
5268

5369
assert.equal(s.calls.length, 2)
5470
const [v1, v2] = s.calls
5571
assert.ok(v1 && v2)
56-
assert.deepEqual(v2.opts, { viewId: 'tab-1' })
72+
assert.deepEqual(v2.opts, {
73+
viewId: 'tab-1',
74+
backgroundColor: 'rgb(17, 29, 23)',
75+
})
5776
assert.notEqual(v1.url, v2.url)
5877
})
5978

‎src/main/services/canvas-agent-mirror.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@
1919
import type { CanvasArtefact } from '@shared/types/canvas.ts'
2020
import { artefactUrl } from '@shared/canvas/artefact.ts'
2121

22+
interface CanvasMirrorNavigateOptions {
23+
newTab?: boolean | undefined
24+
viewId?: string | undefined
25+
backgroundColor?: string | undefined
26+
}
27+
2228
export interface CanvasMirrorSession {
23-
navigate(
24-
url: string,
25-
opts?: { newTab?: boolean | undefined; viewId?: string | undefined },
26-
): Promise<{ viewId: string }>
29+
navigate(url: string, opts?: CanvasMirrorNavigateOptions): Promise<{ viewId: string }>
2730
/** A small PNG `data:` URL of the tab, or null when capture is unavailable. */
2831
capturePreview(viewId: string): Promise<string | null>
2932
}
@@ -54,6 +57,7 @@ export function resetCanvasAgentMirrorForTest(): void {
5457
export async function mirrorArtefactToAgent(
5558
artefact: CanvasArtefact,
5659
session: CanvasMirrorSession,
60+
backgroundColor?: string,
5761
): Promise<string | null> {
5862
// `text/uri-list` is supplied by an MCP server and may name any external
5963
// origin. Navigating it here would bypass the approval that guards
@@ -69,10 +73,12 @@ export async function mirrorArtefactToAgent(
6973
}
7074
const key = artefactKey(artefact.threadId, artefact.title)
7175
const known = viewIdByArtefact.get(key)
76+
const navigation: CanvasMirrorNavigateOptions = known ? { viewId: known } : { newTab: true }
77+
if (backgroundColor) navigation.backgroundColor = backgroundColor
7278

7379
let viewId: string
7480
try {
75-
viewId = (await session.navigate(url, known ? { viewId: known } : { newTab: true })).viewId
81+
viewId = (await session.navigate(url, navigation)).viewId
7682
} catch {
7783
// The remembered tab is gone — the agent closed it via `browser_tabs`, or
7884
// the session was torn down. Forget it and try once more in a fresh tab.
@@ -81,7 +87,9 @@ export async function mirrorArtefactToAgent(
8187
if (!known) return null
8288
viewIdByArtefact.delete(key)
8389
try {
84-
viewId = (await session.navigate(url, { newTab: true })).viewId
90+
const retry: CanvasMirrorNavigateOptions = { newTab: true }
91+
if (backgroundColor) retry.backgroundColor = backgroundColor
92+
viewId = (await session.navigate(url, retry)).viewId
8593
} catch {
8694
return null
8795
}

‎src/renderer/styles/global/layout.css‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2052,6 +2052,9 @@
20522052
flex-direction: column;
20532053
min-height: 0;
20542054
overflow: hidden;
2055+
/* Transparent canvas artefacts expose this surface. The headless mirror
2056+
captures against the resolved value of the same token. */
2057+
background: var(--bg-base);
20552058
-webkit-app-region: no-drag;
20562059
position: relative;
20572060
}

0 commit comments

Comments
 (0)