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:
Peter Steinberger
2026-07-27 22:03:58 -04:00
committed by GitHub
parent 1292262996
commit 56d9d07516
8 changed files with 410 additions and 12 deletions
+5
View File
@@ -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[] = [];
+5 -5
View File
@@ -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;