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}
/>