From 9d7dc4fc4eb3720a9c6cfc678a4f379bcbf03c06 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:02:40 +0200 Subject: [PATCH] fix: stop plugin frames from painting a white backdrop in dark themes --- components/plugins/plugin-iframe-slot.tsx | 4 ++++ .../plugin-host-bridge-origin.test.ts | 22 +++++++++++++++++++ lib/plugin-sandbox/host-bridge.ts | 6 +++++ 3 files changed, 32 insertions(+) diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index d2dc4de56..0eda9b2ec 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -95,6 +95,10 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { const activeThemeId = useThemeStore((s) => s.activeThemeId); useEffect(() => { instanceRef.current?.setTheme(snapshotHostTheme()); + // The store may apply the new theme to the page after this render: read + // it again a frame later, so the frame never keeps the old values. + const frame = requestAnimationFrame(() => instanceRef.current?.setTheme(snapshotHostTheme())); + return () => cancelAnimationFrame(frame); }, [resolvedTheme, activeThemeId]); if (show !== true) return null; diff --git a/lib/__tests__/plugin-host-bridge-origin.test.ts b/lib/__tests__/plugin-host-bridge-origin.test.ts index f4d92d739..74a474139 100644 --- a/lib/__tests__/plugin-host-bridge-origin.test.ts +++ b/lib/__tests__/plugin-host-bridge-origin.test.ts @@ -162,3 +162,25 @@ describe('plugin frame permissions', () => { } }); }); + +describe('plugin frame color scheme', () => { + // A frame whose document says dark while the iframe element says light gets + // an opaque white canvas from the browser: dark text tokens on white. + it('gives the iframe element the color-scheme the frame declares, at start and on a theme change', () => { + document.documentElement.classList.add('dark'); + try { + const inst = createSlotInstance({ + plugin: plugin(), slot: 'plugin-dialog', code: '', locale: 'en', tier: 'untrusted', extraProps: {}, + hostContainer: document.body, onResize: () => {}, + }); + instances.push(inst); + expect(inst.iframe.style.colorScheme).toBe('dark'); + inst.setTheme({ dark: false, fontFamily: 'sans-serif', vars: {} }); + expect(inst.iframe.style.colorScheme).toBe('light'); + inst.setTheme({ dark: true, fontFamily: 'sans-serif', vars: {} }); + expect(inst.iframe.style.colorScheme).toBe('dark'); + } finally { + document.documentElement.classList.remove('dark'); + } + }); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 1adcb1808..014f1ef0a 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -172,6 +172,11 @@ export class SandboxInstance { this.iframe.setAttribute('allow', 'clipboard-write'); this.iframe.title = `plugin-${plugin.id}-${initPayload.mode}`; this.iframe.style.border = 'none'; + // The frame's document declares the theme's color-scheme (themeSnapshotToCSS). + // When the iframe element's own color-scheme differs, the browser paints + // an opaque canvas behind the frame (white in a dark theme), so the two + // are always set together. + if (initPayload.mode === 'slot') this.iframe.style.colorScheme = initPayload.theme.dark ? 'dark' : 'light'; this.iframe.style.display = 'block'; if (initPayload.mode === 'background') { this.iframe.style.position = 'absolute'; @@ -377,6 +382,7 @@ export class SandboxInstance { /** Push a new resolved theme so the slot iframe re-injects its theme CSS. */ setTheme(theme: ThemeSnapshot): void { if (this.destroyed) return; + this.iframe.style.colorScheme = theme.dark ? 'dark' : 'light'; this.send({ type: 'theme-change', theme }); }