feat(basic-host): Add theme toggle and MCP style variables (#336)
* feat(basic-host): add theme toggle and MCP style variables Add theme support to basic-host example: - theme.ts: Global theme manager with light/dark toggle - host-styles.ts: Full set of MCP CSS variables using light-dark() - ThemeToggle component with sun/moon icons - Theme-aware CSS using CSS custom properties - Pass theme + styles to apps via hostContext - Notify apps when theme changes via sendHostContextChange Apps now receive: - hostContext.theme: 'light' | 'dark' - hostContext.styles.variables: All 60+ MCP CSS variables - onhostcontextchanged notifications when theme changes * test: update e2e goldens for theme changes
This commit is contained in:
parent
ee5d3f85ac
commit
7bbf2bb250
10 changed files with 287 additions and 12 deletions
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
113
examples/basic-host/src/host-styles.ts
Normal file
113
examples/basic-host/src/host-styles.ts
Normal file
|
|
@ -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)",
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Theme>(getTheme);
|
||||
|
||||
useEffect(() => {
|
||||
return onThemeChange(setTheme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={styles.themeToggle}
|
||||
onClick={() => toggleTheme()}
|
||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||
>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function Host({ serversPromise }: HostProps) {
|
||||
const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]);
|
||||
const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set());
|
||||
|
|
@ -84,6 +107,7 @@ function Host({ serversPromise }: HostProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ThemeToggle />
|
||||
{toolCalls.map((info) => (
|
||||
<ToolCallInfoPanel
|
||||
key={info.id}
|
||||
|
|
|
|||
64
examples/basic-host/src/theme.ts
Normal file
64
examples/basic-host/src/theme.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Simple theme manager for the basic-host example.
|
||||
* Manages light/dark theme state and notifies listeners.
|
||||
*/
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
type ThemeListener = (theme: Theme) => void;
|
||||
|
||||
const listeners = new Set<ThemeListener>();
|
||||
|
||||
// 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");
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 44 KiB |
Loading…
Reference in a new issue