diff --git a/.changeset/preview-native-dark-mode.md b/.changeset/preview-native-dark-mode.md new file mode 100644 index 0000000000..d927139889 --- /dev/null +++ b/.changeset/preview-native-dark-mode.md @@ -0,0 +1,9 @@ +--- +'@react-email/ui': minor +--- + +Split the preview's dark mode control in two, so both of the behaviors email clients have can be previewed: emulated color inversion, as clients that ignore `prefers-color-scheme` apply it, and the dark styles the email itself defines, as clients that honor it render them. The second is new — until now the toggle could only ever invert the light theme, so an email shipping real `@media (prefers-color-scheme: dark)` rules had no way to be previewed. + +Which of the email's color scheme rules apply is now decided by the preview rather than by the machine it runs on. Before, an email's dark rules could match whenever the reader's own OS was set to dark, so with dark mode off the preview showed the dark theme, and the inversion emulation recolored an already-dark one. This was only ever visible in browsers that don't propagate `color-scheme` into embedded documents — which today still includes Safari. + +Turning the color inversion off also no longer leaves a white background on an email that never painted one. The inversion gives `` the colors it already renders at so it has something to invert, and those were being restored on the way out as though the email had authored them. diff --git a/.gitignore b/.gitignore index 9c6d3b46be..8a1f3c38f4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ next-env.d.ts dist .vercel +# typescript incremental build state +*.tsbuildinfo + # misc .DS_Store *.pem diff --git a/packages/ui/src/app/preview/[...slug]/email-frame.spec.ts b/packages/ui/src/app/preview/[...slug]/email-frame.spec.ts new file mode 100644 index 0000000000..d59a15ad2f --- /dev/null +++ b/packages/ui/src/app/preview/[...slug]/email-frame.spec.ts @@ -0,0 +1,216 @@ +import type { ColorScheme } from '../../../utils/dark-mode-preview'; +import { syncDarkMode } from './email-frame'; + +const darkCardCss = '@media (prefers-color-scheme: dark){.card{color:#fff}}'; + +/** + * A loaded preview frame whose browser reports `reports` as the reader's color + * scheme — which is what the OS setting alone decides in every browser that + * doesn't propagate `color-scheme` into embedded documents, Safari included. + */ +const frameReporting = async (reports: ColorScheme, markup: string) => { + const iframe = document.createElement('iframe'); + iframe.srcdoc = markup; + document.body.appendChild(iframe); + await new Promise((resolve) => setTimeout(resolve, 20)); + + Object.defineProperty(iframe.contentWindow, 'matchMedia', { + configurable: true, + value: (query: string) => ({ + matches: query.includes('dark') && reports === 'dark', + }), + }); + + return iframe; +}; + +const mediaConditions = (iframe: HTMLIFrameElement) => + Array.from( + iframe.contentDocument!.styleSheets as unknown as CSSStyleSheet[], + ).flatMap((sheet) => + Array.from(sheet.cssRules as unknown as CSSMediaRule[]) + .map((rule) => rule.media?.mediaText) + .filter(Boolean), + ); + +/** + * Whether the inversion has walked the document and taken over its colors. + * + * Read through the per-element record the undo relies on rather than through + * the inverted values themselves: those come out of `colorjs.io` as `lch()`, + * which happy-dom's style parser drops on assignment, so the recoloring is not + * observable here. What it does show is that every element was visited and can + * be put back. + */ +const isInverted = (iframe: HTMLIFrameElement) => + iframe.contentDocument!.body.hasAttribute('data-applied-color-inversion') && + iframe.contentDocument!.body.hasAttribute('data-original-color'); + +const rootColorScheme = (iframe: HTMLIFrameElement) => + iframe.contentDocument!.documentElement.style.colorScheme; + +describe('syncDarkMode()', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('with the reader in light mode, as most are', () => { + it("makes the email's own dark rules apply in the native mode", async () => { + const iframe = await frameReporting( + 'light', + ``, + ); + + syncDarkMode(iframe, 'native'); + + expect(mediaConditions(iframe)).toEqual([ + '(prefers-color-scheme: light)', + ]); + expect(rootColorScheme(iframe)).toBe('dark'); + expect(isInverted(iframe)).toBe(false); + }); + + it('inverts the light theme, and leaves the dark rules unmatched, in the inversion mode', async () => { + const iframe = await frameReporting( + 'light', + ``, + ); + + syncDarkMode(iframe, 'inversion'); + + expect(isInverted(iframe)).toBe(true); + // Still the email's own condition, which a browser reporting light does + // not match — an inverting client never honors `prefers-color-scheme`. + expect(mediaConditions(iframe)).toEqual(['(prefers-color-scheme: dark)']); + expect(rootColorScheme(iframe)).toBe(''); + }); + + it('leaves the email alone when dark mode is off', async () => { + const iframe = await frameReporting( + 'light', + ``, + ); + + syncDarkMode(iframe, 'off'); + + expect(mediaConditions(iframe)).toEqual(['(prefers-color-scheme: dark)']); + expect(isInverted(iframe)).toBe(false); + expect(rootColorScheme(iframe)).toBe(''); + }); + }); + + describe('with the reader in dark mode, which the frame must not inherit', () => { + it("keeps the email's dark rules out of the preview when dark mode is off", async () => { + const iframe = await frameReporting( + 'dark', + ``, + ); + + syncDarkMode(iframe, 'off'); + + expect(mediaConditions(iframe)).toEqual([ + '(prefers-color-scheme: light)', + ]); + expect(rootColorScheme(iframe)).toBe(''); + }); + + it("inverts the light theme rather than the email's dark theme", async () => { + // Inverting an already-dark theme is the failure this guards: the email's + // dark rules have to stop matching before the inversion is what the + // reader sees. + const iframe = await frameReporting( + 'dark', + ``, + ); + + syncDarkMode(iframe, 'inversion'); + + expect(mediaConditions(iframe)).toEqual([ + '(prefers-color-scheme: light)', + ]); + expect(isInverted(iframe)).toBe(true); + }); + + it('rewrites nothing in the native mode, letting the browser match on its own', async () => { + const iframe = await frameReporting( + 'dark', + ``, + ); + + syncDarkMode(iframe, 'native'); + + expect(mediaConditions(iframe)).toEqual(['(prefers-color-scheme: dark)']); + expect(rootColorScheme(iframe)).toBe('dark'); + }); + }); + + it('restores the email as authored when the mode is switched back off', async () => { + const iframe = await frameReporting( + 'light', + ``, + ); + + syncDarkMode(iframe, 'native'); + syncDarkMode(iframe, 'inversion'); + syncDarkMode(iframe, 'off'); + + expect(mediaConditions(iframe)).toEqual(['(prefers-color-scheme: dark)']); + expect(isInverted(iframe)).toBe(false); + expect(rootColorScheme(iframe)).toBe(''); + expect(iframe.contentDocument!.body.style.color).toBe('#000'); + }); + + it('leaves no background behind on an email that never painted one', async () => { + // The inversion seeds `` with white so it has something to invert. + // Restoring that seed on the way out — instead of dropping it — leaves the + // email with a white background it never asked for, which then shows + // through as a white page under the native mode's dark theme. + const iframe = await frameReporting( + 'light', + ``, + ); + const body = iframe.contentDocument!.body; + + syncDarkMode(iframe, 'inversion'); + expect(body.style.background).not.toBe(''); + + syncDarkMode(iframe, 'native'); + + expect(body.style.background).toBe(''); + expect(body.style.color).toBe(''); + expect(body.getAttribute('data-seeded-background')).toBeNull(); + }); + + it("restores an email's own background after inverting it", async () => { + // The flip side: a background the email really did declare has to come + // back exactly as authored. + const iframe = await frameReporting( + 'light', + '', + ); + const body = iframe.contentDocument!.body; + + syncDarkMode(iframe, 'inversion'); + syncDarkMode(iframe, 'off'); + + expect(body.style.background).toBe('#fafafa'); + }); + + it('does nothing before the frame has a document', () => { + const iframe = document.createElement('iframe'); + + expect(() => syncDarkMode(iframe, 'native')).not.toThrow(); + }); + + it('does nothing while the frame is still loading its markup', () => { + // React re-attaches the ref on every render, so this runs against a frame + // whose `srcDoc` has not been parsed yet — a document that answers, but + // with no root element on it. + const stillLoading = { + contentDocument: { styleSheets: [], documentElement: null, body: null }, + contentWindow: { matchMedia: () => ({ matches: false }) }, + } as unknown as HTMLIFrameElement; + + expect(() => syncDarkMode(stillLoading, 'native')).not.toThrow(); + }); +}); diff --git a/packages/ui/src/app/preview/[...slug]/email-frame.tsx b/packages/ui/src/app/preview/[...slug]/email-frame.tsx index 03ac0c1671..d2903b6528 100644 --- a/packages/ui/src/app/preview/[...slug]/email-frame.tsx +++ b/packages/ui/src/app/preview/[...slug]/email-frame.tsx @@ -1,6 +1,12 @@ import { Slot } from '@radix-ui/react-slot'; import Color from 'colorjs.io'; import type { ComponentProps } from 'react'; +import { + type ColorScheme, + type DarkModePreview, + resolveDarkModeRendering, +} from '../../../utils/dark-mode-preview'; +import { forceColorScheme } from '../../../utils/force-color-scheme'; function* walkDom(element: Element): Generator { if (element.children.length > 0) { @@ -222,6 +228,26 @@ const styleProperties = new Map< [['color'], 'foreground'], ]); +// The inversion only recolors values it finds inline, so a `` that +// declares none of its own is seeded with the colors it already renders at, +// giving the walk below something to invert. Those are the preview's values and +// not the email's, so they get marked as such: undoing has to drop them rather +// than restore them, or an email that never painted a background of its own +// keeps a white one for as long as the frame lives. +const seededProperty = (property: StringStyleProperty) => + `data-seeded-${String(property)}`; + +function seedBodyColors(body: HTMLElement) { + if (!body.style.color) { + body.style.color = 'rgb(0, 0, 0)'; + body.setAttribute(seededProperty('color'), ''); + } + if (!body.style.background && !body.style.backgroundColor) { + body.style.background = 'rgb(255, 255, 255)'; + body.setAttribute(seededProperty('background'), ''); + } +} + function undoColorInversion(iframe: HTMLIFrameElement) { const { contentDocument, contentWindow } = iframe; if (!contentDocument || !contentWindow || !contentDocument.body) return; @@ -241,7 +267,10 @@ function undoColorInversion(iframe: HTMLIFrameElement) { for (const properties of styleProperties.keys()) { for (const property of properties) { const original = element.getAttribute(`data-original-${property}`); - if (original) { + if (element.hasAttribute(seededProperty(property))) { + element.style[property] = ''; + element.removeAttribute(seededProperty(property)); + } else if (original) { element.style[property] = original; } element.removeAttribute(`data-original-${property}`); @@ -261,15 +290,7 @@ function applyColorInversion(iframe: HTMLIFrameElement) { if (appliedColorInversion) return; contentDocument.body.setAttribute('data-applied-color-inversion', ''); - if (!contentDocument.body.style.color) { - contentDocument.body.style.color = 'rgb(0, 0, 0)'; - } - if ( - !contentDocument.body.style.background && - !contentDocument.body.style.backgroundColor - ) { - contentDocument.body.style.background = 'rgb(255, 255, 255)'; - } + seedBodyColors(contentDocument.body); for (const element of walkDom(contentDocument.documentElement)) { if ( @@ -295,11 +316,57 @@ function applyColorInversion(iframe: HTMLIFrameElement) { } } +// The backdrop an email sits on while a dark mode is active. Derived from the +// inversion already applied to a white page, so an email that paints no +// background of its own lands on the same color in both dark modes. +const invertedPageBackground = invertColor('rgb(255, 255, 255)', 'background'); + +function reportedColorScheme(window: Window): ColorScheme { + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; +} + +export function syncDarkMode(iframe: HTMLIFrameElement, mode: DarkModePreview) { + const { contentDocument, contentWindow } = iframe; + // A frame still loading its `srcDoc` hands back a document that has no root + // element yet, so there is nothing to read a stylesheet off or to mark. The + // `onLoad` handler runs this again once there is. + if (!contentDocument?.documentElement || !contentWindow) return; + + const { colorScheme, invertColors } = resolveDarkModeRendering(mode); + + // Which of the email's color scheme rules apply is decided here rather than + // left to the reader's OS setting — including in the modes that want none of + // them. A force-inverting client never honors `prefers-color-scheme`, so + // `inversion` asks for the light scheme and inverts that; without forcing it, + // a reader whose OS is set to dark would get the email's dark theme with the + // inversion recoloring it on top. + forceColorScheme(contentDocument, { + from: reportedColorScheme(contentWindow), + to: colorScheme, + }); + + if (invertColors) { + applyColorInversion(iframe); + } else { + undoColorInversion(iframe); + } + + // Mirroring the scheme onto the embedded document's root is what turns its + // canvas transparent so the frame's own backdrop shows through. Left alone, + // the browser paints an opaque white canvas over that backdrop for every + // email that doesn't declare `color-scheme` itself. It also themes the + // scrollbars and any form control the email renders. + contentDocument.documentElement.style.colorScheme = + colorScheme === 'dark' ? 'dark' : ''; +} + interface EmailFrameProps extends ComponentProps<'iframe'> { markup: string; width: number; height: number; - darkMode: boolean; + darkMode: DarkModePreview; } export function EmailFrame({ @@ -307,6 +374,7 @@ export function EmailFrame({ width, height, darkMode, + style, ...rest }: EmailFrameProps) { return ( @@ -314,17 +382,32 @@ export function EmailFrame({ ref={(iframe: HTMLIFrameElement) => { if (!iframe) return; - if (darkMode) { - applyColorInversion(iframe); - } else { - undoColorInversion(iframe); - } + syncDarkMode(iframe, darkMode); }} >