fix(desktop): apply app color scheme to inline previews

The ::preview iframe's theme prelude carried tokens, font, and a
transparent background but no color-scheme, so the frame document fell
back to the UA default color-scheme:light — form controls, scrollbars,
the canvas beneath the transparent body, and prefers-color-scheme
inside the frame ignored the app's dark theme (#95814). A transparent
background alone does not fix this.

Inject :root{color-scheme:<app scheme>} as part of the prelude (first,
so a preview page's own color-scheme declaration still wins) and key
the framedDoc memo on the scheme so a theme switch rebuilds the srcdoc.

Mirrors #97463.

Co-authored-by: 墨綠BG <103036558+BlackishGreen33@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson
2026-09-24 17:05:04 -05:00
committed by brooklyn!
parent a78aa6927b
commit dffe2c346e
2 changed files with 37 additions and 15 deletions

View File

@@ -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 = '<html><head><style>body{color:red}</style></head><body><h1>hi</h1></body></html>'
@@ -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 = '<html><head><style>:root{color-scheme:dark}</style></head><body><h1>hi</h1></body></html>'
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')
})
})

View File

@@ -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<string, string>; 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<string, string>, font: string): string {
export function themePrelude(vars: Record<string, string>, 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<string, string>, font: string): string
const fontRule = font ? `font-family:${font};` : ''
return (
`<style>:root{${tokens}}` +
`<style>:root{color-scheme:${colorScheme};${tokens}}` +
`html,body{margin:0;padding:0;background:transparent;color:var(--foreground,inherit);${fontRule}}</style>`
)
}
@@ -277,6 +281,7 @@ function InlineHtmlFrame({
}) {
const cwd = useStore(useSessionView().$cwd)
const isDark = useIsDark()
const colorScheme = isDark ? 'dark' : 'light'
const [doc, setDoc] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
const [measured, setMeasured] = useState<number | null>(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 <PreviewAttachment target={file} />
@@ -408,7 +414,7 @@ function InlineHtmlFrame({
loading="lazy"
sandbox="allow-scripts"
srcDoc={framedDoc}
style={{ colorScheme: isDark ? 'dark' : 'light' }}
style={{ colorScheme }}
title={file}
/>
</span>