mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ui): match ClickClack discussion sidebars to host themes (#114812)
* feat(ui): align ClickClack discussion themes * fix(ui): synchronize custom discussion palettes from first paint * fix(clickclack): explicitly identify theme-aware discussion embeds
This commit is contained in:
committed by
GitHub
parent
1292262996
commit
56d9d07516
@@ -106,6 +106,11 @@ defaults to the account workspace, and `section` defaults to `Sessions`.
|
||||
`controlUrlBase` adds a link back to `/chat?session=<session-key>` in the
|
||||
OpenClaw Control UI.
|
||||
|
||||
ClickClack-managed embed URLs explicitly advertise host-theme support. The
|
||||
Control UI uses that provider-owned capability to apply its full palette before
|
||||
the sidebar first paints and to stream live palette changes without rewriting
|
||||
opaque or signed discussion URLs from other providers.
|
||||
|
||||
Enable discussions on exactly one ClickClack account. Multiple enabled
|
||||
discussion accounts are rejected because the session discussion provider does
|
||||
not have an account selector.
|
||||
|
||||
@@ -52,7 +52,9 @@ export function discussionInfoForBinding(
|
||||
const baseUrl = normalizedServerBaseUrl(account);
|
||||
return {
|
||||
state: "open",
|
||||
embedUrl: `${baseUrl}/embed/channel/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}`,
|
||||
// Only this provider may opt into host-owned theme parameters; signed
|
||||
// discussion URLs from other providers must remain opaque.
|
||||
embedUrl: `${baseUrl}/embed/channel/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}?openclawHostTheme=1`,
|
||||
openUrl: `${baseUrl}/app/${encodeURIComponent(binding.workspaceRouteId)}/${encodeURIComponent(binding.channelRouteId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,7 +140,8 @@ describe("ClickClack discussion service", () => {
|
||||
|
||||
expect(opened).toEqual({
|
||||
state: "open",
|
||||
embedUrl: "https://clickclack.example/embed/channel/team-route/discussion-route",
|
||||
embedUrl:
|
||||
"https://clickclack.example/embed/channel/team-route/discussion-route?openclawHostTheme=1",
|
||||
openUrl: "https://clickclack.example/app/team-route/discussion-route",
|
||||
});
|
||||
expect(reopened).toEqual(opened);
|
||||
@@ -595,7 +596,8 @@ describe("ClickClack discussion service", () => {
|
||||
);
|
||||
expect(opened).toEqual({
|
||||
state: "open",
|
||||
embedUrl: "https://clickclack.example/embed/channel/team-route/recovered-route",
|
||||
embedUrl:
|
||||
"https://clickclack.example/embed/channel/team-route/recovered-route?openclawHostTheme=1",
|
||||
openUrl: "https://clickclack.example/app/team-route/recovered-route",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,4 +109,125 @@ describeControlUiE2e("session discussion toggle", () => {
|
||||
await page.screenshot({ path: path.join(proofDir, "discussion-closed.png") });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a cross-origin discussion in sync when the real host color scheme changes", async () => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: "light",
|
||||
...(captureUiProof
|
||||
? { recordVideo: { dir: proofDir, size: { height: 720, width: 1280 } } }
|
||||
: {}),
|
||||
viewport: { height: 720, width: 1280 },
|
||||
});
|
||||
openContexts.add(context);
|
||||
const page = await context.newPage();
|
||||
const sessionKey = "agent:main:discussion-theme-proof";
|
||||
|
||||
await page.route("https://discussion.example/embed/channel/**", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "text/html; charset=utf-8",
|
||||
body: `<!doctype html><html><head><meta charset="utf-8">
|
||||
<style>
|
||||
:root { font: 14px system-ui, sans-serif; }
|
||||
body { margin: 0; padding: 20px; background: var(--host-surface, #fff);
|
||||
color: var(--host-text, #18181b); }
|
||||
article { padding: 16px; border: 1px solid var(--host-border, #e4e4e7);
|
||||
border-radius: 8px; background: var(--host-card, #fff); }
|
||||
</style></head><body><article><h2>Cross-origin discussion</h2>
|
||||
<p>The sidebar follows its OpenClaw host.</p></article>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
document.documentElement.dataset.hostMode = params.get("theme") || "dark";
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== parent || event.origin !== params.get("hostOrigin")) return;
|
||||
if (event.data?.type !== "openclaw:widget-theme") return;
|
||||
document.documentElement.dataset.hostMode = event.data.mode;
|
||||
for (const [token, value] of Object.entries(event.data.tokens || {})) {
|
||||
if (typeof value === "string") {
|
||||
document.documentElement.style.setProperty("--host-" + token, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script></body></html>`,
|
||||
}),
|
||||
);
|
||||
|
||||
const gateway = await installMockGateway(page, {
|
||||
featureMethods: ["session.discussion.info", "session.discussion.open"],
|
||||
historyMessages: [
|
||||
{
|
||||
content: [{ type: "text", text: "Cross-origin theme proof." }],
|
||||
role: "assistant",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
methodResponses: {
|
||||
"session.discussion.info": { state: "available" },
|
||||
"session.discussion.open": {
|
||||
embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1",
|
||||
openUrl: "https://discussion.example/app/T1/C1",
|
||||
state: "open",
|
||||
},
|
||||
},
|
||||
sessionKey,
|
||||
});
|
||||
|
||||
await page.goto(controlUiSessionUrl(server.baseUrl, sessionKey));
|
||||
await gateway.waitForRequest("session.discussion.info");
|
||||
await page.getByRole("button", { name: "Show discussion" }).click();
|
||||
|
||||
const frameElement = page.locator("iframe.session-discussion__frame");
|
||||
await expect.poll(() => frameElement.count()).toBe(1);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.frames()
|
||||
.some((candidate) =>
|
||||
candidate.url().startsWith("https://discussion.example/embed/channel/"),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
const frame = page
|
||||
.frames()
|
||||
.find((candidate) => candidate.url().startsWith("https://discussion.example/embed/channel/"));
|
||||
expect(frame).toBeDefined();
|
||||
|
||||
const frameUrl = new URL(frame!.url());
|
||||
expect(frameUrl.searchParams.get("theme")).toBe("light");
|
||||
expect(frameUrl.searchParams.get("hostOrigin")).toBe(new URL(server.baseUrl).origin);
|
||||
await expect.poll(() => frame!.locator("html").getAttribute("data-host-mode")).toBe("light");
|
||||
await expect
|
||||
.poll(() =>
|
||||
frame!.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--host-surface").trim(),
|
||||
),
|
||||
)
|
||||
.not.toBe("");
|
||||
if (captureUiProof) {
|
||||
await page.screenshot({ path: path.join(proofDir, "discussion-theme-light.png") });
|
||||
}
|
||||
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.dataset.themeMode))
|
||||
.toBe("dark");
|
||||
await expect.poll(() => frame!.locator("html").getAttribute("data-host-mode")).toBe("dark");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [hostSurface, embeddedSurface] = await Promise.all([
|
||||
page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--bg").trim(),
|
||||
),
|
||||
frame!.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--host-surface").trim(),
|
||||
),
|
||||
]);
|
||||
return { hostSurface, embeddedSurface };
|
||||
})
|
||||
.toEqual({ hostSurface: "#0e1015", embeddedSurface: "#0e1015" });
|
||||
expect(new URL(frame!.url()).searchParams.get("theme")).toBe("light");
|
||||
if (captureUiProof) {
|
||||
await page.screenshot({ path: path.join(proofDir, "discussion-theme-dark.png") });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,8 +22,25 @@ const panels: DiscussionPanelElement[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
panels.splice(0).forEach((panel) => panel.remove());
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
document.documentElement.removeAttribute("data-theme-mode");
|
||||
document.documentElement.removeAttribute("style");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function expectedEmbedUrl(url: string, mode: "light" | "dark" = "dark"): string {
|
||||
const resolved = new URL(url);
|
||||
if (
|
||||
resolved.searchParams.get("openclawHostTheme") !== "1" ||
|
||||
!/^\/embed\/(?:channel|thread)\/[^/]+\/[^/]+\/?$/u.test(resolved.pathname)
|
||||
) {
|
||||
return resolved.href;
|
||||
}
|
||||
resolved.searchParams.set("theme", mode);
|
||||
resolved.searchParams.set("hostOrigin", window.location.origin);
|
||||
return resolved.href;
|
||||
}
|
||||
|
||||
function mount(params: {
|
||||
loadInfo: SessionDiscussionInfoLoader;
|
||||
openDiscussion: SessionDiscussionOpener;
|
||||
@@ -56,7 +73,7 @@ describe("session discussion panel", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
|
||||
"https://discussion.example/embed/thread",
|
||||
expectedEmbedUrl("https://discussion.example/embed/thread"),
|
||||
);
|
||||
expect(panel.querySelector("iframe")?.getAttribute("sandbox")).toBe(
|
||||
"allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts",
|
||||
@@ -74,6 +91,179 @@ describe("session discussion panel", () => {
|
||||
expect(panel.querySelector("a")).toBeNull();
|
||||
});
|
||||
|
||||
it("selects the host color mode before the discussion frame paints", async () => {
|
||||
document.documentElement.dataset.themeMode = "light";
|
||||
const panel = mount({
|
||||
loadInfo: vi.fn().mockResolvedValue({
|
||||
state: "open",
|
||||
embedUrl:
|
||||
"https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1&existing=1#messages",
|
||||
}),
|
||||
openDiscussion: vi.fn(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
|
||||
expectedEmbedUrl(
|
||||
"https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1&existing=1#messages",
|
||||
"light",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves signed provider URLs even when their routes resemble ClickClack", async () => {
|
||||
const signedUrl =
|
||||
"https://discussion.example/embed/channel/a/b?signature=abc%2B123&expires=1785200000#thread";
|
||||
document.documentElement.dataset.themeMode = "light";
|
||||
document.documentElement.style.setProperty("--bg", "#faf9f7");
|
||||
const panel = mount({
|
||||
loadInfo: vi.fn().mockResolvedValue({
|
||||
state: "open",
|
||||
embedUrl: signedUrl,
|
||||
}),
|
||||
openDiscussion: vi.fn(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.querySelector<HTMLIFrameElement>("iframe")?.src).toBe(signedUrl);
|
||||
});
|
||||
const frame = panel.querySelector<HTMLIFrameElement>("iframe")!;
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
|
||||
frame.dispatchEvent(new Event("load"));
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "openclaw:widget-theme",
|
||||
tokens: expect.objectContaining({ surface: "#faf9f7" }),
|
||||
}),
|
||||
"https://discussion.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("posts the complete host palette to the exact discussion origin on frame load", async () => {
|
||||
document.documentElement.dataset.themeMode = "light";
|
||||
document.documentElement.style.setProperty("--bg", "#faf9f7");
|
||||
document.documentElement.style.setProperty("--card", "#ffffff");
|
||||
document.documentElement.style.setProperty("--accent", "#bd4531");
|
||||
const panel = mount({
|
||||
loadInfo: vi.fn().mockResolvedValue({
|
||||
state: "open",
|
||||
embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1",
|
||||
}),
|
||||
openDiscussion: vi.fn(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(panel.querySelector("iframe")).not.toBeNull());
|
||||
const frame = panel.querySelector<HTMLIFrameElement>("iframe")!;
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
|
||||
frame.dispatchEvent(new Event("load"));
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "openclaw:widget-theme",
|
||||
mode: "light",
|
||||
tokens: expect.objectContaining({
|
||||
surface: "#faf9f7",
|
||||
card: "#ffffff",
|
||||
accent: "#bd4531",
|
||||
}),
|
||||
}),
|
||||
"https://discussion.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes custom host palette tokens in the first-paint discussion URL", async () => {
|
||||
document.documentElement.dataset.themeMode = "dark";
|
||||
document.documentElement.style.setProperty("--bg", "#171229");
|
||||
document.documentElement.style.setProperty("--card", "#211a36");
|
||||
document.documentElement.style.setProperty("--accent", "#c084fc");
|
||||
const panel = mount({
|
||||
loadInfo: vi.fn().mockResolvedValue({
|
||||
state: "open",
|
||||
embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1",
|
||||
}),
|
||||
openDiscussion: vi.fn(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(panel.querySelector("iframe")).not.toBeNull());
|
||||
const embedUrl = new URL(panel.querySelector<HTMLIFrameElement>("iframe")!.src);
|
||||
|
||||
expect(embedUrl.searchParams.get("theme")).toBe("dark");
|
||||
expect(JSON.parse(embedUrl.searchParams.get("themeTokens")!)).toEqual(
|
||||
expect.objectContaining({
|
||||
surface: "#171229",
|
||||
card: "#211a36",
|
||||
accent: "#c084fc",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates an existing discussion frame when the host theme changes", async () => {
|
||||
document.documentElement.dataset.themeMode = "light";
|
||||
document.documentElement.style.setProperty("--bg", "#faf9f7");
|
||||
const panel = mount({
|
||||
loadInfo: vi.fn().mockResolvedValue({
|
||||
state: "open",
|
||||
embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1",
|
||||
}),
|
||||
openDiscussion: vi.fn(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(panel.querySelector("iframe")).not.toBeNull());
|
||||
const frame = panel.querySelector<HTMLIFrameElement>("iframe")!;
|
||||
const originalUrl = frame.src;
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
|
||||
document.documentElement.dataset.themeMode = "dark";
|
||||
document.documentElement.style.setProperty("--bg", "#0e1015");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "openclaw:widget-theme",
|
||||
mode: "dark",
|
||||
tokens: expect.objectContaining({ surface: "#0e1015" }),
|
||||
}),
|
||||
"https://discussion.example",
|
||||
);
|
||||
});
|
||||
expect(frame.src).toBe(originalUrl);
|
||||
});
|
||||
|
||||
it("updates the discussion when only the custom host palette changes", async () => {
|
||||
document.documentElement.dataset.themeMode = "dark";
|
||||
document.documentElement.style.setProperty("--bg", "#171229");
|
||||
const panel = mount({
|
||||
loadInfo: vi.fn().mockResolvedValue({
|
||||
state: "open",
|
||||
embedUrl: "https://discussion.example/embed/channel/T1/C1?openclawHostTheme=1",
|
||||
}),
|
||||
openDiscussion: vi.fn(),
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(panel.querySelector("iframe")).not.toBeNull());
|
||||
const frame = panel.querySelector<HTMLIFrameElement>("iframe")!;
|
||||
const originalUrl = frame.src;
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
|
||||
document.documentElement.style.setProperty("--bg", "#211a36");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "openclaw:widget-theme",
|
||||
mode: "dark",
|
||||
tokens: expect.objectContaining({ surface: "#211a36" }),
|
||||
}),
|
||||
"https://discussion.example",
|
||||
);
|
||||
});
|
||||
expect(frame.src).toBe(originalUrl);
|
||||
});
|
||||
|
||||
it("offers the valid open URL when a same-origin embed is rejected", async () => {
|
||||
const openUrl = "https://discussion.example/thread";
|
||||
const panel = mount({
|
||||
@@ -180,7 +370,7 @@ describe("session discussion panel", () => {
|
||||
const panel = mount({ loadInfo, openDiscussion: vi.fn() });
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
|
||||
"https://old.example/embed/thread",
|
||||
expectedEmbedUrl("https://old.example/embed/thread"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -188,7 +378,7 @@ describe("session discussion panel", () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(panel.querySelector("iframe")?.getAttribute("src")).toBe(
|
||||
"https://new.example/embed/thread",
|
||||
expectedEmbedUrl("https://new.example/embed/thread"),
|
||||
);
|
||||
});
|
||||
expect(loadInfo).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "../../../../../packages/gateway-protocol/src/index.js";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts";
|
||||
import { buildWidgetThemeMessage, postWidgetTheme } from "./widget-theme.ts";
|
||||
|
||||
type SessionDiscussionInfoLoader = (sessionKey: string) => Promise<SessionDiscussionInfo>;
|
||||
type SessionDiscussionOpener = (sessionKey: string) => Promise<SessionDiscussionInfo>;
|
||||
@@ -45,7 +46,30 @@ function resolveDiscussionEmbedUrl(value: string | undefined): string | null {
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
return new URL(resolved).origin === window.location.origin ? null : resolved;
|
||||
const url = new URL(resolved);
|
||||
if (url.origin === window.location.origin) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
url.searchParams.get("openclawHostTheme") !== "1" ||
|
||||
!/^\/embed\/(?:channel|thread)\/[^/]+\/[^/]+\/?$/u.test(url.pathname)
|
||||
) {
|
||||
// Provider-issued and signed discussion URLs are opaque. Only ClickClack's
|
||||
// documented embed routes support the first-paint theme query contract.
|
||||
return url.href;
|
||||
}
|
||||
// The initial URL protects the first paint; hostOrigin binds subsequent
|
||||
// full-palette messages to this exact Control UI parent.
|
||||
url.searchParams.set(
|
||||
"theme",
|
||||
document.documentElement.dataset.themeMode === "light" ? "light" : "dark",
|
||||
);
|
||||
url.searchParams.set("hostOrigin", window.location.origin);
|
||||
const themeTokens = buildWidgetThemeMessage().tokens;
|
||||
if (Object.keys(themeTokens).length > 0) {
|
||||
url.searchParams.set("themeTokens", JSON.stringify(themeTokens));
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
class SessionDiscussionPanel extends OpenClawLightDomElement {
|
||||
@@ -62,6 +86,41 @@ class SessionDiscussionPanel extends OpenClawLightDomElement {
|
||||
@state() private error: string | null = null;
|
||||
|
||||
private requestVersion = 0;
|
||||
private themeObserver: MutationObserver | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (typeof MutationObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
this.themeObserver = new MutationObserver(() => this.postDiscussionTheme());
|
||||
this.themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme", "data-theme-mode", "style"],
|
||||
});
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.themeObserver?.disconnect();
|
||||
this.themeObserver = null;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private readonly handleDiscussionFrameLoad = (event: Event): void => {
|
||||
const frame = event.currentTarget;
|
||||
if (frame instanceof HTMLIFrameElement) {
|
||||
this.postDiscussionTheme(frame);
|
||||
}
|
||||
};
|
||||
|
||||
private postDiscussionTheme(
|
||||
frame = this.querySelector<HTMLIFrameElement>(".session-discussion__frame"),
|
||||
): void {
|
||||
if (!frame?.isConnected) {
|
||||
return;
|
||||
}
|
||||
postWidgetTheme(frame, new URL(frame.src).origin);
|
||||
}
|
||||
|
||||
private isCurrentRequest(sessionKey: string, version: number): boolean {
|
||||
return version === this.requestVersion && sessionKey === this.sessionKey.trim();
|
||||
@@ -161,6 +220,7 @@ class SessionDiscussionPanel extends OpenClawLightDomElement {
|
||||
src=${embedUrl}
|
||||
title=${t("chat.sessionDiscussion.frameTitle")}
|
||||
sandbox="allow-forms allow-popups allow-popups-to-escape-sandbox allow-same-origin allow-scripts"
|
||||
@load=${this.handleDiscussionFrameLoad}
|
||||
></iframe>
|
||||
`
|
||||
: html`<div class="session-discussion__empty">
|
||||
|
||||
@@ -75,6 +75,24 @@ describe("widget theme bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("targets the exact origin for authenticated cross-origin embeds", () => {
|
||||
document.documentElement.dataset.themeMode = "dark";
|
||||
stubComputedStyles({ "--bg": "#0e1015", "--accent": "#ff5c5c" });
|
||||
const postMessage = vi.fn();
|
||||
const frame = { contentWindow: { postMessage } } as unknown as HTMLIFrameElement;
|
||||
|
||||
postWidgetTheme(frame, "https://discussion.example");
|
||||
|
||||
expect(postedMessage(postMessage)).toEqual([
|
||||
{
|
||||
type: "openclaw:widget-theme",
|
||||
mode: "dark",
|
||||
tokens: { surface: "#0e1015", accent: "#ff5c5c" },
|
||||
},
|
||||
"https://discussion.example",
|
||||
]);
|
||||
});
|
||||
|
||||
it("posts theme changes to connected frames and installs once", () => {
|
||||
class FakeMutationObserver {
|
||||
static instances: FakeMutationObserver[] = [];
|
||||
|
||||
@@ -53,7 +53,7 @@ function collectWidgetThemeTokens(read: (hostVar: string) => string): Record<str
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function buildWidgetThemeMessage(): {
|
||||
export function buildWidgetThemeMessage(): {
|
||||
type: "openclaw:widget-theme";
|
||||
mode: "light" | "dark";
|
||||
tokens: Record<string, string>;
|
||||
@@ -67,10 +67,10 @@ function buildWidgetThemeMessage(): {
|
||||
};
|
||||
}
|
||||
|
||||
export function postWidgetTheme(frame: HTMLIFrameElement): void {
|
||||
// Widget documents have opaque origins, so "*" is required; the payload
|
||||
// contains theme colors only.
|
||||
frame.contentWindow?.postMessage(buildWidgetThemeMessage(), "*");
|
||||
export function postWidgetTheme(frame: HTMLIFrameElement, targetOrigin = "*"): void {
|
||||
// Canvas widgets have opaque origins and require "*". Authenticated
|
||||
// cross-origin embeds instead supply their exact, validated origin.
|
||||
frame.contentWindow?.postMessage(buildWidgetThemeMessage(), targetOrigin);
|
||||
}
|
||||
|
||||
let widgetThemeObserverInstalled = false;
|
||||
|
||||
Reference in New Issue
Block a user