diff --git a/clients/web/src/components/elements/AppRenderer/hostContext.test.ts b/clients/web/src/components/elements/AppRenderer/hostContext.test.ts new file mode 100644 index 00000000..23041332 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/hostContext.test.ts @@ -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, +): CSSStyleDeclaration { + return { + getPropertyValue: (name: string) => values[name] ?? "", + } as unknown as CSSStyleDeclaration; +} + +function stubComputedStyle(values: Record) { + 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(); + }); +}); diff --git a/clients/web/src/components/elements/AppRenderer/hostContext.ts b/clients/web/src/components/elements/AppRenderer/hostContext.ts new file mode 100644 index 00000000..9ea6f8f0 --- /dev/null +++ b/clients/web/src/components/elements/AppRenderer/hostContext.ts @@ -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 ``. 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> = { + "--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 } : {}), + }; +}