-
-
+
+ >
+ Reply to sunpeak...
+
diff --git a/packages/sunpeak/src/claude/claude-host.ts b/packages/sunpeak/src/claude/claude-host.ts
index 27f6098..ad3527c 100644
--- a/packages/sunpeak/src/claude/claude-host.ts
+++ b/packages/sunpeak/src/claude/claude-host.ts
@@ -3,18 +3,30 @@ import { registerHostShell } from '../inspector/hosts';
import { DEFAULT_STYLE_VARIABLES } from '../inspector/host-styles';
import { ClaudeConversation } from './claude-conversation';
+/**
+ * Claude host version info — matches what Claude reports via the MCP protocol.
+ * Verified against production Claude on 2026-03-25.
+ */
const CLAUDE_HOST_INFO = {
name: 'Claude',
version: '1.0.0',
};
+/**
+ * Claude host capabilities — matches what Claude reports via the MCP protocol.
+ * Verified against production Claude on 2026-03-25.
+ *
+ * Notable: Claude supports downloadFile, updateModelContext.image, and
+ * message.text. serverTools and serverResources both report listChanged.
+ * No sandbox.permissions (no microphone etc.). No PiP display mode.
+ */
const CLAUDE_HOST_CAPABILITIES: McpUiHostCapabilities = {
openLinks: {},
- serverTools: {},
- serverResources: {},
downloadFile: {},
+ serverTools: { listChanged: true },
+ serverResources: { listChanged: true },
logging: {},
- updateModelContext: { text: {} },
+ updateModelContext: { text: {}, image: {} },
message: { text: {} },
sandbox: {},
};
@@ -29,25 +41,90 @@ function applyClaudeTheme(theme: 'light' | 'dark'): void {
}
/**
- * Claude-specific style variable overrides (warm beige/cream palette).
- * Inherits defaults from DEFAULT_STYLE_VARIABLES, overriding only colors.
+ * Claude style variable overrides — warm beige/cream palette with Anthropic Sans.
+ * Verified against production Claude on 2026-03-25.
+ *
+ * Only overrides values that differ from DEFAULT_STYLE_VARIABLES.
+ * Claude sends all variables via styles.variables using light-dark(rgba()) format.
*/
const CLAUDE_STYLE_VARIABLES = {
...DEFAULT_STYLE_VARIABLES,
- // Background colors — warm beige/cream palette
- '--color-background-primary': 'light-dark(#faf9f5, #262624)',
- '--color-background-secondary': 'light-dark(#ffffff, #3a3935)',
- '--color-background-tertiary': 'light-dark(#e8e4dc, #4a4843)',
- '--color-background-inverse': 'light-dark(#2b2a27, #f3f0e8)',
- // Text colors
- '--color-text-primary': 'light-dark(#2d2b27, #e8e4dc)',
- '--color-text-secondary': 'light-dark(#6b6560, #9b9690)',
- '--color-text-tertiary': 'light-dark(#9b9690, #6b6560)',
- '--color-text-inverse': 'light-dark(#e8e4dc, #2d2b27)',
- // Border colors
- '--color-border-primary': 'light-dark(#e0ddd5, #4a4843)',
- '--color-border-secondary': 'light-dark(#d5d1c8, #5a5753)',
- '--color-border-tertiary': 'light-dark(#f0ede5, #3a3935)',
+
+ // ── Background colors ──
+ '--color-background-primary': 'light-dark(rgba(255, 255, 255, 1), rgba(48, 48, 46, 1))',
+ '--color-background-secondary': 'light-dark(rgba(245, 244, 237, 1), rgba(38, 38, 36, 1))',
+ '--color-background-tertiary': 'light-dark(rgba(250, 249, 245, 1), rgba(20, 20, 19, 1))',
+ '--color-background-inverse': 'light-dark(rgba(20, 20, 19, 1), rgba(250, 249, 245, 1))',
+ '--color-background-ghost': 'light-dark(rgba(255, 255, 255, 0), rgba(48, 48, 46, 0))',
+ '--color-background-info': 'light-dark(rgba(214, 228, 246, 1), rgba(37, 62, 95, 1))',
+ '--color-background-danger': 'light-dark(rgba(247, 236, 236, 1), rgba(96, 42, 40, 1))',
+ '--color-background-success': 'light-dark(rgba(233, 241, 220, 1), rgba(27, 70, 20, 1))',
+ '--color-background-warning': 'light-dark(rgba(246, 238, 223, 1), rgba(72, 58, 15, 1))',
+ '--color-background-disabled': 'light-dark(rgba(255, 255, 255, 0.5), rgba(48, 48, 46, 0.5))',
+
+ // ── Text colors ──
+ '--color-text-primary': 'light-dark(rgba(20, 20, 19, 1), rgba(250, 249, 245, 1))',
+ '--color-text-secondary': 'light-dark(rgba(61, 61, 58, 1), rgba(194, 192, 182, 1))',
+ '--color-text-tertiary': 'light-dark(rgba(115, 114, 108, 1), rgba(156, 154, 146, 1))',
+ '--color-text-inverse': 'light-dark(rgba(255, 255, 255, 1), rgba(20, 20, 19, 1))',
+ '--color-text-ghost': 'light-dark(rgba(115, 114, 108, 0.5), rgba(156, 154, 146, 0.5))',
+ '--color-text-info': 'light-dark(rgba(50, 102, 173, 1), rgba(128, 170, 221, 1))',
+ '--color-text-danger': 'light-dark(rgba(127, 44, 40, 1), rgba(238, 136, 132, 1))',
+ '--color-text-success': 'light-dark(rgba(38, 91, 25, 1), rgba(122, 185, 72, 1))',
+ '--color-text-warning': 'light-dark(rgba(90, 72, 21, 1), rgba(209, 160, 65, 1))',
+ '--color-text-disabled': 'light-dark(rgba(20, 20, 19, 0.5), rgba(250, 249, 245, 0.5))',
+
+ // ── Border colors ──
+ '--color-border-primary': 'light-dark(rgba(31, 30, 29, 0.4), rgba(222, 220, 209, 0.4))',
+ '--color-border-secondary': 'light-dark(rgba(31, 30, 29, 0.3), rgba(222, 220, 209, 0.3))',
+ '--color-border-tertiary': 'light-dark(rgba(31, 30, 29, 0.15), rgba(222, 220, 209, 0.15))',
+ '--color-border-inverse': 'light-dark(rgba(255, 255, 255, 0.3), rgba(20, 20, 19, 0.15))',
+ '--color-border-ghost': 'light-dark(rgba(31, 30, 29, 0), rgba(222, 220, 209, 0))',
+ '--color-border-info': 'light-dark(rgba(70, 130, 213, 1), rgba(70, 130, 213, 1))',
+ '--color-border-danger': 'light-dark(rgba(167, 61, 57, 1), rgba(205, 92, 88, 1))',
+ '--color-border-success': 'light-dark(rgba(67, 116, 38, 1), rgba(89, 145, 48, 1))',
+ '--color-border-warning': 'light-dark(rgba(128, 92, 31, 1), rgba(168, 120, 41, 1))',
+ '--color-border-disabled': 'light-dark(rgba(31, 30, 29, 0.1), rgba(222, 220, 209, 0.1))',
+
+ // ── Ring colors ──
+ '--color-ring-primary': 'light-dark(rgba(20, 20, 19, 0.7), rgba(250, 249, 245, 0.7))',
+ '--color-ring-secondary': 'light-dark(rgba(61, 61, 58, 0.7), rgba(194, 192, 182, 0.7))',
+ '--color-ring-inverse': 'light-dark(rgba(255, 255, 255, 0.7), rgba(20, 20, 19, 0.7))',
+ '--color-ring-info': 'light-dark(rgba(50, 102, 173, 0.5), rgba(128, 170, 221, 0.5))',
+ '--color-ring-danger': 'light-dark(rgba(167, 61, 57, 0.5), rgba(205, 92, 88, 0.5))',
+ '--color-ring-success': 'light-dark(rgba(67, 116, 38, 0.5), rgba(89, 145, 48, 0.5))',
+ '--color-ring-warning': 'light-dark(rgba(128, 92, 31, 0.5), rgba(168, 120, 41, 0.5))',
+
+ // ── Typography ──
+ '--font-sans': '"Anthropic Sans", system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
+ '--font-mono': 'ui-monospace, monospace',
+ // Sizes (px, not rem — Claude uses fixed px values)
+ '--font-text-xs-size': '12px',
+ '--font-text-sm-size': '14px',
+ '--font-text-md-size': '16px',
+ '--font-text-lg-size': '20px',
+ '--font-heading-xs-size': '12px',
+ '--font-heading-sm-size': '14px',
+ '--font-heading-md-size': '16px',
+ '--font-heading-lg-size': '20px',
+ '--font-heading-xl-size': '24px',
+ '--font-heading-2xl-size': '28px',
+ '--font-heading-3xl-size': '36px',
+ // Line heights
+ '--font-text-md-line-height': '1.4',
+ '--font-text-lg-line-height': '1.25',
+ '--font-heading-lg-line-height': '1.25',
+ '--font-heading-2xl-line-height': '1.1',
+ '--font-heading-3xl-line-height': '1',
+
+ // ── Border radius (Claude uses slightly larger values) ──
+ '--border-radius-xs': '4px',
+ '--border-radius-sm': '6px',
+ '--border-radius-md': '8px',
+ '--border-radius-lg': '10px',
+
+ // ── Border width ──
+ '--border-width-regular': '0.5px',
};
registerHostShell({
@@ -57,12 +134,28 @@ registerHostShell({
applyTheme: applyClaudeTheme,
hostInfo: CLAUDE_HOST_INFO,
hostCapabilities: CLAUDE_HOST_CAPABILITIES,
- userAgent: 'claude',
styleVariables: CLAUDE_STYLE_VARIABLES,
pageStyles: {
- '--sim-bg-sidebar': 'light-dark(#f9f8f3, #252523)',
- '--sim-bg-conversation': 'light-dark(#faf9f5, #262624)',
- '--sim-bg-user-bubble': 'light-dark(#f1eee6, #141413)',
- '--sim-bg-reply-input': 'light-dark(#ffffff, #30302e)',
+ '--sim-bg-sidebar': 'light-dark(rgb(250, 249, 245), rgb(38, 38, 36))',
+ '--sim-bg-conversation': 'light-dark(rgb(250, 249, 245), rgb(38, 38, 36))',
+ '--sim-bg-user-bubble': 'light-dark(rgb(240, 238, 230), rgb(20, 20, 19))',
+ '--sim-bg-reply-input': 'light-dark(rgb(255, 255, 255), rgb(48, 48, 46))',
},
+ availableDisplayModes: ['inline', 'fullscreen'],
+ fontCss: `@font-face {
+ font-family: "Anthropic Sans";
+ src: url("https://assets-proxy.anthropic.com/claude-ai/v2/assets/v1/cc27851ad-CFxw3nG7.woff2") format("woff2");
+ font-weight: 300 800;
+ font-style: normal;
+ font-display: swap;
+ font-feature-settings: "dlig" 0;
+}
+@font-face {
+ font-family: "Anthropic Sans";
+ src: url("https://assets-proxy.anthropic.com/claude-ai/v2/assets/v1/c9d3a3a49-BI1hrwN4.woff2") format("woff2");
+ font-weight: 300 800;
+ font-style: italic;
+ font-display: swap;
+ font-feature-settings: "dlig" 0;
+}`,
});
diff --git a/packages/sunpeak/src/inspector/hosts.ts b/packages/sunpeak/src/inspector/hosts.ts
index 8e7c2ec..4683064 100644
--- a/packages/sunpeak/src/inspector/hosts.ts
+++ b/packages/sunpeak/src/inspector/hosts.ts
@@ -80,6 +80,18 @@ export interface HostShell {
* Values should use CSS light-dark() for automatic theme adaptation.
*/
pageStyles?: Record
;
+ /**
+ * Display modes this host supports.
+ * The sidebar display mode picker only shows modes in this list.
+ * Defaults to ['inline', 'pip', 'fullscreen'] if not specified.
+ */
+ availableDisplayModes?: McpUiDisplayMode[];
+ /**
+ * CSS containing @font-face rules for the host's custom fonts.
+ * Injected into the inspector page when this host is active so the
+ * conversation chrome can use the same font as the real host.
+ */
+ fontCss?: string;
}
// ── Host Shell Registry ──────────────────────────────────────────
diff --git a/packages/sunpeak/src/inspector/inspector.tsx b/packages/sunpeak/src/inspector/inspector.tsx
index 3d7a639..5ceb86d 100644
--- a/packages/sunpeak/src/inspector/inspector.tsx
+++ b/packages/sunpeak/src/inspector/inspector.tsx
@@ -340,7 +340,7 @@ export function Inspector({
const registeredHosts = getRegisteredHosts();
const ShellConversation = activeShell?.Conversation;
- // Merge host style variables and userAgent into the hostContext.
+ // Merge host style variables, userAgent, and availableDisplayModes into hostContext.
const hostContext = React.useMemo(() => {
const styleVars = activeShell?.styleVariables;
const userAgent = activeShell?.userAgent;
@@ -351,11 +351,25 @@ export function Inspector({
if (userAgent) {
(ctx as McpUiHostContext).userAgent = userAgent;
}
+ if (activeShell?.availableDisplayModes) {
+ (ctx as McpUiHostContext).availableDisplayModes = activeShell.availableDisplayModes;
+ }
return ctx as McpUiHostContext;
}, [state.hostContext, activeShell]);
- // Apply host style variables to the document root.
+ // Reset display mode to inline if the active host doesn't support it.
+ const { displayMode, setDisplayMode } = state;
React.useEffect(() => {
+ const modes = activeShell?.availableDisplayModes;
+ if (modes && !modes.includes(displayMode)) {
+ setDisplayMode('inline');
+ }
+ }, [activeShell, displayMode, setDisplayMode]);
+
+ // Apply host style variables to the document root.
+ // Uses useLayoutEffect so variables are set BEFORE paint, preventing a flash
+ // of stale colors when switching hosts and then toggling theme.
+ React.useLayoutEffect(() => {
const vars = activeShell?.styleVariables;
if (!vars) return;
const root = document.documentElement;
@@ -365,8 +379,9 @@ export function Inspector({
}, [activeShell]);
// Apply host page styles. Cleans up old properties when switching hosts.
+ // Uses useLayoutEffect to stay in sync with style variables above.
const prevPageStyleKeysRef = React.useRef([]);
- React.useEffect(() => {
+ React.useLayoutEffect(() => {
const root = document.documentElement;
for (const key of prevPageStyleKeysRef.current) {
root.style.removeProperty(key);
@@ -384,6 +399,26 @@ export function Inspector({
}
}, [activeShell]);
+ // Inject host font CSS (@font-face rules) so the conversation chrome
+ // uses the same font as the real host (e.g., Anthropic Sans for Claude).
+ React.useLayoutEffect(() => {
+ const fontCss = activeShell?.fontCss;
+ const id = 'sunpeak-host-fonts';
+ const existing = document.getElementById(id);
+ if (!fontCss) {
+ existing?.remove();
+ return;
+ }
+ if (existing) {
+ existing.textContent = fontCss;
+ } else {
+ const style = document.createElement('style');
+ style.id = id;
+ style.textContent = fontCss;
+ document.head.appendChild(style);
+ }
+ }, [activeShell]);
+
// Handle callServerTool from the iframe.
// When a simulation is active: prefer serverTools mocks, fall back to MCP.
// When "None": always use MCP (real handlers).
@@ -838,7 +873,11 @@ export function Inspector({
{ value: 'inline', label: 'Inline' },
{ value: 'pip', label: 'PiP' },
{ value: 'fullscreen', label: 'Full' },
- ]}
+ ].filter(
+ (opt) =>
+ !activeShell?.availableDisplayModes ||
+ activeShell.availableDisplayModes.includes(opt.value as McpUiDisplayMode)
+ )}
/>
diff --git a/packages/sunpeak/src/inspector/sandbox-proxy.ts b/packages/sunpeak/src/inspector/sandbox-proxy.ts
index b00814f..c3a81b6 100644
--- a/packages/sunpeak/src/inspector/sandbox-proxy.ts
+++ b/packages/sunpeak/src/inspector/sandbox-proxy.ts
@@ -42,7 +42,7 @@ export function generateSandboxProxyHtml(platformScript?: string): string {
@@ -105,6 +105,16 @@ iframe { border: none; width: 100%; height: 100%; display: block; }
return;
}
+ // Sync color-scheme on the inner iframe element when theme changes.
+ // This ensures prefers-color-scheme resolves correctly inside the app.
+ // Important: do NOT set color-scheme on the proxy's own document —
+ // changing it from the initial 'dark' causes Chrome to re-evaluate
+ // the CSS Canvas as opaque white, blocking the host's conversation
+ // background from showing through the transparent proxy.
+ if (data.method === 'ui/notifications/host-context-changed' && data.params && data.params.theme) {
+ if (innerFrame) innerFrame.style.colorScheme = data.params.theme;
+ }
+
// Forward all other messages to the inner iframe
if (innerWindow) {
try { innerWindow.postMessage(data, '*'); } catch(e) { /* detached */ }
diff --git a/packages/sunpeak/template/tests/e2e/albums.spec.ts b/packages/sunpeak/template/tests/e2e/albums.spec.ts
index aef10a0..066545d 100644
--- a/packages/sunpeak/template/tests/e2e/albums.spec.ts
+++ b/packages/sunpeak/template/tests/e2e/albums.spec.ts
@@ -222,21 +222,25 @@ for (const host of hosts) {
});
});
- test('should render content after switching from inline to pip', async ({ page }) => {
- // Start in inline mode
- await page.goto(createInspectorUrl({ simulation: 'show-albums', theme: 'dark', host }));
-
- const iframe = page.frameLocator('iframe').frameLocator('iframe');
- await expect(iframe.locator('button:has-text("Summer Slice")')).toBeVisible();
-
- // Switch to PiP via sidebar
- await page.locator('button:has-text("PiP")').click();
-
- // Content should still be visible after the mode transition
- await expect(iframe.locator('button:has-text("Summer Slice")')).toBeVisible({
- timeout: 5000,
- });
- });
+ // Claude doesn't support PiP — only run this test for hosts that have the button.
+ (host === 'claude' ? test.skip : test)(
+ 'should render content after switching from inline to pip',
+ async ({ page }) => {
+ // Start in inline mode
+ await page.goto(createInspectorUrl({ simulation: 'show-albums', theme: 'dark', host }));
+
+ const iframe = page.frameLocator('iframe').frameLocator('iframe');
+ await expect(iframe.locator('button:has-text("Summer Slice")')).toBeVisible();
+
+ // Switch to PiP via sidebar
+ await page.locator('button:has-text("PiP")').click();
+
+ // Content should still be visible after the mode transition
+ await expect(iframe.locator('button:has-text("Summer Slice")')).toBeVisible({
+ timeout: 5000,
+ });
+ }
+ );
});
});
}
diff --git a/packages/sunpeak/template/tests/live/albums.spec.ts b/packages/sunpeak/template/tests/live/albums.spec.ts
index 4a337fb..9fe7164 100644
--- a/packages/sunpeak/template/tests/live/albums.spec.ts
+++ b/packages/sunpeak/template/tests/live/albums.spec.ts
@@ -25,6 +25,16 @@ test('albums tool renders photo grid with correct styles', async ({ live }) => {
});
expect(containerStyles.overflow).toBe('hidden');
+ // Background: the app's root should have a resolved background color
+ // (from --color-background-primary or the CSS Canvas system color),
+ // not transparent. A transparent root would show the host container
+ // rather than the app's own styled background.
+ const rootBg = await app
+ .locator(':root')
+ .evaluate((el) => window.getComputedStyle(el).backgroundColor);
+ expect(rootBg).not.toBe('rgba(0, 0, 0, 0)');
+ expect(rootBg).toMatch(/^rgb/);
+
// Theme: text color has appropriate luminance in light mode
const textColor = await albumCard.evaluate((el) => window.getComputedStyle(el).color);
assertTextContrast(textColor);