feat(ui): preview the dark styles an email defines
Closes #3707. Implements the proposal in #3609.
Problem
The preview's dark mode toggle emulates forced color inversion — what Gmail and Outlook.com do to a light theme. Clients that honor prefers-color-scheme, such as Apple Mail and Outlook for Mac, instead render whatever dark theme the email authored, and that behavior had no representation in the preview.
Because the inversion walks the DOM recoloring inline styles, it never evaluates a media query. An email shipping real @media (prefers-color-scheme: dark) rules — which includes anything using Tailwind's dark: variants — could not be previewed at all.
Changes
The single toggle becomes two, so both client behaviors are reachable and each is labelled for what it does:
| Control | Emulates |
|---|---|
| Emulated color inversion | Clients that ignore prefers-color-scheme (Gmail, Outlook.com) |
| Dark styles defined by the email | Clients that honor it (Apple Mail, Outlook for Mac) |
The existing ?dark search param keeps meaning color inversion, so links written before this change resolve the same way.
How the scheme is applied
Setting color-scheme on the frame is enough wherever the browser propagates it into the embedded document, and there the email's own rules match untouched. That propagation is Firefox 105+ and Chromium 129+ only; Safari does not ship it yet (it landed in Safari Technology Preview 242). Left at that, an email would render one way on Chrome and another on Safari.
So where the browser does not do it, forceColorScheme flips the value of every prefers-color-scheme condition, turning the queries the reader's real setting matches into the ones it doesn't. Only the leaf value changes, so the browser still evaluates the surrounding and/or/not and the comma-separated query list. Rules keep their position in the cascade, and prefers-color-scheme: light blocks are handled alongside the dark ones. Only the live CSSOM is touched, never the <style> elements' text, so the source view still shows the email as authored.
Fixes along the way
- With dark mode off, an email's dark rules matched whenever the reader's own OS was set to dark, and the inversion emulation then recolored an already-dark theme. Both are now decided by the preview rather than by the machine it runs on.
- Undoing the inversion left a white background on an email that never painted one. The inversion gives
<body>the colors it already renders at so it has something to invert; those were being written back as though the email had authored them.
Testing
36 unit tests covering the condition rewriting, the URL state, and syncDarkMode against a real frame.
Verified end to end in Chromium 147, Chrome 151 and WebKit 26.4, across all three modes with the OS set to light and to dark — including the non-propagating path that Safari takes today.
Out of scope
Color inversion still only recolors inline styles; rules in a <style> block are not inverted. That is unchanged behavior and separate from what this PR addresses.
Summary by cubic
Adds a native dark-mode preview alongside the existing inversion. The preview now renders an email’s authored dark styles independently of the reader’s OS; ?dark still selects inversion, while ?dark=native selects native mode.
New Features
- Replaces the single toggle with labeled controls for emulated inversion and email-defined dark styles.
- Rewrites
prefers-color-schemerules when embedded browsers do not propagatecolor-scheme, without changing the email source. - Keeps the preview backdrop consistent across dark modes.
Bug Fixes
- Prevents dark mode from inheriting the reader’s OS setting when disabled or using inversion.
- Removes temporary body colors when inversion ends while restoring colors authored by the email.
- Adds coverage for URL state, media-query rewriting, and frame synchronization.