feat(web): extract hostContext utilities for the Apps host (closes #1559)
Add clients/web/src/components/elements/AppRenderer/hostContext.ts — a pure utilities module for the MCP Apps host: - currentTheme(): read the resolved Mantine color scheme from the DOM - currentStyles(): map the inspector's Mantine/-inspector design tokens to a McpUiHostStyles snapshot via STYLE_VARIABLE_SOURCES - measureContainerDimensions(): whole-pixel container measurement - snapshotHostContext(): assemble the initial McpUiHostContext seed Wave 1 of the PR #1510 decomposition (#1579). Pure extraction only — the AppRenderer/AppsScreen/createAppBridgeFactory rewrites belong to Wave 2. Full unit coverage (100% lines/statements/functions/branches). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
This commit is contained in:
parent
066676a241
commit
5831b5c3d9
2 changed files with 364 additions and 0 deletions
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||||
|
import {
|
||||||
|
currentStyles,
|
||||||
|
currentTheme,
|
||||||
|
measureContainerDimensions,
|
||||||
|
snapshotHostContext,
|
||||||
|
} from "./hostContext";
|
||||||
|
|
||||||
|
const COLOR_SCHEME_ATTR = "data-mantine-color-scheme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fake CSSStyleDeclaration whose `getPropertyValue` reads from a
|
||||||
|
* lookup table, so tests control exactly which CSS variables resolve (and to
|
||||||
|
* what) without depending on happy-dom's custom-property resolution.
|
||||||
|
*/
|
||||||
|
function fakeComputedStyle(
|
||||||
|
values: Record<string, string>,
|
||||||
|
): CSSStyleDeclaration {
|
||||||
|
return {
|
||||||
|
getPropertyValue: (name: string) => values[name] ?? "",
|
||||||
|
} as unknown as CSSStyleDeclaration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubComputedStyle(values: Record<string, string>) {
|
||||||
|
vi.spyOn(window, "getComputedStyle").mockReturnValue(
|
||||||
|
fakeComputedStyle(values),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
document.documentElement.removeAttribute(COLOR_SCHEME_ATTR);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("currentTheme", () => {
|
||||||
|
it("returns the resolved dark scheme written by Mantine", () => {
|
||||||
|
document.documentElement.setAttribute(COLOR_SCHEME_ATTR, "dark");
|
||||||
|
expect(currentTheme()).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the resolved light scheme written by Mantine", () => {
|
||||||
|
document.documentElement.setAttribute(COLOR_SCHEME_ATTR, "light");
|
||||||
|
expect(currentTheme()).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores an unrecognized attribute value and falls back", () => {
|
||||||
|
document.documentElement.setAttribute(COLOR_SCHEME_ATTR, "sepia");
|
||||||
|
vi.stubGlobal("matchMedia", () => ({ matches: false }));
|
||||||
|
expect(currentTheme()).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to matchMedia dark when the attribute is absent", () => {
|
||||||
|
document.documentElement.removeAttribute(COLOR_SCHEME_ATTR);
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
matches: query.includes("dark"),
|
||||||
|
}));
|
||||||
|
expect(currentTheme()).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to light when matchMedia does not match dark", () => {
|
||||||
|
document.documentElement.removeAttribute(COLOR_SCHEME_ATTR);
|
||||||
|
vi.stubGlobal("matchMedia", () => ({ matches: false }));
|
||||||
|
expect(currentTheme()).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to light when matchMedia is unavailable", () => {
|
||||||
|
document.documentElement.removeAttribute(COLOR_SCHEME_ATTR);
|
||||||
|
vi.stubGlobal("matchMedia", undefined);
|
||||||
|
expect(currentTheme()).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns light in a non-DOM environment (no document, no window)", () => {
|
||||||
|
vi.stubGlobal("document", undefined);
|
||||||
|
vi.stubGlobal("window", undefined);
|
||||||
|
expect(currentTheme()).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consults matchMedia when document is absent but window is present", () => {
|
||||||
|
vi.stubGlobal("document", undefined);
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
matches: query.includes("dark"),
|
||||||
|
}));
|
||||||
|
expect(currentTheme()).toBe("dark");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("currentStyles", () => {
|
||||||
|
it("maps resolved Mantine tokens to spec style variables", () => {
|
||||||
|
stubComputedStyle({
|
||||||
|
"--mantine-color-body": "#101113",
|
||||||
|
"--mantine-color-text": "#c9c9c9",
|
||||||
|
"--mantine-radius-md": "0.5rem",
|
||||||
|
});
|
||||||
|
const styles = currentStyles();
|
||||||
|
expect(styles).toEqual({
|
||||||
|
variables: {
|
||||||
|
"--color-background-primary": "#101113",
|
||||||
|
"--color-text-primary": "#c9c9c9",
|
||||||
|
"--border-radius-md": "0.5rem",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims whitespace and omits variables that resolve to empty", () => {
|
||||||
|
stubComputedStyle({
|
||||||
|
"--mantine-color-body": " #ffffff ",
|
||||||
|
"--mantine-color-text": " ",
|
||||||
|
});
|
||||||
|
const styles = currentStyles();
|
||||||
|
expect(styles?.variables?.["--color-background-primary"]).toBe("#ffffff");
|
||||||
|
expect(styles?.variables?.["--color-text-primary"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when nothing resolves", () => {
|
||||||
|
stubComputedStyle({});
|
||||||
|
expect(currentStyles()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined in a non-DOM environment (no document)", () => {
|
||||||
|
vi.stubGlobal("document", undefined);
|
||||||
|
expect(currentStyles()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined in a non-DOM environment (no window)", () => {
|
||||||
|
vi.stubGlobal("window", undefined);
|
||||||
|
expect(currentStyles()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("measureContainerDimensions", () => {
|
||||||
|
it("returns whole-pixel width and height for a laid-out element", () => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
||||||
|
width: 320.6,
|
||||||
|
height: 199.4,
|
||||||
|
} as DOMRect);
|
||||||
|
expect(measureContainerDimensions(el)).toEqual({ width: 321, height: 199 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for a 0x0 (unattached) element", () => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
} as DOMRect);
|
||||||
|
expect(measureContainerDimensions(el)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when only one dimension is zero", () => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
||||||
|
width: 100,
|
||||||
|
height: 0,
|
||||||
|
} as DOMRect);
|
||||||
|
expect(measureContainerDimensions(el)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when getBoundingClientRect is unavailable", () => {
|
||||||
|
const el = {} as unknown as HTMLElement;
|
||||||
|
expect(measureContainerDimensions(el)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("snapshotHostContext", () => {
|
||||||
|
const MODES: readonly McpUiDisplayMode[] = ["inline", "fullscreen"];
|
||||||
|
|
||||||
|
it("seeds theme, inline display mode, and a copied available-modes list", () => {
|
||||||
|
document.documentElement.setAttribute(COLOR_SCHEME_ATTR, "dark");
|
||||||
|
stubComputedStyle({});
|
||||||
|
const context = snapshotHostContext(null, MODES);
|
||||||
|
expect(context.theme).toBe("dark");
|
||||||
|
expect(context.displayMode).toBe("inline");
|
||||||
|
expect(context.availableDisplayModes).toEqual(["inline", "fullscreen"]);
|
||||||
|
// A copy, not the caller's array.
|
||||||
|
expect(context.availableDisplayModes).not.toBe(MODES);
|
||||||
|
expect(context.styles).toBeUndefined();
|
||||||
|
expect(context.containerDimensions).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes styles and container dimensions when both resolve", () => {
|
||||||
|
document.documentElement.setAttribute(COLOR_SCHEME_ATTR, "light");
|
||||||
|
stubComputedStyle({ "--mantine-color-body": "#ffffff" });
|
||||||
|
const container = document.createElement("div");
|
||||||
|
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
} as DOMRect);
|
||||||
|
const context = snapshotHostContext(container, MODES);
|
||||||
|
expect(context.theme).toBe("light");
|
||||||
|
expect(context.styles).toEqual({
|
||||||
|
variables: { "--color-background-primary": "#ffffff" },
|
||||||
|
});
|
||||||
|
expect(context.containerDimensions).toEqual({ width: 640, height: 480 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits container dimensions when the container has no layout box", () => {
|
||||||
|
stubComputedStyle({});
|
||||||
|
const container = document.createElement("div");
|
||||||
|
vi.spyOn(container, "getBoundingClientRect").mockReturnValue({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
} as DOMRect);
|
||||||
|
const context = snapshotHostContext(container, MODES);
|
||||||
|
expect(context.containerDimensions).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
156
clients/web/src/components/elements/AppRenderer/hostContext.ts
Normal file
156
clients/web/src/components/elements/AppRenderer/hostContext.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import type {
|
||||||
|
McpUiDisplayMode,
|
||||||
|
McpUiHostContext,
|
||||||
|
McpUiHostStyles,
|
||||||
|
McpUiStyles,
|
||||||
|
McpUiStyleVariableKey,
|
||||||
|
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the host theme from the DOM. Mantine writes the resolved color
|
||||||
|
* scheme to `<html data-mantine-color-scheme>`. Reading it here (rather than
|
||||||
|
* capturing React state) keeps the bridge factory's identity stable across
|
||||||
|
* theme toggles — the renderer treats a new factory identity as "rebuild the
|
||||||
|
* bridge", which would reload a running app's iframe on every theme flip.
|
||||||
|
*
|
||||||
|
* The attribute is only ever `"light"` or `"dark"` — Mantine resolves
|
||||||
|
* `defaultColorScheme="auto"` to the system value before paint and never
|
||||||
|
* writes `"auto"` here, so no `auto` branch is needed. The matchMedia
|
||||||
|
* fallback only covers the attribute being absent (e.g. a hydration race).
|
||||||
|
*/
|
||||||
|
export function currentTheme(): "light" | "dark" {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
const attr = document.documentElement.getAttribute(
|
||||||
|
"data-mantine-color-scheme",
|
||||||
|
);
|
||||||
|
if (attr === "dark" || attr === "light") return attr;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||||
|
) {
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the spec's host-style variable keys ({@link McpUiStyleVariableKey}) to
|
||||||
|
* the inspector's underlying CSS custom properties. The inspector themes itself
|
||||||
|
* with Mantine, so each spec token resolves to the matching Mantine design-token
|
||||||
|
* variable (or an `--inspector-*` token layered on top of one). Only a curated
|
||||||
|
* subset of the ~90 spec keys is mapped — the ones the inspector has a
|
||||||
|
* meaningful equivalent for; the rest are omitted, which the spec allows (hosts
|
||||||
|
* may provide any subset).
|
||||||
|
*/
|
||||||
|
const STYLE_VARIABLE_SOURCES: Partial<Record<McpUiStyleVariableKey, string>> = {
|
||||||
|
"--color-background-primary": "--mantine-color-body",
|
||||||
|
"--color-background-secondary": "--inspector-surface-card",
|
||||||
|
"--color-background-tertiary": "--inspector-surface-subtle",
|
||||||
|
"--color-text-primary": "--mantine-color-text",
|
||||||
|
"--color-text-secondary": "--inspector-text-secondary",
|
||||||
|
"--color-text-inverse": "--inspector-text-inverse",
|
||||||
|
"--color-text-info": "--inspector-log-info",
|
||||||
|
"--color-text-danger": "--inspector-log-error",
|
||||||
|
"--color-text-success": "--inspector-status-connected",
|
||||||
|
"--color-text-warning": "--inspector-log-warning",
|
||||||
|
"--color-border-primary": "--inspector-border-default",
|
||||||
|
"--color-border-secondary": "--inspector-border-subtle",
|
||||||
|
"--font-sans": "--mantine-font-family",
|
||||||
|
"--font-mono": "--mantine-font-family-monospace",
|
||||||
|
"--font-text-xs-size": "--mantine-font-size-xs",
|
||||||
|
"--font-text-sm-size": "--mantine-font-size-sm",
|
||||||
|
"--font-text-md-size": "--mantine-font-size-md",
|
||||||
|
"--font-text-lg-size": "--mantine-font-size-lg",
|
||||||
|
"--border-radius-xs": "--mantine-radius-xs",
|
||||||
|
"--border-radius-sm": "--mantine-radius-sm",
|
||||||
|
"--border-radius-md": "--mantine-radius-md",
|
||||||
|
"--border-radius-lg": "--mantine-radius-lg",
|
||||||
|
"--border-radius-xl": "--mantine-radius-xl",
|
||||||
|
"--shadow-sm": "--mantine-shadow-sm",
|
||||||
|
"--shadow-md": "--mantine-shadow-md",
|
||||||
|
"--shadow-lg": "--mantine-shadow-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STYLE_VARIABLE_ENTRIES = Object.entries(STYLE_VARIABLE_SOURCES) as [
|
||||||
|
McpUiStyleVariableKey,
|
||||||
|
string,
|
||||||
|
][];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the inspector's design tokens into a {@link McpUiHostStyles} for
|
||||||
|
* hostContext, so style-aware apps can theme themselves from the host instead
|
||||||
|
* of falling back to their own defaults. Reads the computed value of each
|
||||||
|
* mapped CSS variable from the document root — which reflects the active
|
||||||
|
* Mantine color scheme — and keeps only the ones that resolve to a non-empty
|
||||||
|
* value. Returns undefined when nothing resolves (e.g. a non-DOM/test
|
||||||
|
* environment) so we never advertise an empty styles object.
|
||||||
|
*/
|
||||||
|
export function currentStyles(): McpUiHostStyles | undefined {
|
||||||
|
if (typeof document === "undefined" || typeof window === "undefined") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const computed = window.getComputedStyle(document.documentElement);
|
||||||
|
const variables: McpUiStyles = {} as McpUiStyles;
|
||||||
|
let resolved = false;
|
||||||
|
for (const [specKey, sourceVar] of STYLE_VARIABLE_ENTRIES) {
|
||||||
|
const value = computed.getPropertyValue(sourceVar).trim();
|
||||||
|
if (value) {
|
||||||
|
variables[specKey] = value;
|
||||||
|
resolved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved ? { variables } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spec shape for `hostContext.containerDimensions`. Derived from
|
||||||
|
* {@link McpUiHostContext} so the seed and live-push paths share one source of
|
||||||
|
* truth and stay in lockstep with the spec types.
|
||||||
|
*/
|
||||||
|
export type ContainerDimensions = NonNullable<
|
||||||
|
McpUiHostContext["containerDimensions"]
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the host container an app renders into and return its concrete
|
||||||
|
* `{ width, height }` (whole pixels). Returns undefined when the element has
|
||||||
|
* no layout box yet (0×0 — e.g. before the iframe is attached, or in a
|
||||||
|
* non-DOM/test environment) so a meaningless size is never seeded into
|
||||||
|
* hostContext. The return type is the concrete pair rather than the spec's
|
||||||
|
* {@link ContainerDimensions} union so callers can compare both fields.
|
||||||
|
*/
|
||||||
|
export function measureContainerDimensions(
|
||||||
|
element: HTMLElement,
|
||||||
|
): { width: number; height: number } | undefined {
|
||||||
|
if (typeof element.getBoundingClientRect !== "function") return undefined;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const width = Math.round(rect.width);
|
||||||
|
const height = Math.round(rect.height);
|
||||||
|
if (width <= 0 || height <= 0) return undefined;
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the live host UI state into a {@link McpUiHostContext} for the bridge
|
||||||
|
* handshake — the single place that decides which fields the inspector seeds.
|
||||||
|
* Optional fields are omitted (not set undefined) so the SDK's diff stays
|
||||||
|
* accurate; subsequent live changes are pushed by the renderer's observers as
|
||||||
|
* partial `host-context-changed` notifications.
|
||||||
|
*/
|
||||||
|
export function snapshotHostContext(
|
||||||
|
container: HTMLElement | null,
|
||||||
|
availableDisplayModes: readonly McpUiDisplayMode[],
|
||||||
|
): McpUiHostContext {
|
||||||
|
const styles = currentStyles();
|
||||||
|
const containerDimensions = container
|
||||||
|
? measureContainerDimensions(container)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
theme: currentTheme(),
|
||||||
|
displayMode: "inline",
|
||||||
|
availableDisplayModes: [...availableDisplayModes],
|
||||||
|
...(styles ? { styles } : {}),
|
||||||
|
...(containerDimensions ? { containerDimensions } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue