diff --git a/ui/src/components/mcp-app-theme.test.ts b/ui/src/components/mcp-app-theme.test.ts new file mode 100644 index 000000000000..e2b7a9165342 --- /dev/null +++ b/ui/src/components/mcp-app-theme.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { collectMcpAppStyleVariables } from "./mcp-app-theme.ts"; + +function rootWithTokens(tokens: Record): HTMLElement { + const element = document.createElement("div"); + for (const [name, value] of Object.entries(tokens)) { + element.style.setProperty(name, value); + } + document.body.append(element); + return element; +} + +describe("collectMcpAppStyleVariables", () => { + it("publishes Control UI tokens under specification keys", () => { + const variables = collectMcpAppStyleVariables( + rootWithTokens({ + "--card": "#161920", + "--bg": "#0e1015", + "--text": "#d4d4d8", + "--border": "#1e2028", + "--radius": "10px", + }), + ); + + expect(variables?.["--color-background-primary"]).toBe("#161920"); + expect(variables?.["--color-background-secondary"]).toBe("#0e1015"); + expect(variables?.["--color-text-primary"]).toBe("#d4d4d8"); + expect(variables?.["--color-border-primary"]).toBe("#1e2028"); + expect(variables?.["--border-radius-md"]).toBe("10px"); + }); + + it("omits keys the host cannot source instead of publishing empty values", () => { + const variables = collectMcpAppStyleVariables(rootWithTokens({ "--card": "#161920" })); + + expect(variables).not.toHaveProperty("--color-text-primary"); + expect( + Object.values(variables ?? {}).every( + (value) => typeof value === "string" && value.length > 0, + ), + ).toBe(true); + }); + + it("preserves text hierarchy and inverse-surface contrast", () => { + const variables = collectMcpAppStyleVariables( + rootWithTokens({ + "--text": "#d4d4d8", + "--muted-strong": "#a1a1aa", + "--muted": "#71717a", + "--bg": "#0e1015", + }), + ); + + expect(variables?.["--color-text-secondary"]).toBe("#a1a1aa"); + expect(variables?.["--color-text-tertiary"]).toBe("#71717a"); + expect(variables?.["--color-background-inverse"]).toBe("#d4d4d8"); + expect(variables?.["--color-text-inverse"]).toBe("#0e1015"); + expect(variables?.["--color-border-inverse"]).toBe("#0e1015"); + }); + + it("never publishes a font stack whose leading face the sandbox cannot load", () => { + const variables = collectMcpAppStyleVariables( + rootWithTokens({ + "--font-body": '"Inter", sans-serif', + "--mono": '"JetBrains Mono", monospace', + }), + ); + + // Font requests are limited to resource domains the app declares, so a + // host-led webfont resolves to an arbitrary system face rather than + // failing visibly. Apps own their sans stack until Control UI can supply + // a sandbox-safe one. + expect(variables).not.toHaveProperty("--font-sans"); + expect(variables?.["--font-mono"]).toContain("monospace"); + }); + + it("publishes trimmed, non-empty values", () => { + const variables = collectMcpAppStyleVariables( + rootWithTokens({ "--card": " #161920 ", "--bg": "#0e1015" }), + ); + + // Values cross into a separate origin, where a Control UI token name would + // have nothing to resolve against. Browsers substitute nested var() + // references when computing a custom property, which is what makes the + // published values self-contained; jsdom does not implement that + // substitution, so the guarantee is verified in a browser rather than here. + expect(variables?.["--color-background-primary"]).toBe("#161920"); + expect( + Object.values(variables ?? {}).every( + (value) => typeof value === "string" && value === value.trim(), + ), + ).toBe(true); + }); + + it("publishes only specification keys", () => { + const variables = collectMcpAppStyleVariables(rootWithTokens({ "--card": "#161920" })); + + // The transported record is validated against a closed key set, so an + // OpenClaw name here would be rejected for the whole payload. + expect(Object.keys(variables ?? {}).filter((key) => key.startsWith("--oc-"))).toEqual([]); + expect( + Object.keys(variables ?? {}).every( + (key) => + key.startsWith("--color-") || + key.startsWith("--font-") || + key.startsWith("--border-") || + key.startsWith("--shadow-"), + ), + ).toBe(true); + }); +}); diff --git a/ui/src/components/mcp-app-theme.ts b/ui/src/components/mcp-app-theme.ts new file mode 100644 index 000000000000..f0032cad4f2c --- /dev/null +++ b/ui/src/components/mcp-app-theme.ts @@ -0,0 +1,99 @@ +/** + * Control UI custom properties published to embedded MCP apps, keyed by the + * specification variable they satisfy. The key set is closed by the MCP Apps + * specification, so an OpenClaw name can never be added here; the canonical + * meaning of each key lives in the carapace embed contract. + * + * Only keys Control UI can honestly source are listed. The specification lets + * a host publish any subset, and an app resolves the rest from its own + * fallbacks, so omitting a key is preferable to inventing a value for it. + */ +const HOST_TOKEN_SOURCES = { + "--color-background-primary": "--card", + "--color-background-secondary": "--bg", + "--color-background-tertiary": "--bg-elevated", + "--color-background-inverse": "--text", + "--color-background-disabled": "--bg-muted", + "--color-background-success": "--ok-subtle", + "--color-background-warning": "--warn-subtle", + "--color-background-danger": "--danger-subtle", + + "--color-text-primary": "--text", + "--color-text-secondary": "--muted-strong", + "--color-text-tertiary": "--muted", + "--color-text-inverse": "--bg", + "--color-text-success": "--ok", + "--color-text-warning": "--warn", + "--color-text-danger": "--danger", + "--color-text-info": "--info", + + "--color-border-primary": "--border", + "--color-border-secondary": "--border-strong", + // Inverse borders sit on the inverse background, so they use its foreground. + "--color-border-inverse": "--bg", + "--color-ring-primary": "--ring", + + /* + * Only the monospace stack is published. Control UI's body font leads with a + * webfont, and an embedded app cannot load it: the sandbox policy allows + * font requests only from resource domains the app itself declares. Sending + * it would resolve to an arbitrary system face instead of failing visibly. + * The monospace stack degrades correctly because its fallbacks are system + * faces. Apps supply their own sans stack until Control UI adopts the + * carapace embed tokens, which define a sandbox-safe one. + */ + "--font-mono": "--mono", + + "--font-text-xs-size": "--control-ui-text-xs", + "--font-text-sm-size": "--control-ui-text-sm", + "--font-text-md-size": "--control-ui-text-md", + "--font-text-lg-size": "--control-ui-text-lg", + + "--border-radius-xs": "--radius-sm", + "--border-radius-sm": "--radius-sm", + "--border-radius-md": "--radius", + "--border-radius-lg": "--radius-lg", + "--border-radius-xl": "--radius-xl", + "--border-radius-full": "--radius-full", + + "--shadow-sm": "--shadow-sm", + "--shadow-md": "--shadow-md", + "--shadow-lg": "--shadow-lg", +} as const; + +/** Values with no Control UI source, fixed by the specification's own scale. */ +const STATIC_VARIABLES = { + "--border-width-regular": "1px", + "--font-weight-normal": "400", + "--font-weight-medium": "500", + "--font-weight-semibold": "600", + "--font-weight-bold": "700", +} as const; + +type StyleVariableKey = keyof typeof HOST_TOKEN_SOURCES | keyof typeof STATIC_VARIABLES; +type StyleVariables = Partial>; + +/** + * Snapshot the current theme as MCP Apps style variables. + * + * Values are read as computed custom properties, which substitutes nested + * `var()` references and leaves a self-contained value. That matters because + * the app document is a separate origin: an unresolved reference to a Control + * UI token would have nothing to resolve against once it crosses the boundary. + */ +export function collectMcpAppStyleVariables( + root: HTMLElement | undefined = document.documentElement, +): StyleVariables | undefined { + if (!root) { + return undefined; + } + const computed = getComputedStyle(root); + const variables: Record = { ...STATIC_VARIABLES }; + for (const [specKey, hostToken] of Object.entries(HOST_TOKEN_SOURCES)) { + const value = computed.getPropertyValue(hostToken).trim(); + if (value) { + variables[specKey] = value; + } + } + return variables as StyleVariables; +} diff --git a/ui/src/components/mcp-app-view.test.ts b/ui/src/components/mcp-app-view.test.ts index 21c448bc5535..32bc653d0297 100644 --- a/ui/src/components/mcp-app-view.test.ts +++ b/ui/src/components/mcp-app-view.test.ts @@ -101,6 +101,8 @@ describe("mcp-app-view localization", () => { document.body.replaceChildren(); delete (document as unknown as Record).activeElement; delete document.documentElement.dataset.themeMode; + document.documentElement.style.removeProperty("--card"); + document.documentElement.style.removeProperty("--text"); vi.restoreAllMocks(); vi.unstubAllGlobals(); await i18n.setLocale("en"); @@ -333,6 +335,8 @@ describe("mcp-app-view localization", () => { () => ({ width }) as DOMRect, ); document.documentElement.dataset.themeMode = "dark"; + document.documentElement.style.setProperty("--card", "#161920"); + document.documentElement.style.setProperty("--text", "#d4d4d8"); const { bridge, themeListeners, unsubscribe, view } = await mountBridge( `view-context-${crypto.randomUUID()}`, @@ -340,13 +344,29 @@ describe("mcp-app-view localization", () => { expect(bridge.options.hostContext).toMatchObject({ theme: "dark", containerDimensions: { width: 640, height: 600 }, + styles: { + variables: { + "--color-background-primary": "#161920", + "--color-text-primary": "#d4d4d8", + }, + }, }); await expect.poll(() => themeListeners.size).toBe(1); document.documentElement.dataset.themeMode = "light"; + document.documentElement.style.setProperty("--card", "#ffffff"); + document.documentElement.style.setProperty("--text", "#403c35"); themeListeners.values().next().value?.(); expect(bridge.setHostContext).toHaveBeenLastCalledWith( - expect.objectContaining({ theme: "light" }), + expect.objectContaining({ + theme: "light", + styles: { + variables: expect.objectContaining({ + "--color-background-primary": "#ffffff", + "--color-text-primary": "#403c35", + }), + }, + }), ); width = 720; diff --git a/ui/src/components/mcp-app-view.ts b/ui/src/components/mcp-app-view.ts index 10f9e3d7a768..7269f6ff5c23 100644 --- a/ui/src/components/mcp-app-view.ts +++ b/ui/src/components/mcp-app-view.ts @@ -20,6 +20,7 @@ import { resolveMcpAppSandboxUrl, type McpAppHostSandboxCsp, } from "./mcp-app-security.ts"; +import { collectMcpAppStyleVariables } from "./mcp-app-theme.ts"; type McpAppViewPayload = { sandboxUrl: string; @@ -89,6 +90,10 @@ function hostContext(element: Element | undefined, height: number): HostContext hover: window.matchMedia?.("(hover: hover)").matches, }, safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 }, + // Additive alongside `theme`: the string says which appearance is active, + // these say what it actually resolves to. Republished by the same theme + // subscription that re-sends this context. + styles: { variables: collectMcpAppStyleVariables() }, }; } diff --git a/ui/src/e2e/mcp-app-conformance.e2e.test.ts b/ui/src/e2e/mcp-app-conformance.e2e.test.ts index 3f3f95e48c69..1742c274e9eb 100644 --- a/ui/src/e2e/mcp-app-conformance.e2e.test.ts +++ b/ui/src/e2e/mcp-app-conformance.e2e.test.ts @@ -78,6 +78,19 @@ async function waitForTextContaining( function appHtml(appModuleUrl: string): string { return ` + +
Host-themed surface
@@ -96,11 +109,33 @@ function appHtml(appModuleUrl: string): string { + + +