diff --git a/examples/basic-host/sandbox.html b/examples/basic-host/sandbox.html index e0790fb0..fb4cd8d2 100644 --- a/examples/basic-host/sandbox.html +++ b/examples/basic-host/sandbox.html @@ -12,6 +12,8 @@ margin: 0; height: 100vh; width: 100vw; + /* Transparent background allows parent page to show through */ + background-color: transparent; } body { display: flex; @@ -26,6 +28,8 @@ padding: 0px; overflow: hidden; flex-grow: 1; + /* Inherit color scheme from parent for consistent transparency */ + color-scheme: inherit; } diff --git a/examples/basic-host/src/global.css b/examples/basic-host/src/global.css index 97cda440..74b57514 100644 --- a/examples/basic-host/src/global.css +++ b/examples/basic-host/src/global.css @@ -1,10 +1,38 @@ +:root { + color-scheme: light dark; + + /* Light theme (default) */ + --color-bg: #ffffff; + --color-bg-secondary: #f5f5f5; + --color-text: #1f2937; + --color-text-secondary: #6b7280; + --color-border: #e5e7eb; + --color-primary: #1e3a5f; + --color-primary-hover: #2d4a7c; +} + +[data-theme="dark"] { + --color-bg: #1a1a1a; + --color-bg-secondary: #2d2d2d; + --color-text: #f3f4f6; + --color-text-secondary: #9ca3af; + --color-border: #404040; + --color-primary: #3b82f6; + --color-primary-hover: #60a5fa; +} + * { box-sizing: border-box; } html, body { + margin: 0; + padding: 0; font-family: system-ui, -apple-system, sans-serif; font-size: 1rem; + background-color: var(--color-bg); + color: var(--color-text); + transition: background-color 0.2s, color 0.2s; } code { diff --git a/examples/basic-host/src/host-styles.ts b/examples/basic-host/src/host-styles.ts new file mode 100644 index 00000000..f6d4887c --- /dev/null +++ b/examples/basic-host/src/host-styles.ts @@ -0,0 +1,113 @@ +/** + * MCP style variables for the basic-host example. + * These are passed to apps via hostContext.styles.variables. + */ +import type { McpUiStyles } from "@modelcontextprotocol/ext-apps"; + +/** + * MCP App style variables using light-dark() for theme adaptation. + * Apps receive these and can use them as CSS custom properties. + */ +export const HOST_STYLE_VARIABLES: McpUiStyles = { + // Background colors - using light-dark() for automatic adaptation + "--color-background-primary": "light-dark(#ffffff, #1a1a1a)", + "--color-background-secondary": "light-dark(#f5f5f5, #2d2d2d)", + "--color-background-tertiary": "light-dark(#e5e5e5, #404040)", + "--color-background-inverse": "light-dark(#1a1a1a, #ffffff)", + "--color-background-ghost": "light-dark(rgba(255,255,255,0), rgba(26,26,26,0))", + "--color-background-info": "light-dark(#eff6ff, #1e3a5f)", + "--color-background-danger": "light-dark(#fef2f2, #7f1d1d)", + "--color-background-success": "light-dark(#f0fdf4, #14532d)", + "--color-background-warning": "light-dark(#fefce8, #713f12)", + "--color-background-disabled": "light-dark(rgba(255,255,255,0.5), rgba(26,26,26,0.5))", + + // Text colors + "--color-text-primary": "light-dark(#1f2937, #f3f4f6)", + "--color-text-secondary": "light-dark(#6b7280, #9ca3af)", + "--color-text-tertiary": "light-dark(#9ca3af, #6b7280)", + "--color-text-inverse": "light-dark(#f3f4f6, #1f2937)", + "--color-text-ghost": "light-dark(rgba(107,114,128,0.5), rgba(156,163,175,0.5))", + "--color-text-info": "light-dark(#1d4ed8, #60a5fa)", + "--color-text-danger": "light-dark(#b91c1c, #f87171)", + "--color-text-success": "light-dark(#15803d, #4ade80)", + "--color-text-warning": "light-dark(#a16207, #fbbf24)", + "--color-text-disabled": "light-dark(rgba(31,41,55,0.5), rgba(243,244,246,0.5))", + + // Border colors + "--color-border-primary": "light-dark(#e5e7eb, #404040)", + "--color-border-secondary": "light-dark(#d1d5db, #525252)", + "--color-border-tertiary": "light-dark(#f3f4f6, #374151)", + "--color-border-inverse": "light-dark(rgba(255,255,255,0.3), rgba(0,0,0,0.3))", + "--color-border-ghost": "light-dark(rgba(229,231,235,0), rgba(64,64,64,0))", + "--color-border-info": "light-dark(#93c5fd, #1e40af)", + "--color-border-danger": "light-dark(#fca5a5, #991b1b)", + "--color-border-success": "light-dark(#86efac, #166534)", + "--color-border-warning": "light-dark(#fde047, #854d0e)", + "--color-border-disabled": "light-dark(rgba(229,231,235,0.5), rgba(64,64,64,0.5))", + + // Ring colors (focus) + "--color-ring-primary": "light-dark(#3b82f6, #60a5fa)", + "--color-ring-secondary": "light-dark(#6b7280, #9ca3af)", + "--color-ring-inverse": "light-dark(#ffffff, #1f2937)", + "--color-ring-info": "light-dark(#2563eb, #3b82f6)", + "--color-ring-danger": "light-dark(#dc2626, #ef4444)", + "--color-ring-success": "light-dark(#16a34a, #22c55e)", + "--color-ring-warning": "light-dark(#ca8a04, #eab308)", + + // Typography - Family + "--font-sans": "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", + "--font-mono": "ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', monospace", + + // Typography - Weight + "--font-weight-normal": "400", + "--font-weight-medium": "500", + "--font-weight-semibold": "600", + "--font-weight-bold": "700", + + // Typography - Text Size + "--font-text-xs-size": "0.75rem", + "--font-text-sm-size": "0.875rem", + "--font-text-md-size": "1rem", + "--font-text-lg-size": "1.125rem", + + // Typography - Heading Size + "--font-heading-xs-size": "0.75rem", + "--font-heading-sm-size": "0.875rem", + "--font-heading-md-size": "1rem", + "--font-heading-lg-size": "1.25rem", + "--font-heading-xl-size": "1.5rem", + "--font-heading-2xl-size": "1.875rem", + "--font-heading-3xl-size": "2.25rem", + + // Typography - Text Line Height + "--font-text-xs-line-height": "1.4", + "--font-text-sm-line-height": "1.4", + "--font-text-md-line-height": "1.5", + "--font-text-lg-line-height": "1.5", + + // Typography - Heading Line Height + "--font-heading-xs-line-height": "1.4", + "--font-heading-sm-line-height": "1.4", + "--font-heading-md-line-height": "1.4", + "--font-heading-lg-line-height": "1.3", + "--font-heading-xl-line-height": "1.25", + "--font-heading-2xl-line-height": "1.2", + "--font-heading-3xl-line-height": "1.1", + + // Border radius + "--border-radius-xs": "2px", + "--border-radius-sm": "4px", + "--border-radius-md": "6px", + "--border-radius-lg": "8px", + "--border-radius-xl": "12px", + "--border-radius-full": "9999px", + + // Border width + "--border-width-regular": "1px", + + // Shadows + "--shadow-hairline": "0 1px 2px 0 rgba(0, 0, 0, 0.05)", + "--shadow-sm": "0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)", + "--shadow-md": "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)", + "--shadow-lg": "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)", +}; diff --git a/examples/basic-host/src/implementation.ts b/examples/basic-host/src/implementation.ts index f9237370..434169a9 100644 --- a/examples/basic-host/src/implementation.ts +++ b/examples/basic-host/src/implementation.ts @@ -3,6 +3,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js"; +import { getTheme, onThemeChange } from "./theme"; +import { HOST_STYLE_VARIABLES } from "./host-styles"; const SANDBOX_PROXY_BASE_URL = "http://localhost:8081/sandbox.html"; @@ -270,13 +272,25 @@ export function newAppBridge( // Declare support for model context updates updateModelContext: { text: {} }, }, { + // Pass initial host context with theme, display mode, and style variables hostContext: { + theme: getTheme(), + platform: "web", + styles: { + variables: HOST_STYLE_VARIABLES, + }, containerDimensions: options?.containerDimensions ?? { maxHeight: 6000 }, displayMode: options?.displayMode ?? "inline", availableDisplayModes: ["inline", "fullscreen"], }, }); + // Listen for theme changes (from toggle or system) and notify the app + onThemeChange((newTheme) => { + log.info("Theme changed:", newTheme); + appBridge.sendHostContextChange({ theme: newTheme }); + }); + // Register all handlers before calling connect(). The view can start // sending requests immediately after the initialization handshake, so any // handlers registered after connect() might miss early requests. diff --git a/examples/basic-host/src/index.module.css b/examples/basic-host/src/index.module.css index 75fa1de3..eb4d6795 100644 --- a/examples/basic-host/src/index.module.css +++ b/examples/basic-host/src/index.module.css @@ -1,8 +1,34 @@ +/* Theme toggle button */ +.themeToggle { + position: fixed; + top: 1rem; + right: 1rem; + width: 2.5rem; + height: 2.5rem; + padding: 0; + border: 1px solid var(--color-border); + border-radius: 50%; + background-color: var(--color-bg-secondary); + font-size: 1.25rem; + cursor: pointer; + z-index: 1000; + transition: background-color 0.2s, border-color 0.2s, transform 0.1s; + + &:hover { + background-color: var(--color-border); + } + + &:active { + transform: scale(0.95); + } +} + .callToolPanel, .toolCallInfoPanel { margin: 0 auto; padding: 1rem; - border: 1px solid #ddd; + border: 1px solid var(--color-border); border-radius: 4px; + background-color: var(--color-bg-secondary); * + & { margin-top: 1rem; @@ -28,9 +54,11 @@ select, textarea { padding: 0.5rem; - border: 1px solid #ccc; + border: 1px solid var(--color-border); border-radius: 4px; font-size: inherit; + background-color: var(--color-bg); + color: var(--color-text); } .toolSelect { @@ -43,7 +71,7 @@ resize: vertical; &[aria-invalid="true"] { - background-color: #fdd; + background-color: light-dark(#fee2e2, #7f1d1d); } } @@ -53,14 +81,14 @@ padding: 0.75rem 1.5rem; border: none; border-radius: 4px; - background-color: #1e3a5f; + background-color: var(--color-primary); font-size: inherit; font-weight: 600; color: white; cursor: pointer; &:hover:not(:disabled) { - background-color: #2d4a7c; + background-color: var(--color-primary-hover); } &:disabled { @@ -103,15 +131,15 @@ padding: 0; border: none; border-radius: 4px; - background: #e0e0e0; + background: var(--color-border); font-size: 1.25rem; line-height: 1; - color: #666; + color: var(--color-text-secondary); cursor: pointer; &:hover { - background: #d0d0d0; - color: #333; + background: var(--color-bg-secondary); + color: var(--color-text); } } } @@ -123,7 +151,7 @@ width: 100%; height: 600px; box-sizing: border-box; - border: 3px dashed #888; + border: 3px dashed var(--color-border); border-radius: 4px; } @@ -246,6 +274,6 @@ .error { padding: 1.5rem; - background-color: #ddd; - color: #d00; + background-color: var(--color-bg-secondary); + color: light-dark(#dc2626, #f87171); } diff --git a/examples/basic-host/src/index.tsx b/examples/basic-host/src/index.tsx index cbed7c79..0b4fda77 100644 --- a/examples/basic-host/src/index.tsx +++ b/examples/basic-host/src/index.tsx @@ -3,6 +3,7 @@ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation"; +import { getTheme, toggleTheme, onThemeChange, type Theme } from "./theme"; import styles from "./index.module.css"; /** @@ -64,6 +65,28 @@ function getQueryParams() { }; } +/** + * Theme toggle button with light/dark icons. + */ +function ThemeToggle() { + const [theme, setTheme] = useState(getTheme); + + useEffect(() => { + return onThemeChange(setTheme); + }, []); + + return ( + + ); +} + + function Host({ serversPromise }: HostProps) { const [toolCalls, setToolCalls] = useState([]); const [destroyingIds, setDestroyingIds] = useState>(new Set()); @@ -84,6 +107,7 @@ function Host({ serversPromise }: HostProps) { return ( <> + {toolCalls.map((info) => ( void; + +const listeners = new Set(); + +// Get initial theme from system preference +let currentTheme: Theme = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + +// Apply theme to document +function applyTheme(theme: Theme) { + document.documentElement.setAttribute("data-theme", theme); + document.documentElement.style.colorScheme = theme; +} + +// Initial application +applyTheme(currentTheme); + +/** + * Get current theme. + */ +export function getTheme(): Theme { + return currentTheme; +} + +/** + * Set theme and notify all listeners. + */ +export function setTheme(theme: Theme): void { + if (theme === currentTheme) return; + currentTheme = theme; + applyTheme(theme); + listeners.forEach((listener) => listener(theme)); +} + +/** + * Toggle between light and dark themes. + */ +export function toggleTheme(): Theme { + const newTheme = currentTheme === "dark" ? "light" : "dark"; + setTheme(newTheme); + return newTheme; +} + +/** + * Subscribe to theme changes. + * Returns unsubscribe function. + */ +export function onThemeChange(listener: ThemeListener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +// Also listen for system preference changes +window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => { + setTheme(e.matches ? "dark" : "light"); +}); diff --git a/tests/e2e/servers.spec.ts-snapshots/basic-react.png b/tests/e2e/servers.spec.ts-snapshots/basic-react.png index bfc28bb1..a03d8176 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/basic-react.png and b/tests/e2e/servers.spec.ts-snapshots/basic-react.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/basic-vanillajs.png b/tests/e2e/servers.spec.ts-snapshots/basic-vanillajs.png index 57586967..787a0f95 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/basic-vanillajs.png and b/tests/e2e/servers.spec.ts-snapshots/basic-vanillajs.png differ diff --git a/tests/e2e/servers.spec.ts-snapshots/integration.png b/tests/e2e/servers.spec.ts-snapshots/integration.png index 60b52e7c..45362b7e 100644 Binary files a/tests/e2e/servers.spec.ts-snapshots/integration.png and b/tests/e2e/servers.spec.ts-snapshots/integration.png differ