fix: theme plugin slot iframes with host font + color tokens
This commit is contained in:
@@ -10,6 +10,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import type { SlotName } from '@/lib/plugin-types';
|
import type { SlotName } from '@/lib/plugin-types';
|
||||||
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
|
||||||
import { createSlotInstance, type SandboxInstance } from '@/lib/plugin-sandbox/host-bridge';
|
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 {
|
interface Props {
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
@@ -69,6 +71,16 @@ export function PluginIframeSlot({ pluginId, slot, extraProps }: Props) {
|
|||||||
instanceRef.current?.updateProps(extraProps ?? {});
|
instanceRef.current?.updateProps(extraProps ?? {});
|
||||||
}, [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;
|
if (show !== true) return null;
|
||||||
return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
|
return <div ref={wrapperRef} style={{ height, minHeight: height }} data-plugin-iframe-slot={`${pluginId}:${slot}`} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <html>', () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import type { InstalledPlugin, SlotName } from '../plugin-types';
|
|||||||
import { dispatchApiCall } from './host-api';
|
import { dispatchApiCall } from './host-api';
|
||||||
import { SANDBOX_PATH } from './protocol';
|
import { SANDBOX_PATH } from './protocol';
|
||||||
import { withBasePath } from '../browser-navigation';
|
import { withBasePath } from '../browser-navigation';
|
||||||
|
import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
|
||||||
import type {
|
import type {
|
||||||
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
|
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
|
||||||
} from './protocol';
|
} from './protocol';
|
||||||
@@ -278,6 +279,12 @@ export class SandboxInstance {
|
|||||||
this.send({ type: 'locale-change', locale });
|
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<string, unknown>): void {
|
updateProps(props: Record<string, unknown>): void {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
// Stale references would leak if we kept growing the table without
|
// Stale references would leak if we kept growing the table without
|
||||||
@@ -346,6 +353,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance {
|
|||||||
},
|
},
|
||||||
extraProps: opts.extraProps,
|
extraProps: opts.extraProps,
|
||||||
locale: opts.locale,
|
locale: opts.locale,
|
||||||
|
theme: snapshotHostTheme(),
|
||||||
};
|
};
|
||||||
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
|
return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <style> block plus a
|
||||||
|
// `.dark` class, so every plugin slot inherits the app font and can react to
|
||||||
|
// theme changes by reading `var(--color-*)`.
|
||||||
|
|
||||||
|
/** A replayable description of the host's currently resolved theme. */
|
||||||
|
export interface ThemeSnapshot {
|
||||||
|
/** Whether the host has dark mode active (`.dark` on <html>). */
|
||||||
|
dark: boolean;
|
||||||
|
/** Resolved font-family stack (system fonts only — safe to replay verbatim). */
|
||||||
|
fontFamily: string;
|
||||||
|
/** Resolved values for each mirrored CSS custom property. */
|
||||||
|
vars: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS custom properties mirrored into plugin slots. Kept in sync with the
|
||||||
|
// `:root` token block in app/globals.css. Both the colour tokens (the stable
|
||||||
|
// theming API surface) and the tier-2 typography/density tokens are included so
|
||||||
|
// plugins can match the host's metrics, not just its colours.
|
||||||
|
const THEME_TOKENS: readonly string[] = [
|
||||||
|
'--color-border',
|
||||||
|
'--color-input',
|
||||||
|
'--color-ring',
|
||||||
|
'--color-background',
|
||||||
|
'--color-foreground',
|
||||||
|
'--color-primary',
|
||||||
|
'--color-primary-foreground',
|
||||||
|
'--color-secondary',
|
||||||
|
'--color-secondary-foreground',
|
||||||
|
'--color-muted',
|
||||||
|
'--color-muted-foreground',
|
||||||
|
'--color-accent',
|
||||||
|
'--color-accent-foreground',
|
||||||
|
'--color-destructive',
|
||||||
|
'--color-destructive-foreground',
|
||||||
|
'--color-popover',
|
||||||
|
'--color-popover-foreground',
|
||||||
|
'--color-sidebar',
|
||||||
|
'--color-sidebar-foreground',
|
||||||
|
'--color-sidebar-border',
|
||||||
|
'--color-sidebar-accent',
|
||||||
|
'--color-sidebar-accent-foreground',
|
||||||
|
'--color-card',
|
||||||
|
'--color-card-foreground',
|
||||||
|
'--color-success',
|
||||||
|
'--color-success-foreground',
|
||||||
|
'--color-warning',
|
||||||
|
'--color-warning-foreground',
|
||||||
|
'--color-info',
|
||||||
|
'--color-info-foreground',
|
||||||
|
'--color-selection',
|
||||||
|
'--color-selection-foreground',
|
||||||
|
'--color-unread',
|
||||||
|
'--color-chart-1',
|
||||||
|
'--color-chart-2',
|
||||||
|
'--color-chart-3',
|
||||||
|
'--color-chart-4',
|
||||||
|
'--color-chart-5',
|
||||||
|
'--font-size-base',
|
||||||
|
'--list-item-height',
|
||||||
|
'--transition-duration',
|
||||||
|
'--density-item-py',
|
||||||
|
'--density-item-gap',
|
||||||
|
'--density-header-py',
|
||||||
|
'--density-card-p',
|
||||||
|
'--density-sidebar-py',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mirrors the body font stack in app/globals.css. Used when no document is
|
||||||
|
// available (SSR) or the body has no resolvable font-family yet.
|
||||||
|
const FALLBACK_FONT_FAMILY =
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans Thai", "Leelawadee UI", Tahoma, sans-serif';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the host's resolved theme off `<html>`. Reading computed styles means
|
||||||
|
* the snapshot automatically reflects the active built-in *and* custom theme,
|
||||||
|
* not just the static globals.css defaults.
|
||||||
|
*/
|
||||||
|
export function snapshotHostTheme(): ThemeSnapshot {
|
||||||
|
if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') {
|
||||||
|
return { dark: false, fontFamily: FALLBACK_FONT_FAMILY, vars: {} };
|
||||||
|
}
|
||||||
|
const root = document.documentElement;
|
||||||
|
const computed = getComputedStyle(root);
|
||||||
|
const vars: Record<string, string> = {};
|
||||||
|
for (const token of THEME_TOKENS) {
|
||||||
|
const value = computed.getPropertyValue(token).trim();
|
||||||
|
if (value) vars[token] = value;
|
||||||
|
}
|
||||||
|
const bodyFont = document.body ? getComputedStyle(document.body).fontFamily : '';
|
||||||
|
return {
|
||||||
|
dark: root.classList.contains('dark'),
|
||||||
|
fontFamily: bodyFont || FALLBACK_FONT_FAMILY,
|
||||||
|
vars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the CSS the sandbox injects to replay a snapshot. Pure (no DOM access)
|
||||||
|
* so it can run inside the iframe runtime. The `:root` block restores the
|
||||||
|
* host's token values; the `html, body` block gives plugin slots the app font
|
||||||
|
* and a theme-aware default text colour with a transparent background (the
|
||||||
|
* host's themed container shows through).
|
||||||
|
*/
|
||||||
|
export function themeSnapshotToCSS(snapshot: ThemeSnapshot): string {
|
||||||
|
const declarations = Object.entries(snapshot.vars)
|
||||||
|
.map(([name, value]) => ` ${name}: ${value};`)
|
||||||
|
.join('\n');
|
||||||
|
const colorScheme = snapshot.dark ? 'dark' : 'light';
|
||||||
|
return [
|
||||||
|
':root {',
|
||||||
|
declarations,
|
||||||
|
` color-scheme: ${colorScheme};`,
|
||||||
|
'}',
|
||||||
|
'html, body {',
|
||||||
|
` font-family: ${snapshot.fontFamily};`,
|
||||||
|
' color: var(--color-foreground, inherit);',
|
||||||
|
' background: transparent;',
|
||||||
|
'}',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
// class instances.
|
// class instances.
|
||||||
|
|
||||||
import type { SlotName } from '../plugin-types';
|
import type { SlotName } from '../plugin-types';
|
||||||
|
import type { ThemeSnapshot } from './host-theme';
|
||||||
|
|
||||||
// ─── Sandbox mode ────────────────────────────────────────────
|
// ─── Sandbox mode ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -62,6 +63,12 @@ export interface SlotInit {
|
|||||||
*/
|
*/
|
||||||
extraProps: Record<string, unknown>;
|
extraProps: Record<string, unknown>;
|
||||||
locale: string;
|
locale: string;
|
||||||
|
/**
|
||||||
|
* Resolved host theme (colour tokens, font stack, dark flag). The sandbox
|
||||||
|
* can't load globals.css/fonts cross-origin, so the runtime replays this as
|
||||||
|
* injected CSS + a `.dark` class. Host pushes updates via 'theme-change'.
|
||||||
|
*/
|
||||||
|
theme: ThemeSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InitPayload = BackgroundInit | SlotInit;
|
export type InitPayload = BackgroundInit | SlotInit;
|
||||||
@@ -163,6 +170,9 @@ export interface HookInvokeMsg {
|
|||||||
|
|
||||||
export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
|
export interface LocaleChangeMsg { type: 'locale-change'; locale: string; }
|
||||||
|
|
||||||
|
/** Host → sandbox: the resolved theme changed; re-inject the slot's theme CSS. */
|
||||||
|
export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; }
|
||||||
|
|
||||||
export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; }
|
export interface PropsUpdateMsg { type: 'props-update'; props: Record<string, unknown>; }
|
||||||
|
|
||||||
export interface SlotShouldShowMsg {
|
export interface SlotShouldShowMsg {
|
||||||
@@ -178,6 +188,7 @@ export type HostToSandbox =
|
|||||||
| CallbackResponseMsg
|
| CallbackResponseMsg
|
||||||
| HookInvokeMsg
|
| HookInvokeMsg
|
||||||
| LocaleChangeMsg
|
| LocaleChangeMsg
|
||||||
|
| ThemeChangeMsg
|
||||||
| PropsUpdateMsg
|
| PropsUpdateMsg
|
||||||
| SlotShouldShowMsg;
|
| SlotShouldShowMsg;
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
BackgroundInit,
|
BackgroundInit,
|
||||||
SlotInit,
|
SlotInit,
|
||||||
} from './protocol';
|
} from './protocol';
|
||||||
|
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
|
||||||
import type { SlotName } from '../plugin-types';
|
import type { SlotName } from '../plugin-types';
|
||||||
|
|
||||||
// ─── Module-scope state ──────────────────────────────────────
|
// ─── Module-scope state ──────────────────────────────────────
|
||||||
@@ -67,6 +68,28 @@ function sendToHost(msg: SandboxToHost): void {
|
|||||||
parentWindow.postMessage(msg, parentOrigin);
|
parentWindow.postMessage(msg, parentOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Theme replay ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const THEME_STYLE_ID = '__plugin_host_theme';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replay a host theme snapshot inside the iframe: inject the token + font CSS
|
||||||
|
* and mirror the `.dark` class onto <html> so plugin styles that key off
|
||||||
|
* `.dark` (or read `var(--color-*)`) behave like the host. Idempotent — safe
|
||||||
|
* to call again on every 'theme-change'.
|
||||||
|
*/
|
||||||
|
function applyHostTheme(theme: ThemeSnapshot): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
let styleEl = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null;
|
||||||
|
if (!styleEl) {
|
||||||
|
styleEl = document.createElement('style');
|
||||||
|
styleEl.id = THEME_STYLE_ID;
|
||||||
|
document.head.appendChild(styleEl);
|
||||||
|
}
|
||||||
|
styleEl.textContent = themeSnapshotToCSS(theme);
|
||||||
|
document.documentElement.classList.toggle('dark', theme.dark);
|
||||||
|
}
|
||||||
|
|
||||||
function uid(): string {
|
function uid(): string {
|
||||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||||
}
|
}
|
||||||
@@ -310,6 +333,10 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bootSlot(payload: SlotInit): void {
|
function bootSlot(payload: SlotInit): void {
|
||||||
|
// Replay the host theme before first paint so the slot never flashes the UA
|
||||||
|
// default serif font or a light-on-light/dark mismatch.
|
||||||
|
applyHostTheme(payload.theme);
|
||||||
|
|
||||||
const api = buildPluginApi(payload.manifest);
|
const api = buildPluginApi(payload.manifest);
|
||||||
const exports = evaluateBundle(payload.code, api);
|
const exports = evaluateBundle(payload.code, api);
|
||||||
pluginExports = exports;
|
pluginExports = exports;
|
||||||
@@ -462,6 +489,10 @@ function handleHostMessage(ev: MessageEvent): void {
|
|||||||
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
|
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = msg.locale;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'theme-change':
|
||||||
|
applyHostTheme(msg.theme);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'props-update':
|
case 'props-update':
|
||||||
slotPropsUpdater?.(msg.props ?? {});
|
slotPropsUpdater?.(msg.props ?? {});
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user