From e172e6667b3ee6703ad4350cbce29883e4468581 Mon Sep 17 00:00:00 2001 From: Phillip Carter Date: Thu, 23 Jul 2026 16:22:16 -0700 Subject: [PATCH] chase more rendering bugs, handle dark text and dark background --- src/lib/__tests__/emailHtml.test.ts | 19 ++++++ src/lib/emailHtml.ts | 99 ++++++++++++++++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/lib/__tests__/emailHtml.test.ts b/src/lib/__tests__/emailHtml.test.ts index 3854ca2..286de8f 100644 --- a/src/lib/__tests__/emailHtml.test.ts +++ b/src/lib/__tests__/emailHtml.test.ts @@ -104,6 +104,25 @@ describe("prepareHtml", () => { assert.ok(!result.includes("filter:invert")); }); + it("adapts only low-contrast neutral text in dark mode", () => { + const result = prepareHtml( + documentWith( + '

Black text

Brand text

Image-backed text
', + ), + ); + assert.ok(result.includes("data-email-client-adapted-text")); + assert.ok(result.includes("contrastRatio(foreground,background)>=4.5")); + assert.ok(result.includes("spread>56||luminance(foreground)>0.18")); + assert.ok( + result.includes( + "style.backgroundImage&&style.backgroundImage!=='none'", + ), + ); + assert.ok(result.includes('style="color:#000"')); + assert.ok(result.includes('style="color:#c026d3"')); + assert.ok(!result.includes("filter:invert")); + }); + it("measures full scroll geometry and batches updates in animation frames", () => { const result = prepareHtml(documentWith("

Hello

")); assert.ok(result.includes("root?root.scrollWidth:0")); diff --git a/src/lib/emailHtml.ts b/src/lib/emailHtml.ts index ea7dcda..d9b1706 100644 --- a/src/lib/emailHtml.ts +++ b/src/lib/emailHtml.ts @@ -134,9 +134,97 @@ const STRIP_QUOTES_JS = `(function(){ }); })();`; -function resizeScript(stripQuotes: boolean): string { +function darkTextAdaptationScript(theme: EmailRenderTheme): string { + const fallbackBackground = JSON.stringify(theme.surfaceDarkColor); + return ` + var darkMode=window.matchMedia&&window.matchMedia('(prefers-color-scheme:dark)'); + function parseRgb(value){ + var text=String(value||''); + var hex=text.match(/^#([\\da-f]{6})$/i); + if(hex){ + return { + r:parseInt(hex[1].slice(0,2),16), + g:parseInt(hex[1].slice(2,4),16), + b:parseInt(hex[1].slice(4,6),16), + a:1 + }; + } + var match=text.match( + /^rgba?\\(\\s*([\\d.]+)\\s*,\\s*([\\d.]+)\\s*,\\s*([\\d.]+)(?:\\s*,\\s*([\\d.]+))?\\s*\\)$/ + ); + return match?{ + r:Number(match[1]),g:Number(match[2]),b:Number(match[3]), + a:match[4]===undefined?1:Number(match[4]) + }:null; + } + function luminance(colour){ + function channel(value){ + value=value/255; + return value<=0.04045?value/12.92:Math.pow((value+0.055)/1.055,2.4); + } + return 0.2126*channel(colour.r)+ + 0.7152*channel(colour.g)+ + 0.0722*channel(colour.b); + } + function contrastRatio(foreground,background){ + var light=Math.max(luminance(foreground),luminance(background)); + var dark=Math.min(luminance(foreground),luminance(background)); + return (light+0.05)/(dark+0.05); + } + function hasDirectText(element){ + for(var node=element.firstChild;node;node=node.nextSibling){ + if(node.nodeType===3&&String(node.nodeValue||'').trim())return true; + } + return false; + } + function effectiveBackground(element){ + for(var current=element;current;current=current.parentElement){ + var style=window.getComputedStyle(current); + if(style.backgroundImage&&style.backgroundImage!=='none')return null; + var colour=parseRgb(style.backgroundColor); + if(colour&&colour.a>0.01)return colour; + } + return parseRgb(${fallbackBackground}); + } + function adaptDarkText(){ + var marked=document.querySelectorAll('[data-email-client-adapted-text]'); + for(var i=0;i56||luminance(foreground)>0.18)continue; + if(contrastRatio(foreground,background)>=4.5)continue; + element.setAttribute('data-email-client-adapted-text','true'); + } + } + if(darkMode){ + if(darkMode.addEventListener)darkMode.addEventListener('change',adaptDarkText); + else if(darkMode.addListener)darkMode.addListener(adaptDarkText); + }`; +} + +function resizeScript( + stripQuotes: boolean, + adaptiveTheme?: EmailRenderTheme, +): string { return `