mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(ui): publish host style variables to embedded MCP apps (#113464)
* feat(ui): publish host style variables to MCP apps An embedded MCP app received only the `theme` string, so it knew which appearance was active but nothing about what that appearance resolves to. Every app therefore had to ship its own palette, and an app installed through a plugin looked like itself rather than like the surface hosting it. Publish the Control UI theme as `hostContext.styles.variables`, the field the MCP Apps specification defines for exactly this. The key set is closed by the specification, so the mapping is a table from Control UI custom properties to specification keys; the canonical meaning of each key lives in the carapace embed contract. Only keys Control UI can honestly source are published. The specification lets a host publish any subset and apps resolve the rest from their own fallbacks, so omitting is preferable to inventing. The body font is deliberately omitted: it leads with a webfont, and an embedded app may load fonts only from resource domains it declares itself, so publishing it would silently resolve to an arbitrary system face. Apps own their sans stack until Control UI adopts the carapace embed tokens, which define a sandbox-safe one. Values are read as computed custom properties so nested references are substituted before crossing into the app's separate origin, where a Control UI token name would have nothing to resolve against. Live updates come free: the existing theme subscription already re-sends host context, and `theme` continues to be published alongside this. * fix(ui): align MCP app host theme semantics
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectMcpAppStyleVariables } from "./mcp-app-theme.ts";
|
||||
|
||||
function rootWithTokens(tokens: Record<string, string>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<Record<StyleVariableKey, string>>;
|
||||
|
||||
/**
|
||||
* 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<string, string> = { ...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;
|
||||
}
|
||||
@@ -101,6 +101,8 @@ describe("mcp-app-view localization", () => {
|
||||
document.body.replaceChildren();
|
||||
delete (document as unknown as Record<string, unknown>).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;
|
||||
|
||||
@@ -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() },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,19 @@ async function waitForTextContaining(
|
||||
function appHtml(appModuleUrl: string): string {
|
||||
return `<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
:root {
|
||||
--color-background-primary: #f6f5f3;
|
||||
--color-text-primary: #17171a;
|
||||
--app-accent: #ff4f4f;
|
||||
}
|
||||
#theme-surface {
|
||||
background: var(--color-background-primary);
|
||||
color: var(--color-text-primary);
|
||||
border-left: 4px solid var(--app-accent);
|
||||
}
|
||||
</style>
|
||||
<div id="theme-surface">Host-themed surface</div>
|
||||
<button id="call-app">Call app tool</button>
|
||||
<button id="call-model">Call model tool</button>
|
||||
<button id="read-resource">Read resource</button>
|
||||
@@ -96,11 +109,33 @@ function appHtml(appModuleUrl: string): string {
|
||||
<output id="message"></output>
|
||||
<output id="teardown"></output>
|
||||
<output id="isolation"></output>
|
||||
<output id="host-theme"></output>
|
||||
<output id="host-variables"></output>
|
||||
<output id="computed-theme"></output>
|
||||
<script type="module">
|
||||
import { App, McpUiResourceTeardownResultSchema } from ${JSON.stringify(appModuleUrl)};
|
||||
import {
|
||||
App,
|
||||
McpUiResourceTeardownResultSchema,
|
||||
applyDocumentTheme,
|
||||
applyHostStyleVariables,
|
||||
} from ${JSON.stringify(appModuleUrl)};
|
||||
const write = (id, value) => { document.getElementById(id).textContent = value; };
|
||||
try { void window.top.document; write("isolation", "failed"); } catch { write("isolation", "isolated"); }
|
||||
const app = new App({ name: "OpenClaw conformance fixture", version: "1.0.0" });
|
||||
const applyHostContext = () => {
|
||||
const context = app.getHostContext();
|
||||
if (context?.theme) applyDocumentTheme(context.theme);
|
||||
if (context?.styles?.variables) applyHostStyleVariables(context.styles.variables);
|
||||
const surface = getComputedStyle(document.getElementById("theme-surface"));
|
||||
write("host-theme", context?.theme ?? "missing");
|
||||
write("host-variables", JSON.stringify(context?.styles?.variables ?? {}));
|
||||
write("computed-theme", JSON.stringify({
|
||||
background: surface.backgroundColor,
|
||||
color: surface.color,
|
||||
accent: surface.borderLeftColor,
|
||||
}));
|
||||
};
|
||||
app.onhostcontextchanged = applyHostContext;
|
||||
app.ontoolinput = ({ arguments: args }) => write("input", JSON.stringify(args ?? {}));
|
||||
app.ontoolresult = (value) => write("result", JSON.stringify(value.structuredContent ?? value));
|
||||
app.onteardown = async () => {
|
||||
@@ -142,6 +177,7 @@ document.getElementById("send-message").onclick = async () => {
|
||||
};
|
||||
document.getElementById("request-teardown").onclick = () => app.requestTeardown();
|
||||
await app.connect();
|
||||
applyHostContext();
|
||||
write("capabilities", JSON.stringify(app.getHostCapabilities() ?? {}));
|
||||
write("ping", JSON.stringify(await app.request(
|
||||
{ method: "ping", params: {} },
|
||||
@@ -304,18 +340,38 @@ window.mcpConformanceUnmount = async () => {
|
||||
}),
|
||||
]);
|
||||
const view = document.createElement("mcp-app-view");
|
||||
const root = document.documentElement;
|
||||
const themeListeners = new Set<() => void>();
|
||||
const setTheme = (theme: "light" | "dark") => {
|
||||
root.dataset.themeMode = theme;
|
||||
root.style.setProperty("--card", theme === "light" ? "#ffffff" : "#161920");
|
||||
root.style.setProperty("--text", theme === "light" ? "#403c35" : "#d4d4d8");
|
||||
for (const listener of themeListeners) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
setTheme("dark");
|
||||
Reflect.set(view, "context", {
|
||||
gateway: {
|
||||
snapshot: { client },
|
||||
connection: { gatewayUrl: params.gatewayUrl },
|
||||
},
|
||||
theme: { subscribe: () => () => undefined },
|
||||
theme: {
|
||||
subscribe(listener: () => void) {
|
||||
themeListeners.add(listener);
|
||||
return () => themeListeners.delete(listener);
|
||||
},
|
||||
},
|
||||
});
|
||||
view.sessionKey = params.sessionKey;
|
||||
view.viewId = params.viewId;
|
||||
view.title = "Conformance app";
|
||||
document.getElementById("mount")?.appendChild(view);
|
||||
Object.assign(window, { mcpConformanceClient: client, mcpConformanceView: view });
|
||||
Object.assign(window, {
|
||||
mcpConformanceClient: client,
|
||||
mcpConformanceView: view,
|
||||
mcpConformanceSetTheme: setTheme,
|
||||
});
|
||||
},
|
||||
{
|
||||
gatewayUrl: `ws://127.0.0.1:${gatewayPort}`,
|
||||
@@ -499,6 +555,40 @@ describeConformance("MCP App Control UI and standalone host conformance", () =>
|
||||
await waitForTextContaining(app.locator("#capabilities"), "updateModelContext");
|
||||
await waitForText(app.locator("#ping"), "{}");
|
||||
await waitForText(app.locator("#isolation"), "isolated");
|
||||
await waitForText(app.locator("#host-theme"), "dark");
|
||||
await waitForTextContaining(
|
||||
app.locator("#host-variables"),
|
||||
'"--color-background-primary":"#161920"',
|
||||
);
|
||||
await waitForTextContaining(app.locator("#host-variables"), '"--color-text-primary":"#d4d4d8"');
|
||||
await waitForText(
|
||||
app.locator("#computed-theme"),
|
||||
JSON.stringify({
|
||||
background: "rgb(22, 25, 32)",
|
||||
color: "rgb(212, 212, 216)",
|
||||
accent: "rgb(255, 79, 79)",
|
||||
}),
|
||||
);
|
||||
await controlPage.evaluate(() => {
|
||||
const setTheme = Reflect.get(window, "mcpConformanceSetTheme") as
|
||||
| ((theme: "light" | "dark") => void)
|
||||
| undefined;
|
||||
setTheme?.("light");
|
||||
});
|
||||
await waitForText(app.locator("#host-theme"), "light");
|
||||
await waitForTextContaining(
|
||||
app.locator("#host-variables"),
|
||||
'"--color-background-primary":"#ffffff"',
|
||||
);
|
||||
await waitForTextContaining(app.locator("#host-variables"), '"--color-text-primary":"#403c35"');
|
||||
await waitForText(
|
||||
app.locator("#computed-theme"),
|
||||
JSON.stringify({
|
||||
background: "rgb(255, 255, 255)",
|
||||
color: "rgb(64, 60, 53)",
|
||||
accent: "rgb(255, 79, 79)",
|
||||
}),
|
||||
);
|
||||
await app.locator("#call-app").click();
|
||||
await waitForTextContaining(app.locator("#app-tool"), "companion-called");
|
||||
await app.locator("#call-model").click();
|
||||
|
||||
Reference in New Issue
Block a user