From 5a9c318ec5b9a1a89f4369a060cb671a696dcca9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:39:28 +0100 Subject: [PATCH] fix: keep mails that paint their own page background from flashing white in dark mode --- lib/__tests__/email-dark-mode.test.ts | 17 ++++++++++++++++ lib/email-dark-mode.ts | 29 ++++++++++++++++++++------- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/lib/__tests__/email-dark-mode.test.ts b/lib/__tests__/email-dark-mode.test.ts index 298084a9f..c90232551 100644 --- a/lib/__tests__/email-dark-mode.test.ts +++ b/lib/__tests__/email-dark-mode.test.ts @@ -162,6 +162,23 @@ describe('applyEmailDarkMode', () => { const doc = render('

hi

'); expect(doc.documentElement.classList.contains(DARK_PENDING_CLASS)).toBe(false); }); + + // Newsletters (Lieferando) paint the canvas through `html { background }`. + // The pass only looked at the body, so that white stayed behind the mail + // and was taken for our dark canvas. + it('darkens a canvas the mail paints on ', () => { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + const doc = frame.contentDocument!; + doc.documentElement.className = DARK_PENDING_CLASS; + doc.documentElement.style.backgroundColor = '#ffffff'; + doc.body.style.backgroundColor = '#ffffff'; + doc.body.innerHTML = '

hi

'; + applyEmailDarkMode(doc); + const bgOf = (el: Element) => c(doc.defaultView!.getComputedStyle(el).backgroundColor); + expect(lightness(bgOf(doc.documentElement))).toBeLessThan(40); + expect(lightness(bgOf(doc.body))).toBeLessThan(40); + }); }); // #693: cells coloured through the legacy bgcolor attribute stayed white in diff --git a/lib/email-dark-mode.ts b/lib/email-dark-mode.ts index e95d26872..698f491ed 100644 --- a/lib/email-dark-mode.ts +++ b/lib/email-dark-mode.ts @@ -63,8 +63,10 @@ export const DARK_PENDING_CLASS = 'bulwark-dark-pending'; /** * Stylesheet for the srcdoc head in dark mode. Sets the fallback colours for * mails that set none, and hides the body until {@link applyEmailDarkMode} - * ran so the light original never flashes. The reveal animation is a - * failsafe: the body shows after a moment even if the pass never runs. + * ran so the light original never flashes. The canvas is pinned too while + * pending: a mail that paints itself (`html { background: #fff }`) + * would otherwise show as a white box until the pass. The reveal animation + * is a failsafe: the body shows after a moment even if the pass never runs. */ export function emailDarkModeCss(): string { return ` @@ -72,6 +74,7 @@ export function emailDarkModeCss(): string { body { color: ${DARK_TEXT}; background: ${EMAIL_DARK_BACKGROUND}; } a { color: ${DARK_LINK}; } @keyframes bulwark-dark-reveal { to { visibility: visible; } } + html.${DARK_PENDING_CLASS} { background-color: ${EMAIL_DARK_BACKGROUND} !important; } html.${DARK_PENDING_CLASS} body { visibility: hidden; animation: bulwark-dark-reveal 0s 1.5s forwards; } `; } @@ -374,8 +377,16 @@ export function applyEmailDarkMode(doc: Document, options: EmailDarkModeOptions const body = doc.body; try { if (!win || !body) return; - const canvas = parseComputedColor(win.getComputedStyle(doc.documentElement).backgroundColor); - const base: Rgba = canvas && canvas.a === 1 ? canvas : parseComputedColor('rgb(20, 20, 20)')!; + // Un-pin the canvas to read the mail's own; nothing paints before the + // writes below, so the light original never shows. + const html = doc.documentElement; + html.classList.remove(DARK_PENDING_CLASS); + const ours = parseComputedColor('rgb(20, 20, 20)')!; + const canvas = parseComputedColor(win.getComputedStyle(html).backgroundColor); + // A mail that paints itself gets that canvas darkened like any + // other fill; left alone it shows wherever the body doesn't cover it. + const mailCanvas = canvas && canvas.a === 1 && !sameColor(canvas, ours) ? canvas : null; + const base: Rgba = mailCanvas ? darkenBackground(mailCanvas) : ours; // Read everything first, then write: interleaving would force a style // recalc per element. @@ -397,8 +408,9 @@ export function applyEmailDarkMode(doc: Document, options: EmailDarkModeOptions }; }); - // The original canvas is white: that is what the light view paints. - const root: Surface = { bg: base, original: WHITE, onImage: false }; + // The original canvas is the mail's own or white: that is what the light + // view paints. + const root: Surface = { bg: base, original: mailCanvas ?? WHITE, onImage: false }; const surfaces = new Map(); const surfaceOf = (el: Element | null): Surface => { for (let n = el; n; n = n.parentElement) { @@ -409,6 +421,9 @@ export function applyEmailDarkMode(doc: Document, options: EmailDarkModeOptions }; const writes: Array<() => void> = []; + if (mailCanvas && base !== mailCanvas) { + writes.push(() => html.style.setProperty('background-color', formatColor(base), 'important')); + } for (const snap of snaps) { const { el } = snap; const parent = el === body ? root : surfaceOf(el.parentElement); @@ -426,7 +441,7 @@ export function applyEmailDarkMode(doc: Document, options: EmailDarkModeOptions } // A translucent box over a picture still shows the picture, so it // keeps its colours; an opaque one is a surface of its own. - if (el === body && snap.bg && sameColor(snap.bg, base)) { + if (el === body && snap.bg && sameColor(snap.bg, ours)) { // Our own dark canvas (emailDarkModeCss), not a fill the sender // chose: in the light view this is the white page. } else if (snap.bg && snap.bg.a > 0 && !(parent.onImage && snap.bg.a < 0.9)) {