diff --git a/apps/desktop/src/components/assistant-ui/inline-preview-directive.test.ts b/apps/desktop/src/components/assistant-ui/inline-preview-directive.test.ts index 98d3ba0706..6b3cf15352 100644 --- a/apps/desktop/src/components/assistant-ui/inline-preview-directive.test.ts +++ b/apps/desktop/src/components/assistant-ui/inline-preview-directive.test.ts @@ -24,7 +24,7 @@ describe('directiveFrameHeight', () => { }) describe('withInlineChrome', () => { - const prelude = themePrelude({ '--foreground': '#eee' }, 'Inter') + const prelude = themePrelude({ '--foreground': '#eee' }, 'Inter', 'dark') it('puts the theme prelude FIRST so page styles override it', () => { const doc = '

hi

' @@ -52,8 +52,24 @@ describe('withInlineChrome', () => { }) describe('themePrelude', () => { + it.each(['light', 'dark'] as const)('injects the app color scheme %s into the frame document', colorScheme => { + expect(themePrelude({}, '', colorScheme)).toContain(`color-scheme:${colorScheme}`) + }) + + it('puts the injected color scheme where a page declaration overrides it', () => { + const doc = '

hi

' + const framed = withInlineChrome(doc, 'tok', themePrelude({}, '', 'light')) + + // The injected default comes first; the page's own :root rule wins. + expect(framed.indexOf('color-scheme:light')).toBeLessThan(framed.indexOf('color-scheme:dark')) + }) + it('carries resolved tokens, transparent background, and the app font', () => { - const prelude = themePrelude({ '--foreground': 'oklch(0.9 0 0)', '--accent': '#7aa2f7' }, 'Inter, sans-serif') + const prelude = themePrelude( + { '--foreground': 'oklch(0.9 0 0)', '--accent': '#7aa2f7' }, + 'Inter, sans-serif', + 'dark' + ) expect(prelude).toContain('--foreground:oklch(0.9 0 0)') expect(prelude).toContain('--accent:#7aa2f7') @@ -62,7 +78,7 @@ describe('themePrelude', () => { }) it('omits the font rule when no font resolved', () => { - expect(themePrelude({}, '')).not.toContain('font-family') + expect(themePrelude({}, '', 'light')).not.toContain('font-family') }) }) diff --git a/apps/desktop/src/components/assistant-ui/inline-preview-directive.tsx b/apps/desktop/src/components/assistant-ui/inline-preview-directive.tsx index 0005d2c91e..30d5582109 100644 --- a/apps/desktop/src/components/assistant-ui/inline-preview-directive.tsx +++ b/apps/desktop/src/components/assistant-ui/inline-preview-directive.tsx @@ -28,10 +28,10 @@ import { localPreviewTarget } from '@/lib/local-preview' * * NATIVE BY DEFAULT. A theme prelude injects first: the app's resolved * theme tokens under friendly names (--foreground, --muted-foreground, - * --accent, --border, --card), the app font, zero body margin/padding, and - * a transparent background — so widget-shaped content reads as part of the - * app. The page's own styles override all of it, so a full page keeps its - * own design. + * --accent, --border, --card), the app's color scheme, the app font, zero + * body margin/padding, and a transparent background — so widget-shaped + * content reads as part of the app. The page's own styles override all of + * it, so a full page keeps its own design. * * WIDGETS TALK BACK OFF-SCREEN. `window.hermes.send(prompt)` (or declarative * `data-hermes-send` on any clickable element) routes the prompt through the @@ -150,12 +150,16 @@ export function collectThemeBridge(): { vars: Record; font: stri /** * The style prelude that makes an inline widget read as NATIVE: the app's - * resolved theme tokens as CSS vars, the app font, no margin, and a + * resolved theme tokens as CSS vars, the app's color scheme (so UA controls, + * scrollbars, form defaults, and `prefers-color-scheme` inside the frame + * follow the app instead of the UA light default — a transparent background + * alone does not do this, #95814), the app font, no margin, and a * transparent background so the widget sits directly on the chat surface. * Injected FIRST, so the page's own styles override every default here — a - * full page that wants its own look keeps it. + * full page that wants its own look keeps it, including its own + * `color-scheme` declaration. */ -export function themePrelude(vars: Record, font: string): string { +export function themePrelude(vars: Record, font: string, colorScheme: 'light' | 'dark'): string { const tokens = Object.entries(vars) .map(([name, value]) => `${name}:${value}`) .join(';') @@ -163,7 +167,7 @@ export function themePrelude(vars: Record, font: string): string const fontRule = font ? `font-family:${font};` : '' return ( - `` ) } @@ -277,6 +281,7 @@ function InlineHtmlFrame({ }) { const cwd = useStore(useSessionView().$cwd) const isDark = useIsDark() + const colorScheme = isDark ? 'dark' : 'light' const [doc, setDoc] = useState(null) const [failed, setFailed] = useState(false) const [measured, setMeasured] = useState(null) @@ -370,7 +375,8 @@ function InlineHtmlFrame({ return () => window.removeEventListener('message', onMessage) }, [initialHeight, token]) - // Resolved once per mount; theme switches remount the transcript anyway. + // Rebuild the srcdoc when the color scheme changes so its native controls and + // transparent canvas stay aligned with the app. const framedDoc = useMemo(() => { if (doc === null) { return null @@ -378,8 +384,8 @@ function InlineHtmlFrame({ const { vars, font } = collectThemeBridge() - return withInlineChrome(doc, token, themePrelude(vars, font)) - }, [doc, token]) + return withInlineChrome(doc, token, themePrelude(vars, font, colorScheme)) + }, [colorScheme, doc, token]) if (!path || failed) { return @@ -408,7 +414,7 @@ function InlineHtmlFrame({ loading="lazy" sandbox="allow-scripts" srcDoc={framedDoc} - style={{ colorScheme: isDark ? 'dark' : 'light' }} + style={{ colorScheme }} title={file} />