From 6ee38494634c68bda722280550cf61e70213cdc5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 31 May 2026 16:28:22 +0200 Subject: [PATCH] fix: theme plugin slot iframes with host font + color tokens --- components/plugins/plugin-iframe-slot.tsx | 12 ++ lib/__tests__/host-theme.test.ts | 43 +++++++ lib/plugin-sandbox/host-bridge.ts | 8 ++ lib/plugin-sandbox/host-theme.ts | 131 ++++++++++++++++++++++ lib/plugin-sandbox/protocol.ts | 11 ++ lib/plugin-sandbox/runtime.tsx | 31 +++++ 6 files changed, 236 insertions(+) create mode 100644 lib/__tests__/host-theme.test.ts create mode 100644 lib/plugin-sandbox/host-theme.ts diff --git a/components/plugins/plugin-iframe-slot.tsx b/components/plugins/plugin-iframe-slot.tsx index 5bbdf2e8..6156f2a0 100644 --- a/components/plugins/plugin-iframe-slot.tsx +++ b/components/plugins/plugin-iframe-slot.tsx @@ -10,6 +10,8 @@ import React, { useEffect, useRef, useState } from 'react'; import type { SlotName } from '@/lib/plugin-types'; import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry'; import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge'; +import { snapshotHostTheme } from '@/lib/plugin-sandbox/host-theme'; +import { useThemeStore } from '@/stores/theme-store'; interface Props { pluginId: string; @@ -69,6 +71,16 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) { instanceRef.current?.updateProps(extraProps ?? {}); }, [extraProps]); + // Re-theme the live slot iframe when the host theme changes (dark/light + // toggle or custom theme switch), without tearing down the iframe. Reacting + // to resolvedTheme + activeThemeId covers both; the snapshot reads the + // resolved DOM values so it picks up whichever is active. + const resolvedTheme = useThemeStore((s) => s.resolvedTheme); + const activeThemeId = useThemeStore((s) => s.activeThemeId); + useEffect(() => { + instanceRef.current?.setTheme(snapshotHostTheme()); + }, [resolvedTheme, activeThemeId]); + if (show !== true) return null; return
; } diff --git a/lib/__tests__/host-theme.test.ts b/lib/__tests__/host-theme.test.ts new file mode 100644 index 00000000..4542480e --- /dev/null +++ b/lib/__tests__/host-theme.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { snapshotHostTheme, themeSnapshotToCSS, type ThemeSnapshot } from '../plugin-sandbox/host-theme'; + +describe('host-theme', () => { + describe('themeSnapshotToCSS', () => { + it('emits the token values, font, and color-scheme', () => { + const snapshot: ThemeSnapshot = { + dark: false, + fontFamily: 'Inter, sans-serif', + vars: { '--color-background': '#ffffff', '--color-foreground': '#0f172a' }, + }; + const css = themeSnapshotToCSS(snapshot); + expect(css).toContain('--color-background: #ffffff;'); + expect(css).toContain('--color-foreground: #0f172a;'); + expect(css).toContain('font-family: Inter, sans-serif;'); + expect(css).toContain('color-scheme: light;'); + // Body inherits the theme foreground so unstyled plugin text adapts. + expect(css).toContain('color: var(--color-foreground, inherit);'); + expect(css).toContain('background: transparent;'); + }); + + it('reports a dark color-scheme when dark', () => { + const css = themeSnapshotToCSS({ dark: true, fontFamily: 'sans-serif', vars: {} }); + expect(css).toContain('color-scheme: dark;'); + }); + }); + + describe('snapshotHostTheme', () => { + afterEach(() => { + document.documentElement.classList.remove('dark'); + document.documentElement.removeAttribute('style'); + }); + + it('reads the dark flag and declared tokens off ', () => { + document.documentElement.classList.add('dark'); + document.documentElement.style.setProperty('--color-background', '#0a0a0a'); + const snapshot = snapshotHostTheme(); + expect(snapshot.dark).toBe(true); + expect(snapshot.vars['--color-background']).toBe('#0a0a0a'); + expect(snapshot.fontFamily).toBeTruthy(); + }); + }); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index a0235ec8..15cb31d9 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -11,6 +11,7 @@ import type { InstalledPlugin, SlotName } from '../plugin-types'; import { dispatchApiCall } from './host-api'; import { SANDBOX_PATH } from './protocol'; import { withBasePath } from '../browser-navigation'; +import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; import type { SandboxToHost, HostToSandbox, InitMsg, InitPayload, } from './protocol'; @@ -278,6 +279,12 @@ export class SandboxInstance { this.send({ type: 'locale-change', locale }); } + /** Push a new resolved theme so the slot iframe re-injects its theme CSS. */ + setTheme(theme: ThemeSnapshot): void { + if (this.destroyed) return; + this.send({ type: 'theme-change', theme }); + } + updateProps(props: Record): void { if (this.destroyed) return; // Stale references would leak if we kept growing the table without @@ -346,6 +353,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance { }, extraProps: opts.extraProps, locale: opts.locale, + theme: snapshotHostTheme(), }; return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize); } diff --git a/lib/plugin-sandbox/host-theme.ts b/lib/plugin-sandbox/host-theme.ts new file mode 100644 index 00000000..7b1b0423 --- /dev/null +++ b/lib/plugin-sandbox/host-theme.ts @@ -0,0 +1,131 @@ +// Theme bridge for plugin slot iframes. +// +// Slot iframes run with an opaque ("null") origin, so they can't load the +// host's globals.css or web fonts cross-origin (see app/(sandbox)/layout.tsx). +// The result: plugin slot UIs fall back to the UA default serif font and have +// no knowledge of the host's light/dark (or custom) theme. +// +// To fix that without any cross-origin asset fetch, the host snapshots the +// *resolved* theme — the computed `--color-*` token values, the resolved +// font-family (which is a pure system-font stack, so no fetch is needed), and +// whether dark mode is active — and ships it across the postMessage bridge. +// The sandbox runtime replays the snapshot as an injected