Skip to content

Preview dark mode toggle only inverts inline colors, never renders actual prefers-color-scheme styles #3707

Closed as not planned

Description

@MILLERMARRU

Description

I read through the preview's dark mode toggle implementation (packages/ui/src/app/preview/[...slug]/email-frame.tsx) after seeing #3609, and the toggle can't render an email's actual @media (prefers-color-scheme: dark) styles at all, only simulate one.

applyColorInversion/undoColorInversion walk the iframe DOM and invert inline style colors element by element:

function applyColorInversion(iframe: HTMLIFrameElement) {
  // ...
  for (const element of walkDom(contentDocument.documentElement)) {
    // ...
    for (const [properties, type] of styleProperties.entries()) {
      for (const property of properties) {
        const value = element.style[property]
        if (value && value.trim() !== '') {
          element.setAttribute(`data-original-${property}`, value)
          element.style[property] = value
            .replaceAll(colorRegex(), (color) => invertColor(color, type))
          // ...
        }
      }
    }
  }
}

It never touches <style> tags or evaluates any @media query. For any email that ships real prefers-color-scheme: dark rules in a <style> block, which is most dark-mode-aware markup, including anything using Tailwind's dark: classes, the toggle shows a color-inverted version of the light styles instead of the email's actual authored dark styles. There's no way in the preview UI today to see what the email genuinely looks like in a client that respects prefers-color-scheme (Apple Mail, etc).

Reproduction

  1. Author an email with <style>{'@media (prefers-color-scheme: dark) { .card { background: #111 } }'}</style> (or the Tailwind dark: equivalent).
  2. Open it in the preview and toggle dark mode.
  3. Compare against opening the same rendered HTML directly in a mail client (or a browser tab with OS dark mode forced) that actually evaluates prefers-color-scheme.

The preview shows an inverted light theme; the real dark-mode styles never apply.

Suggested fix

The iframe is already rendered with sandbox="allow-same-origin ..." and srcDoc content (same origin as the parent app), which applyColorInversion itself relies on to reach into contentDocument directly. The same access works for reading doc.styleSheets, collecting rules inside a CSSMediaRule whose media.mediaText includes prefers-color-scheme: dark, and injecting their unwrapped cssText into a single <style> appended to the iframe's <head> when the toggle is on (removing it when toggled off). That would render the email's actual authored dark theme instead of a simulated inversion, matching what Apple Mail / Outlook for Mac do.

Happy to put together a PR for this if it's a direction the team wants, I already have a rough implementation sketched out against email-frame.tsx.

Activity

  1. creativoma commented on Aug 18, 2026

    @creativoma

    Opened #3712 for this.

    It ended up a little different from the sketch: where the browser propagates color-scheme into the frame (Firefox 105+, Chromium 129+) the email's own rules match untouched, and where it doesn't — Safari today — each prefers-color-scheme condition has its value flipped in place, rather than the rules being lifted into an appended <style>. That keeps them in their original cascade position and handles prefers-color-scheme: light blocks too.

    The toggle is now two controls, one per client behavior, so the inversion emulation stays and is labelled for what it does.

  2. gabrielmfern commented on Aug 25, 2026

    @gabrielmfern
    Member

    This was a conscious decision since the idea of this feature is exactly to help you build an email that will work without media queries, as most email clients don't support them. I think we would be open to supporting both types of dark modes: one that only changes to dark mode for media queries, and the other that simulates email clients.

    That being said, this isn't a bug, so I'm going to close this. Feel free to open a discussion where we can talk more about how to implement a way for this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions