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:
committed by
brooklyn!
parent
a78aa6927b
commit
dffe2c346e
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user