diff --git a/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.test.tsx b/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.test.tsx
index efbadfa48e49..b9c217c1d867 100644
--- a/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.test.tsx
+++ b/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.test.tsx
@@ -1,5 +1,12 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { useThemeStore } from "@posthog/ui/shell/themeStore";
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { BuiltCanvas } from "./BuiltCanvas";
describe("BuiltCanvas", () => {
@@ -7,6 +14,8 @@ describe("BuiltCanvas", () => {
posthog: { insights: [], inlineQueries: false, captureEvents: [] },
network: { origins: [] },
};
+ const initialIsDarkMode = useThemeStore.getState().isDarkMode;
+ afterEach(() => useThemeStore.setState({ isDarkMode: initialIsDarkMode }));
it("loads an immutable artifact without granting origin or popup access", () => {
render(
@@ -20,7 +29,7 @@ describe("BuiltCanvas", () => {
const hostDocument = screen.getByTitle("Canvas").getAttribute("srcdoc");
expect(hostDocument).toContain("frame-src https://usercontent.example");
expect(hostDocument).toContain(
- 'artifactFrame.src = "https://usercontent.example/build/index.html"',
+ 'artifactFrame.src = "https://usercontent.example/build/index.html#theme=light"',
);
expect(hostDocument).not.toContain("frame-src *");
expect(screen.getByTitle("Canvas")).toHaveAttribute(
@@ -100,9 +109,10 @@ describe("BuiltCanvas", () => {
][];
const canvasPort = calls.at(-1)?.[2]?.[0] as MessagePort;
const responses: unknown[] = [];
- canvasPort.addEventListener("message", (event) =>
- responses.push((event as MessageEvent).data),
- );
+ canvasPort.addEventListener("message", (event) => {
+ const data = (event as MessageEvent).data as { type?: string };
+ if (data?.type === "data-response") responses.push(data);
+ });
canvasPort.start();
canvasPort.postMessage({
@@ -122,4 +132,50 @@ describe("BuiltCanvas", () => {
});
expect(onDataRequest).not.toHaveBeenCalled();
});
+
+ it("mirrors the host theme over the artifact bridge", async () => {
+ act(() => useThemeStore.setState({ isDarkMode: true }));
+ render(
+ ,
+ );
+ const iframe = screen.getByTitle("Canvas") as HTMLIFrameElement;
+ if (!iframe.contentWindow) throw new Error("Canvas iframe has no window");
+ expect(iframe.getAttribute("srcdoc")).toContain("#theme=dark");
+ expect(iframe.style.colorScheme).toBe("dark");
+ const postMessage = vi
+ .spyOn(iframe.contentWindow, "postMessage")
+ .mockImplementation(() => undefined);
+
+ fireEvent.load(iframe);
+ const calls = postMessage.mock.calls as unknown as [
+ unknown,
+ string,
+ Transferable[],
+ ][];
+ const canvasPort = calls.at(-1)?.[2]?.[0] as MessagePort;
+ const frames: unknown[] = [];
+ canvasPort.addEventListener("message", (event) =>
+ frames.push((event as MessageEvent).data),
+ );
+ canvasPort.start();
+
+ await waitFor(() => expect(frames).toHaveLength(1));
+ expect(frames[0]).toEqual({
+ channel: "posthog-canvas",
+ type: "set-theme",
+ theme: "dark",
+ });
+
+ act(() => useThemeStore.setState({ isDarkMode: false }));
+ await waitFor(() => expect(frames).toHaveLength(2));
+ expect(frames[1]).toEqual({
+ channel: "posthog-canvas",
+ type: "set-theme",
+ theme: "light",
+ });
+ });
});
diff --git a/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.tsx b/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.tsx
index 865393d7c5b9..15d89d2939d6 100644
--- a/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.tsx
+++ b/products/desktop/packages/ui/src/features/canvas/freeform/BuiltCanvas.tsx
@@ -1,19 +1,30 @@
import { assertCanvasCapability } from "@posthog/core/canvas/canvasCapabilities";
import {
type CanvasNavIntent,
+ type CanvasTheme,
canvasToHostMessageSchema,
} from "@posthog/core/canvas/freeformSchemas";
import type { CanvasCapabilities } from "@posthog/shared";
import { logger } from "@posthog/ui/shell/logger";
import { openExternalUrl } from "@posthog/ui/shell/openExternal";
-import { useLayoutEffect, useRef } from "react";
+import { useThemeStore } from "@posthog/ui/shell/themeStore";
+import { useEffect, useLayoutEffect, useRef } from "react";
import { createCanvasHostMessageRouter } from "./canvasHostMessageRouter";
const log = logger.scope("built-canvas");
-function buildArtifactHostDocument(artifactUrl: string): string {
+function buildArtifactHostDocument(
+ artifactUrl: string,
+ theme: CanvasTheme,
+): string {
const artifactOrigin = new URL(artifactUrl).origin;
- const serializedArtifactUrl = JSON.stringify(artifactUrl).replaceAll(
+ // The theme rides the fragment so the artifact runtime (a synchronous head
+ // script) applies `.dark` before first paint — the bridge port only connects
+ // at the load event, far too late to prevent a light flash. Fragments don't
+ // reach the server, so signed artifact URLs stay valid.
+ const themedUrl = new URL(artifactUrl);
+ themedUrl.hash = `theme=${theme}`;
+ const serializedArtifactUrl = JSON.stringify(themedUrl.href).replaceAll(
"<",
"\\u003c",
);
@@ -93,7 +104,16 @@ export function BuiltCanvas({
onNavigate,
}: BuiltCanvasProps) {
const iframeRef = useRef(null);
- const hostDocument = buildArtifactHostDocument(artifactUrl);
+ // Mirrors the host's light/dark theme, like FreeformCanvas — sent over the
+ // artifact bridge port right after connect and again on every change.
+ const theme = useThemeStore(
+ (s): CanvasTheme => (s.isDarkMode ? "dark" : "light"),
+ );
+ const artifactPortRef = useRef(null);
+ // The srcDoc bakes in the mount-time theme only — folding the live theme in
+ // would reload the artifact on every toggle. Live changes go over the port.
+ const initialTheme = useRef(theme).current;
+ const hostDocument = buildArtifactHostDocument(artifactUrl, initialTheme);
const latest = useRef({
capabilities,
onDataRequest,
@@ -101,6 +121,7 @@ export function BuiltCanvas({
onReady,
onRendered,
onNavigate,
+ theme,
});
latest.current = {
capabilities,
@@ -109,15 +130,15 @@ export function BuiltCanvas({
onReady,
onRendered,
onNavigate,
+ theme,
};
// biome-ignore lint/correctness/useExhaustiveDependencies: a new host document needs a fresh bridge even though the effect reads it only through the iframe.
useLayoutEffect(() => {
const iframe = iframeRef.current;
- let artifactPort: MessagePort | null = null;
const route = createCanvasHostMessageRouter({
- post: (message) => artifactPort?.postMessage(message),
+ post: (message) => artifactPortRef.current?.postMessage(message),
callbacks: () => ({
onDataRequest: (method, payload) => {
// Gating lives here so every consumer of BuiltCanvas gets it by
@@ -149,16 +170,23 @@ export function BuiltCanvas({
};
const onLoad = () => {
- if (artifactPort) return;
+ if (artifactPortRef.current) return;
const bridge = new MessageChannel();
- artifactPort = bridge.port1;
- artifactPort.addEventListener("message", onMessage);
- artifactPort.start();
+ artifactPortRef.current = bridge.port1;
+ artifactPortRef.current.addEventListener("message", onMessage);
+ artifactPortRef.current.start();
iframe?.contentWindow?.postMessage(
{ channel: "posthog-canvas-host", type: "connect" },
"*",
[bridge.port2],
);
+ // Queued on the port until the artifact runtime starts it, so the first
+ // themed paint happens before any data renders.
+ artifactPortRef.current.postMessage({
+ channel: "posthog-canvas",
+ type: "set-theme",
+ theme: latest.current.theme,
+ });
};
const onHostMessage = (event: MessageEvent) => {
@@ -169,8 +197,8 @@ export function BuiltCanvas({
) {
return;
}
- artifactPort?.close();
- artifactPort = null;
+ artifactPortRef.current?.close();
+ artifactPortRef.current = null;
};
iframe?.addEventListener("load", onLoad);
@@ -178,10 +206,21 @@ export function BuiltCanvas({
return () => {
iframe?.removeEventListener("load", onLoad);
window.removeEventListener("message", onHostMessage);
- artifactPort?.close();
+ artifactPortRef.current?.close();
+ artifactPortRef.current = null;
};
}, [hostDocument]);
+ // Live theme change: re-theme the running artifact without reloading it. On
+ // mount the port is still null — the initial theme goes out in onLoad above.
+ useEffect(() => {
+ artifactPortRef.current?.postMessage({
+ channel: "posthog-canvas",
+ type: "set-theme",
+ theme,
+ });
+ }, [theme]);
+
return (
);