mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
fix: recolour mails for dark mode instead of inverting them
Dark mode used to invert the whole message body with a CSS filter and re-invert images and coloured cells. Brand colours came out shifted (red turned pink), white cells set through bgcolor stayed white, and the result changed after loading remote content because the blocker strips the background attributes the selectors relied on. The rendered body is now read back through computed styles and its colours are rewritten in place: light backgrounds are darkened in their own hue, vivid and dark fills stay as sent, text and borders only change when they lack contrast, and anything painted on a background image is left alone. Transparent dark images get their original light background behind them when their pixels show they would vanish. The thread conversation view uses the same pass. Also fixed along the way: - blocking remote content dropped every leading `<style>` block, so mails lost their CSS until images were loaded - a mail's `prefers-color-scheme` rules followed the OS instead of the app theme
This commit is contained in:
@@ -8,6 +8,7 @@ import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isOpenableLinkHref, pl
|
||||
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
|
||||
import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
|
||||
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
|
||||
@@ -2228,7 +2229,7 @@ export function EmailViewer({
|
||||
// Detect if the email HTML has native dark mode support
|
||||
const emailHasNativeDarkMode = useMemo(() => {
|
||||
if (!effectiveEmailContent.isHtml) return false;
|
||||
return /prefers-color-scheme\s*:\s*dark/i.test(effectiveEmailContent.html);
|
||||
return htmlHasNativeDarkMode(effectiveEmailContent.html);
|
||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml]);
|
||||
|
||||
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
|
||||
@@ -2240,50 +2241,12 @@ export function EmailViewer({
|
||||
const emailIframeSrcDoc = useMemo(() => {
|
||||
if (!effectiveEmailContent.isHtml) return '';
|
||||
|
||||
// If email has native dark mode, let it handle its own theming
|
||||
// Otherwise, use CSS filter inversion for dark mode (preserves layout)
|
||||
// Re-invert leaf media elements so they appear normal.
|
||||
//
|
||||
// Background-COLOR containers (bgcolor, background: shorthand) are
|
||||
// re-inverted so their fill flips to a dark tone, but skipped when they
|
||||
// wrap media: the inner image already gets its own re-invert, and a filter
|
||||
// on the container would double-invert it. Nested color containers must NOT
|
||||
// stack another invert either - each filter toggles the inversion, so an
|
||||
// odd number of stacked filters (body + outer + inner) lands back on
|
||||
// light-on-light; the descendant rule disables filter on a color container
|
||||
// nested inside another.
|
||||
//
|
||||
// Background-IMAGE containers are different: a real photo and whatever is
|
||||
// composited over it (headline text, logos) was designed by the sender to
|
||||
// read together, so the whole subtree should return to its original
|
||||
// light-mode appearance - even when it wraps media. We therefore re-invert
|
||||
// these unconditionally (no :has guard) and cancel the per-image re-invert
|
||||
// for media inside them, otherwise those images would flip back to inverted.
|
||||
const darkModeCSS = isDark && !emailHasNativeDarkMode ? `
|
||||
html { background: #121212; }
|
||||
body { filter: invert(1) hue-rotate(180deg); background: #ededed; }
|
||||
img, video, svg, canvas, object, embed, input[type="image"] {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
[style*="background:"]:not(:has(img, video, svg, canvas, object, embed)),
|
||||
[bgcolor]:not(:has(img, video, svg, canvas, object, embed)) {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
:where([style*="background:"], [bgcolor])
|
||||
:where([style*="background:"], [bgcolor]):not(:has(img, video, svg, canvas, object, embed)) {
|
||||
filter: none !important;
|
||||
}
|
||||
[style*="background-image"],
|
||||
[background] {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
[style*="background-image"] :where(img, video, svg, canvas, object, embed, input[type="image"]),
|
||||
[background] :where(img, video, svg, canvas, object, embed, input[type="image"]) {
|
||||
filter: none;
|
||||
}
|
||||
` : '';
|
||||
|
||||
const colorScheme = isDark && emailHasNativeDarkMode ? 'light dark' : 'light';
|
||||
// Dark mode rewrites the mail's colours after load (applyEmailDarkMode in
|
||||
// handleIframeLoad); this only sets the fallback colours and keeps the
|
||||
// body hidden until that pass ran. A mail with its own dark styles gets
|
||||
// them pinned to the app theme rather than the OS.
|
||||
const darkModeCSS = isDark ? emailDarkModeCss() : '';
|
||||
const colorScheme = emailColorScheme(isDark, emailHasNativeDarkMode);
|
||||
|
||||
// Bare HTML emails (no <style>) tend to be plain prose without their own
|
||||
// layout - give them the same padding as plain-text mails (.email-content-text).
|
||||
@@ -2320,7 +2283,7 @@ export function EmailViewer({
|
||||
const iframeCsp = emailIframeCsp(effectiveEmailContent.externalBlocked);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<html class="${isDark ? DARK_PENDING_CLASS : ''}" style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta http-equiv="x-dns-prefetch-control" content="off">
|
||||
@@ -2371,7 +2334,7 @@ export function EmailViewer({
|
||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
${wordHtmlCSS}
|
||||
${darkModeCSS}
|
||||
</style></head><body dir="auto">${effectiveEmailContent.html}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
|
||||
</style></head><body dir="auto">${resolveColorSchemeMedia(effectiveEmailContent.html, isDark)}<style>html,body{height:auto!important;min-height:0!important;max-height:none!important}</style></body></html>`;
|
||||
}, [effectiveEmailContent.html, effectiveEmailContent.isHtml, effectiveEmailContent.hasStyleTag, effectiveEmailContent.externalBlocked, isDark, emailHasNativeDarkMode, messageSpacing]);
|
||||
|
||||
// Unblocking external content is handled by rebuilding the iframe srcDoc:
|
||||
@@ -2459,6 +2422,13 @@ export function EmailViewer({
|
||||
hide: t('hide_quoted_text'),
|
||||
});
|
||||
|
||||
// Before the height wiring and after the quote toggle exists, so the
|
||||
// toggle is converted too. Reads the mail's computed colours and
|
||||
// un-hides the body when done.
|
||||
if (isDark) {
|
||||
applyEmailDarkMode(doc, { remoteContentAllowed: !effectiveEmailContent.externalBlocked });
|
||||
}
|
||||
|
||||
// Auto-resize iframe to fit content
|
||||
// Measure max(documentElement, body): a height:100% wrapper can leave
|
||||
// documentElement.scrollHeight short while the real content lives in body.
|
||||
@@ -2531,7 +2501,7 @@ export function EmailViewer({
|
||||
|
||||
// Plugin intercept: let plugins cancel or rewrite external links inside
|
||||
// the email body before navigation happens. Bound on the iframe doc so
|
||||
// it survives DOM mutations from dark-mode pass below.
|
||||
// it survives later DOM mutations.
|
||||
const onLinkClick = async (ev: Event) => {
|
||||
const targetEl = (ev.target as Element | null)?.closest?.('a[href], area[href]') as HTMLAnchorElement | HTMLAreaElement | null;
|
||||
if (!targetEl) return;
|
||||
@@ -2562,106 +2532,11 @@ export function EmailViewer({
|
||||
};
|
||||
doc.addEventListener('click', onLinkClick, true);
|
||||
|
||||
// Dark mode: re-invert elements with stylesheet-defined background images
|
||||
// (CSS attribute selectors only catch inline styles, not <style> block rules)
|
||||
if (isDark && !emailHasNativeDarkMode) {
|
||||
const win = doc.defaultView;
|
||||
if (win) {
|
||||
doc.body.querySelectorAll('*').forEach(el => {
|
||||
const htmlEl = el as HTMLElement;
|
||||
// Skip elements already handled by CSS attribute selectors
|
||||
if (htmlEl.style.backgroundImage || htmlEl.style.background ||
|
||||
htmlEl.hasAttribute('background') || htmlEl.hasAttribute('bgcolor')) return;
|
||||
// Skip leaf media elements (already re-inverted by CSS)
|
||||
const tag = htmlEl.tagName;
|
||||
if (['IMG', 'VIDEO', 'SVG', 'CANVAS', 'OBJECT', 'EMBED'].includes(tag)) return;
|
||||
const computed = win.getComputedStyle(htmlEl);
|
||||
if (computed.backgroundImage && computed.backgroundImage !== 'none') {
|
||||
// Re-invert so the background image returns to its original
|
||||
// appearance. Matches the CSS background-image rules: re-invert
|
||||
// even when media is nested, then cancel the per-image
|
||||
// re-invert on that media so it isn't left double-inverted.
|
||||
htmlEl.style.filter = 'invert(1) hue-rotate(180deg)';
|
||||
htmlEl.querySelectorAll('img, video, svg, canvas, object, embed, input[type="image"]')
|
||||
.forEach(m => { (m as HTMLElement).style.filter = 'none'; });
|
||||
}
|
||||
});
|
||||
|
||||
// Re-invert emoji glyphs so they keep their original colors. The
|
||||
// body's invert filter flips colored emoji (yellow smiley → blue,
|
||||
// red heart → cyan, etc.). Wrap each emoji run in a span that
|
||||
// re-inverts. Only act when the ancestor invert depth is odd -
|
||||
// emojis inside a double-inverted bgcolor container already render
|
||||
// at their original colors.
|
||||
let emojiRe: RegExp;
|
||||
try {
|
||||
emojiRe = new RegExp('\\p{RGI_Emoji}', 'gv');
|
||||
} catch {
|
||||
emojiRe = /\p{Extended_Pictographic}(?:\uFE0F)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F)?)*/gu;
|
||||
}
|
||||
const emojiTestRe = /\p{Extended_Pictographic}/u;
|
||||
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'IFRAME']);
|
||||
|
||||
const isOddInvertDepth = (start: Element | null) => {
|
||||
let count = 0;
|
||||
let n: Element | null = start;
|
||||
while (n) {
|
||||
if (n === doc.body) { count++; break; }
|
||||
const cs = win.getComputedStyle(n);
|
||||
if (cs.filter && cs.filter.includes('invert')) count++;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return count % 2 === 1;
|
||||
};
|
||||
|
||||
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode(node) {
|
||||
let p = node.parentElement;
|
||||
while (p) {
|
||||
if (SKIP_TAGS.has(p.tagName)) return NodeFilter.FILTER_REJECT;
|
||||
p = p.parentElement;
|
||||
}
|
||||
return emojiTestRe.test(node.nodeValue || '')
|
||||
? NodeFilter.FILTER_ACCEPT
|
||||
: NodeFilter.FILTER_REJECT;
|
||||
},
|
||||
});
|
||||
|
||||
const emojiTextNodes: Text[] = [];
|
||||
let cur: Node | null;
|
||||
while ((cur = walker.nextNode())) emojiTextNodes.push(cur as Text);
|
||||
|
||||
emojiTextNodes.forEach((textNode) => {
|
||||
const parent = textNode.parentElement;
|
||||
if (!parent || !isOddInvertDepth(parent)) return;
|
||||
const text = textNode.nodeValue || '';
|
||||
emojiRe.lastIndex = 0;
|
||||
const frag = doc.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = emojiRe.exec(text)) !== null) {
|
||||
if (m.index > lastIndex) {
|
||||
frag.appendChild(doc.createTextNode(text.slice(lastIndex, m.index)));
|
||||
}
|
||||
const span = doc.createElement('span');
|
||||
span.style.cssText = 'filter:invert(1) hue-rotate(180deg)';
|
||||
span.textContent = m[0];
|
||||
frag.appendChild(span);
|
||||
lastIndex = m.index + m[0].length;
|
||||
}
|
||||
if (lastIndex === 0) return;
|
||||
if (lastIndex < text.length) {
|
||||
frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
|
||||
}
|
||||
parent.replaceChild(frag, textNode);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Cross-origin restrictions - iframe will still display content
|
||||
}
|
||||
}, [isDark, emailHasNativeDarkMode, email?.id, t, openCidPartLink]);
|
||||
}, [isDark, effectiveEmailContent.externalBlocked, email?.id, t, openCidPartLink]);
|
||||
|
||||
// Wire up the iframe as soon as its sandboxed document has parsed, rather than
|
||||
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
|
||||
@@ -5420,7 +5295,7 @@ export function EmailViewer({
|
||||
title="Email content"
|
||||
className="w-full border-0 block"
|
||||
scrolling="no"
|
||||
style={{ minHeight: '100px', colorScheme: isDark && emailHasNativeDarkMode ? 'light dark' : 'light' }}
|
||||
style={{ minHeight: '100px', colorScheme: emailColorScheme(isDark, emailHasNativeDarkMode) }}
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,7 @@ import { VerificationCodeChip } from "./verification-code-chip";
|
||||
import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
|
||||
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
|
||||
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
|
||||
import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform";
|
||||
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailDarkModeCss } from "@/lib/email-dark-mode";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -247,6 +247,7 @@ function EmailCard({
|
||||
const wopiStatus = useWopiStatus(true);
|
||||
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
|
||||
const emailAlwaysLightMode = useSettingsStore((state) => state.emailAlwaysLightMode);
|
||||
const isDark = resolvedTheme === 'dark' && !emailAlwaysLightMode;
|
||||
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
|
||||
const showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes);
|
||||
const verificationCode = useMemo(
|
||||
@@ -363,8 +364,6 @@ function EmailCard({
|
||||
const sanitizeConfig = { ...EMAIL_SANITIZE_CONFIG };
|
||||
|
||||
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
||||
const htmlNode = node as HTMLElement;
|
||||
|
||||
// Re-apply the data:-URI allowlist DOMPurify skips on media tags.
|
||||
restrictDataUriResourcesOnNode(node);
|
||||
|
||||
@@ -379,25 +378,6 @@ function EmailCard({
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
|
||||
if (resolvedTheme === 'dark' && !emailAlwaysLightMode) {
|
||||
if (htmlNode.style) {
|
||||
const originalStyles = htmlNode.style.cssText;
|
||||
const transformedStyles = transformInlineStyles(originalStyles, 'dark');
|
||||
if (transformedStyles !== originalStyles) {
|
||||
htmlNode.style.cssText = transformedStyles;
|
||||
}
|
||||
}
|
||||
|
||||
const colorAttr = node.getAttribute('color');
|
||||
if (colorAttr) {
|
||||
node.setAttribute('color', transformColorForDarkMode(colorAttr));
|
||||
}
|
||||
|
||||
const bgcolorAttr = node.getAttribute('bgcolor');
|
||||
if (bgcolorAttr) {
|
||||
node.setAttribute('bgcolor', transformBgColorForDarkMode(bgcolorAttr));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const sanitized = DOMPurify.sanitize(htmlContent, sanitizeConfig);
|
||||
@@ -439,7 +419,7 @@ function EmailCard({
|
||||
// `hasBlockedContent` is only read to skip a redundant setState; as a
|
||||
// dependency it would re-run the sanitizer once the banner shows.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [email, allowExternal, resolvedTheme, emailAlwaysLightMode, cidBlobUrls, t]);
|
||||
}, [email, allowExternal, cidBlobUrls, t]);
|
||||
|
||||
// Parts the body embeds via cid: stay out of the attachment row while the
|
||||
// user hides inline images - the desktop viewer's rule, shared through
|
||||
@@ -461,7 +441,8 @@ function EmailCard({
|
||||
// Strict while external content is blocked: the network-level backstop
|
||||
// for whatever the DOM walk above cannot see.
|
||||
const csp = emailIframeCsp(!allowExternal);
|
||||
return `<!DOCTYPE html><html><head>
|
||||
// Colours are rewritten after load (applyEmailDarkMode in handleIframeLoad).
|
||||
return `<!DOCTYPE html><html class="${isDark ? DARK_PENDING_CLASS : ''}"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp}">
|
||||
@@ -491,8 +472,9 @@ function EmailCard({
|
||||
table { table-layout: auto; overflow-wrap: break-word; }
|
||||
td, th { word-break: break-word; padding: 0.5rem; }
|
||||
pre { white-space: pre-wrap; word-wrap: break-word; }
|
||||
${isDark ? emailDarkModeCss() : ''}
|
||||
</style></head><body dir="auto">${emailContent.html}</body></html>`;
|
||||
}, [emailContent.isHtml, emailContent.html, allowExternal]);
|
||||
}, [emailContent.isHtml, emailContent.html, allowExternal, isDark]);
|
||||
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
@@ -506,6 +488,7 @@ function EmailCard({
|
||||
show: t('email_viewer.show_quoted_text'),
|
||||
hide: t('email_viewer.hide_quoted_text'),
|
||||
});
|
||||
if (isDark) applyEmailDarkMode(doc, { remoteContentAllowed: allowExternal });
|
||||
const resize = () => {
|
||||
// Fit desktop-width mail to the screen before measuring: the transform
|
||||
// leaves the layout height untouched, so scale the box by hand.
|
||||
@@ -523,7 +506,7 @@ function EmailCard({
|
||||
} catch {
|
||||
// contentDocument may be inaccessible under stricter sandboxes; ignore.
|
||||
}
|
||||
}, [t]);
|
||||
}, [t, isDark, allowExternal]);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
|
||||
@@ -2,11 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseColor,
|
||||
getLuminance,
|
||||
isDarkColor,
|
||||
readableTextOn,
|
||||
transformColorForDarkMode,
|
||||
transformBgColorForDarkMode,
|
||||
transformInlineStyles,
|
||||
} from '../color-transform';
|
||||
|
||||
describe('parseColor', () => {
|
||||
@@ -130,281 +126,6 @@ describe('getLuminance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDarkColor', () => {
|
||||
it('should identify dark colors', () => {
|
||||
expect(isDarkColor('#000000')).toBe(true);
|
||||
expect(isDarkColor('#111111')).toBe(true);
|
||||
expect(isDarkColor('#333333')).toBe(true);
|
||||
expect(isDarkColor('rgb(51, 51, 51)')).toBe(true);
|
||||
});
|
||||
|
||||
it('should identify light colors', () => {
|
||||
expect(isDarkColor('#ffffff')).toBe(false);
|
||||
expect(isDarkColor('#eeeeee')).toBe(false);
|
||||
expect(isDarkColor('rgb(200, 200, 200)')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for invalid colors', () => {
|
||||
expect(isDarkColor('invalid')).toBe(false);
|
||||
expect(isDarkColor('inherit')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformColorForDarkMode', () => {
|
||||
it('should brighten very dark colors', () => {
|
||||
const original = '#111111';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
const originalRgb = parseColor(original)!;
|
||||
const transformedRgb = parseColor(transformed)!;
|
||||
|
||||
expect(transformedRgb.r).toBeGreaterThan(originalRgb.r);
|
||||
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
|
||||
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
|
||||
});
|
||||
|
||||
it('should transform #333333 to a bright color', () => {
|
||||
const transformed = transformColorForDarkMode('#333333');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it('should produce readable results for Google Calendar grays', () => {
|
||||
const googleGrays = ['#757575', '#5f6368', '#70757a', '#3c4043'];
|
||||
for (const color of googleGrays) {
|
||||
const transformed = transformColorForDarkMode(color);
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeGreaterThan(0.55);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve hue for colored text', () => {
|
||||
const transformed = transformColorForDarkMode('#1a73e8');
|
||||
const rgb = parseColor(transformed)!;
|
||||
expect(rgb.b).toBeGreaterThan(rgb.r);
|
||||
expect(rgb.b).toBeGreaterThan(rgb.g);
|
||||
});
|
||||
|
||||
it('should preserve already light colors', () => {
|
||||
const lightColors = ['#eeeeee', '#ffffff', 'rgb(200, 200, 200)'];
|
||||
lightColors.forEach((color) => {
|
||||
const original = parseColor(color)!;
|
||||
const transformed = parseColor(transformColorForDarkMode(color))!;
|
||||
const originalLum = getLuminance(original.r, original.g, original.b);
|
||||
const transformedLum = getLuminance(transformed.r, transformed.g, transformed.b);
|
||||
|
||||
expect(transformedLum).toBeGreaterThanOrEqual(originalLum * 0.9);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle rgba colors with alpha', () => {
|
||||
const original = 'rgba(51, 51, 51, 0.8)';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
expect(transformed).toContain('rgba');
|
||||
expect(transformed).toContain('0.8');
|
||||
});
|
||||
|
||||
it('should preserve nearly transparent colors', () => {
|
||||
const original = 'rgba(0, 0, 0, 0.05)';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
expect(transformed).toBe(original);
|
||||
});
|
||||
|
||||
it('should handle invalid colors gracefully', () => {
|
||||
expect(transformColorForDarkMode('invalid')).toBe('invalid');
|
||||
expect(transformColorForDarkMode('inherit')).toBe('inherit');
|
||||
});
|
||||
|
||||
it('should brighten medium darkness colors', () => {
|
||||
const original = '#646463';
|
||||
const transformed = transformColorForDarkMode(original);
|
||||
const originalRgb = parseColor(original)!;
|
||||
const transformedRgb = parseColor(transformed)!;
|
||||
|
||||
expect(transformedRgb.r).toBeGreaterThan(originalRgb.r);
|
||||
expect(transformedRgb.g).toBeGreaterThan(originalRgb.g);
|
||||
expect(transformedRgb.b).toBeGreaterThan(originalRgb.b);
|
||||
});
|
||||
|
||||
it('should ensure minimum contrast for all dark text colors', () => {
|
||||
const darkTextColors = [
|
||||
'#000000', '#111111', '#222222', '#333333', '#444444',
|
||||
'#555555', '#666666', '#777777', '#888888',
|
||||
];
|
||||
for (const color of darkTextColors) {
|
||||
const transformed = transformColorForDarkMode(color);
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeGreaterThan(0.4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformBgColorForDarkMode', () => {
|
||||
it('should darken white backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('#ffffff');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it('should darken light gray backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('#f8f9fa');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it('should preserve already dark backgrounds', () => {
|
||||
const darkBgs = ['#111111', '#1a1a2e', '#0f172a'];
|
||||
for (const color of darkBgs) {
|
||||
expect(transformBgColorForDarkMode(color)).toBe(color);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve nearly transparent backgrounds', () => {
|
||||
const original = 'rgba(255, 255, 255, 0.05)';
|
||||
expect(transformBgColorForDarkMode(original)).toBe(original);
|
||||
});
|
||||
|
||||
it('should handle invalid colors gracefully', () => {
|
||||
expect(transformBgColorForDarkMode('invalid')).toBe('invalid');
|
||||
expect(transformBgColorForDarkMode('inherit')).toBe('inherit');
|
||||
});
|
||||
|
||||
it('should handle rgba backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('rgba(255, 255, 255, 0.9)');
|
||||
expect(transformed).toContain('rgba');
|
||||
expect(transformed).toContain('0.9');
|
||||
const rgb = parseColor(transformed)!;
|
||||
expect(rgb.r).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should moderately darken medium backgrounds', () => {
|
||||
const transformed = transformBgColorForDarkMode('#e0e0e0');
|
||||
const rgb = parseColor(transformed)!;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
expect(luminance).toBeLessThan(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformInlineStyles', () => {
|
||||
it('should not transform styles in light mode', () => {
|
||||
const original = 'color: #333333; font-size: 16px';
|
||||
expect(transformInlineStyles(original, 'light')).toBe(original);
|
||||
});
|
||||
|
||||
it('should transform color property in dark mode', () => {
|
||||
const original = 'color: #333333';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('color:');
|
||||
expect(transformed).toContain('rgb(');
|
||||
});
|
||||
|
||||
it('should darken light background-color property', () => {
|
||||
const original = 'background-color: #ffffff';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('background-color:');
|
||||
const colorMatch = transformed.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
expect(colorMatch).not.toBeNull();
|
||||
if (colorMatch) {
|
||||
const [, r] = colorMatch.map(Number);
|
||||
expect(r).toBeLessThan(100);
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve dark background-color unchanged', () => {
|
||||
const original = 'background-color: #111111';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toBe(original);
|
||||
});
|
||||
|
||||
it('should preserve non-color properties', () => {
|
||||
const original = 'color: #333333; font-size: 16px; margin: 10px';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toContain('font-size: 16px');
|
||||
expect(transformed).toContain('margin: 10px');
|
||||
});
|
||||
|
||||
it('should handle multiple color properties', () => {
|
||||
const original = 'color: #111111; background-color: #ffffff; font-weight: bold';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toContain('color:');
|
||||
expect(transformed).toContain('background-color:');
|
||||
expect(transformed).toContain('font-weight: bold');
|
||||
});
|
||||
|
||||
it('should preserve !important declarations', () => {
|
||||
const original = 'color: #333333 !important';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toContain('!important');
|
||||
});
|
||||
|
||||
it('should handle empty or invalid styles', () => {
|
||||
expect(transformInlineStyles('', 'dark')).toBe('');
|
||||
expect(transformInlineStyles('invalid', 'dark')).toBe('invalid');
|
||||
});
|
||||
|
||||
it('should brighten text colors for dark mode readability', () => {
|
||||
const original = 'color: #333333; font-family: Georgia; font-size: 16px';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
|
||||
expect(transformed).toContain('font-family: Georgia');
|
||||
expect(transformed).toContain('font-size: 16px');
|
||||
|
||||
const colorMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
expect(colorMatch).not.toBeNull();
|
||||
|
||||
if (colorMatch) {
|
||||
const [, r, g, b] = colorMatch.map(Number);
|
||||
expect(r).toBeGreaterThan(180);
|
||||
expect(g).toBeGreaterThan(180);
|
||||
expect(b).toBeGreaterThan(180);
|
||||
}
|
||||
});
|
||||
|
||||
it('should darken background shorthand with color', () => {
|
||||
const original = 'background: #ffffff';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('background:');
|
||||
});
|
||||
|
||||
it('should not transform background with url', () => {
|
||||
const original = 'background: url(image.jpg) #333333';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).toBe(original);
|
||||
});
|
||||
|
||||
it('should transform border-color', () => {
|
||||
const original = 'border-color: #111111';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
expect(transformed).not.toBe(original);
|
||||
expect(transformed).toContain('border-color:');
|
||||
});
|
||||
|
||||
it('should produce good contrast for Google Calendar emails', () => {
|
||||
const original = 'color: #5f6368; background-color: #f8f9fa';
|
||||
const transformed = transformInlineStyles(original, 'dark');
|
||||
|
||||
const textMatch = transformed.match(/color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
const bgMatch = transformed.match(/background-color:\s*rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
||||
|
||||
expect(textMatch).not.toBeNull();
|
||||
expect(bgMatch).not.toBeNull();
|
||||
|
||||
if (textMatch && bgMatch) {
|
||||
const textLum = getLuminance(+textMatch[1], +textMatch[2], +textMatch[3]);
|
||||
const bgLum = getLuminance(+bgMatch[1], +bgMatch[2], +bgMatch[3]);
|
||||
const contrast = (Math.max(textLum, bgLum) + 0.05) / (Math.min(textLum, bgLum) + 0.05);
|
||||
expect(contrast).toBeGreaterThan(4.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('readableTextOn', () => {
|
||||
it('keeps white text while white holds 3:1 on the fill', () => {
|
||||
expect(readableTextOn('#3b82f6')).toBe('#ffffff');
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
applyEmailDarkMode,
|
||||
contrastRatio,
|
||||
darkenBackground,
|
||||
DARK_PENDING_CLASS,
|
||||
emailColorScheme,
|
||||
ensureContrast,
|
||||
parseComputedColor,
|
||||
resolveColorSchemeMedia,
|
||||
type Rgba,
|
||||
} from '../email-dark-mode';
|
||||
import { sanitizeEmailBodyForIframe } from '../email-sanitization';
|
||||
|
||||
const c = (value: string): Rgba => parseComputedColor(value)!;
|
||||
const lightness = ({ r, g, b }: Rgba) => (r + g + b) / 3;
|
||||
|
||||
describe('darkenBackground', () => {
|
||||
it('turns white into a dark canvas', () => {
|
||||
expect(lightness(darkenBackground(c('rgb(255, 255, 255)')))).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it('keeps a light grey canvas lighter than a white card on it', () => {
|
||||
const card = darkenBackground(c('rgb(255, 255, 255)'));
|
||||
const canvas = darkenBackground(c('rgb(244, 244, 244)'));
|
||||
expect(lightness(canvas)).toBeGreaterThan(lightness(card));
|
||||
});
|
||||
|
||||
it('keeps the hue of a light tint', () => {
|
||||
const out = darkenBackground(c('rgb(214, 233, 248)'));
|
||||
expect(lightness(out)).toBeLessThan(80);
|
||||
expect(out.b).toBeGreaterThan(out.r);
|
||||
});
|
||||
|
||||
it('leaves vivid brand colours and dark fills as sent', () => {
|
||||
const red = c('rgb(227, 6, 19)');
|
||||
const green = c('rgb(115, 215, 0)');
|
||||
const navy = c('rgb(0, 32, 91)');
|
||||
expect(darkenBackground(red)).toBe(red);
|
||||
expect(darkenBackground(green)).toBe(green);
|
||||
expect(darkenBackground(navy)).toBe(navy);
|
||||
});
|
||||
|
||||
it('keeps alpha', () => {
|
||||
expect(darkenBackground(c('rgba(255, 255, 255, 0.5)')).a).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureContrast', () => {
|
||||
const dark = c('rgb(20, 20, 20)');
|
||||
|
||||
it('keeps text that already reads', () => {
|
||||
const white = c('rgb(255, 255, 255)');
|
||||
expect(ensureContrast(white, dark, 4.5)).toBe(white);
|
||||
});
|
||||
|
||||
it('lifts dark text above 4.5:1 on a dark background', () => {
|
||||
const out = ensureContrast(c('rgb(51, 51, 51)'), dark, 4.5);
|
||||
expect(contrastRatio(out, dark)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it('keeps the order of text greys', () => {
|
||||
const black = ensureContrast(c('rgb(0, 0, 0)'), dark, 4.5);
|
||||
const grey = ensureContrast(c('rgb(102, 102, 102)'), dark, 4.5);
|
||||
expect(lightness(black)).toBeGreaterThan(lightness(grey));
|
||||
});
|
||||
|
||||
it('keeps a red link red', () => {
|
||||
const out = ensureContrast(c('rgb(200, 0, 0)'), dark, 4.5);
|
||||
expect(out.r).toBeGreaterThan(out.g + 60);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveColorSchemeMedia', () => {
|
||||
const html = '<style>@media (prefers-color-scheme: dark) { .a { color: white } } @media (prefers-color-scheme:light) { .b {} }</style><p>(prefers-color-scheme: dark)</p>';
|
||||
|
||||
it('turns the dark query on and the light one off in dark mode', () => {
|
||||
const out = resolveColorSchemeMedia(html, true);
|
||||
expect(out).toContain('@media (min-width: 0px) { .a');
|
||||
expect(out).toContain('@media (max-width: 0.001px) { .b');
|
||||
});
|
||||
|
||||
it('turns the dark query off in light mode', () => {
|
||||
const out = resolveColorSchemeMedia(html, false);
|
||||
expect(out).toContain('@media (max-width: 0.001px) { .a');
|
||||
expect(out).toContain('@media (min-width: 0px) { .b');
|
||||
});
|
||||
|
||||
it('leaves text outside <style> alone', () => {
|
||||
expect(resolveColorSchemeMedia(html, true)).toContain('<p>(prefers-color-scheme: dark)</p>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('emailColorScheme', () => {
|
||||
it('is dark only for a mail with its own dark styles shown dark', () => {
|
||||
expect(emailColorScheme(true, true)).toBe('dark');
|
||||
expect(emailColorScheme(true, false)).toBe('light');
|
||||
expect(emailColorScheme(false, true)).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyEmailDarkMode', () => {
|
||||
const render = (body: string) => {
|
||||
const frame = document.createElement('iframe');
|
||||
document.body.appendChild(frame);
|
||||
const doc = frame.contentDocument!;
|
||||
doc.documentElement.className = DARK_PENDING_CLASS;
|
||||
doc.documentElement.style.backgroundColor = 'rgb(20, 20, 20)';
|
||||
doc.body.innerHTML = body;
|
||||
applyEmailDarkMode(doc);
|
||||
return doc;
|
||||
};
|
||||
|
||||
// jsdom doesn't map presentational attributes (bgcolor) into computed
|
||||
// style, so these use inline styles; browsers treat both the same.
|
||||
it('darkens a white cell', () => {
|
||||
const doc = render('<table><tr><td id="a" style="background-color:#fff">y</td></tr></table>');
|
||||
const bg = c(doc.getElementById('a')!.style.getPropertyValue('background-color'));
|
||||
expect(lightness(bg)).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it('makes dark text on a darkened cell readable', () => {
|
||||
const doc = render('<div style="background-color:#fff"><p id="p" style="color:#222">text</p></div>');
|
||||
const color = c(doc.getElementById('p')!.style.getPropertyValue('color'));
|
||||
expect(contrastRatio(color, c('rgb(30, 30, 30)'))).toBeGreaterThan(4.5);
|
||||
});
|
||||
|
||||
it('keeps a brand button and its label as sent', () => {
|
||||
const doc = render('<a id="btn" style="background-color:#e30613;color:#ffffff">Buy</a>');
|
||||
const btn = doc.getElementById('btn')!;
|
||||
expect(btn.style.getPropertyPriority('background-color')).toBe('');
|
||||
expect(btn.style.getPropertyPriority('color')).toBe('');
|
||||
});
|
||||
|
||||
it('leaves text on a background image as sent', () => {
|
||||
const doc = render('<div style="background-image:url(data:image/png;base64,AAAA);background-color:#ffffff"><p id="p" style="color:#111">On the photo</p></div>');
|
||||
expect(doc.getElementById('p')!.style.getPropertyPriority('color')).toBe('');
|
||||
});
|
||||
|
||||
it('converts a cell whose remote background the blocker stripped', () => {
|
||||
const { html } = sanitizeEmailBodyForIframe(
|
||||
'<table><tr><td id="hero" background="https://example.com/hero.jpg" style="background-color:#ffffff;background-image:url(https://example.com/hero.jpg)"><p id="p" style="color:#111">Headline</p></td></tr></table>',
|
||||
true,
|
||||
);
|
||||
const doc = render(html);
|
||||
expect(lightness(c(doc.getElementById('hero')!.style.getPropertyValue('background-color')))).toBeLessThan(40);
|
||||
expect(doc.getElementById('p')!.style.getPropertyPriority('color')).toBe('important');
|
||||
});
|
||||
|
||||
it('un-hides the body', () => {
|
||||
const doc = render('<p>hi</p>');
|
||||
expect(doc.documentElement.classList.contains(DARK_PENDING_CLASS)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -947,6 +947,18 @@ describe('email-sanitization', () => {
|
||||
expect(img.getAttribute('data-blocked-src')).toBe('https://tracker.example/pixel.gif');
|
||||
});
|
||||
|
||||
it('keeps a leading <style> block when it blocks an image', () => {
|
||||
// Collapsing the emptied image cell re-parses the HTML; a <style> at the
|
||||
// very start used to land in <head> there and get dropped, so the mail
|
||||
// lost all its CSS until remote content was allowed.
|
||||
const result = sanitizeEmailBodyForIframe(
|
||||
'<style>.card { background: #fff; }</style><table><tr><td><img src="https://cdn.example/a.png"></td></tr></table>',
|
||||
true,
|
||||
);
|
||||
expect(result.blockedExternalContent).toBe(true);
|
||||
expect(result.html).toContain('<style>.card { background: #fff; }</style>');
|
||||
});
|
||||
|
||||
it('leaves external images alone when blocking is off', () => {
|
||||
const result = sanitizeEmailBodyForIframe('<img src="https://cdn.example/a.png">', false);
|
||||
expect(result.blockedExternalContent).toBe(false);
|
||||
|
||||
@@ -124,105 +124,3 @@ export function readableTextOn(colorString: string): string {
|
||||
const contrastWithWhite = 1.05 / (getLuminance(rgb.r, rgb.g, rgb.b) + 0.05);
|
||||
return contrastWithWhite >= 3 ? '#ffffff' : '#111827';
|
||||
}
|
||||
|
||||
export function isDarkColor(colorString: string): boolean {
|
||||
const rgb = parseColor(colorString);
|
||||
if (!rgb) return false;
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
return luminance < 0.5;
|
||||
}
|
||||
|
||||
export function transformColorForDarkMode(colorString: string): string {
|
||||
const rgb = parseColor(colorString);
|
||||
if (!rgb) return colorString;
|
||||
|
||||
if (rgb.a !== undefined && rgb.a < 0.1) {
|
||||
return colorString;
|
||||
}
|
||||
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
|
||||
if (luminance >= 0.6) return colorString;
|
||||
|
||||
const blendFactor = 0.85 - (luminance / 0.6) * 0.55;
|
||||
|
||||
const r = Math.min(255, Math.round(rgb.r + (255 - rgb.r) * blendFactor));
|
||||
const g = Math.min(255, Math.round(rgb.g + (255 - rgb.g) * blendFactor));
|
||||
const b = Math.min(255, Math.round(rgb.b + (255 - rgb.b) * blendFactor));
|
||||
|
||||
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
export function transformBgColorForDarkMode(colorString: string): string {
|
||||
const rgb = parseColor(colorString);
|
||||
if (!rgb) return colorString;
|
||||
|
||||
if (rgb.a !== undefined && rgb.a < 0.1) {
|
||||
return colorString;
|
||||
}
|
||||
|
||||
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
|
||||
|
||||
if (luminance < 0.2) return colorString;
|
||||
|
||||
const blendFactor = Math.min(0.9, (luminance - 0.2) * 1.125);
|
||||
const darkR = 30, darkG = 31, darkB = 38;
|
||||
|
||||
const r = Math.max(0, Math.round(rgb.r + (darkR - rgb.r) * blendFactor));
|
||||
const g = Math.max(0, Math.round(rgb.g + (darkG - rgb.g) * blendFactor));
|
||||
const b = Math.max(0, Math.round(rgb.b + (darkB - rgb.b) * blendFactor));
|
||||
|
||||
return rgb.a !== undefined ? `rgba(${r}, ${g}, ${b}, ${rgb.a})` : `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
export function transformInlineStyles(cssText: string, theme: 'light' | 'dark'): string {
|
||||
if (theme !== 'dark' || !cssText) {
|
||||
return cssText;
|
||||
}
|
||||
|
||||
const styleProps = cssText.split(';').map((prop) => prop.trim()).filter(Boolean);
|
||||
|
||||
const transformedProps = styleProps.map((prop) => {
|
||||
const colonIndex = prop.indexOf(':');
|
||||
if (colonIndex === -1) return prop;
|
||||
|
||||
const property = prop.slice(0, colonIndex).trim();
|
||||
const value = prop.slice(colonIndex + 1).trim();
|
||||
|
||||
if (property === 'color') {
|
||||
const hasImportant = value.includes('!important');
|
||||
const colorValue = value.replace('!important', '').trim();
|
||||
const transformed = transformColorForDarkMode(colorValue);
|
||||
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
|
||||
}
|
||||
|
||||
if (property === 'background-color') {
|
||||
const hasImportant = value.includes('!important');
|
||||
const colorValue = value.replace('!important', '').trim();
|
||||
const transformed = transformBgColorForDarkMode(colorValue);
|
||||
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
|
||||
}
|
||||
|
||||
if (property === 'background' && !value.includes('url(')) {
|
||||
const colorMatch = value.match(/#[0-9a-f]{3,6}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-z]+/i);
|
||||
if (colorMatch) {
|
||||
const hasImportant = value.includes('!important');
|
||||
const originalColor = colorMatch[0];
|
||||
const transformed = transformBgColorForDarkMode(originalColor);
|
||||
const newValue = value.replace(originalColor, transformed);
|
||||
return `${property}: ${newValue.replace('!important', '').trim()}${hasImportant ? ' !important' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (property === 'border-color') {
|
||||
const hasImportant = value.includes('!important');
|
||||
const colorValue = value.replace('!important', '').trim();
|
||||
const transformed = transformColorForDarkMode(colorValue);
|
||||
return `${property}: ${transformed}${hasImportant ? ' !important' : ''}`;
|
||||
}
|
||||
|
||||
return prop;
|
||||
});
|
||||
|
||||
return transformedProps.join('; ');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Dark mode for HTML email bodies rendered in a viewer iframe.
|
||||
*
|
||||
* Instead of inverting the whole body with a CSS filter (which turns brand
|
||||
* red into pink, flips photos that then need re-inverting, and depends on
|
||||
* which background attributes survived the external-content blocker), the
|
||||
* rendered document is read back through getComputedStyle and its colours are
|
||||
* rewritten in place:
|
||||
*
|
||||
* - Light backgrounds are darkened with their hue kept. Vivid colours (brand
|
||||
* buttons, banners) and backgrounds that are already dark stay as sent.
|
||||
* - Text and borders only change when they lack contrast against the new
|
||||
* background; they are then lightened (or darkened) in their own hue.
|
||||
* - Images are never touched. An element painted with a background image keeps
|
||||
* its colours, and so does the text drawn straight on it: the sender
|
||||
* designed the two to be read together.
|
||||
*
|
||||
* Because the pass works on computed styles, colours from inline styles,
|
||||
* legacy attributes (bgcolor, <font color>) and <style> blocks are all handled
|
||||
* the same way, whether or not remote content is loaded.
|
||||
*/
|
||||
|
||||
/** Whether the email ships its own dark styles. */
|
||||
export function emailHasNativeDarkMode(html: string): boolean {
|
||||
return /prefers-color-scheme\s*:\s*dark/i.test(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* `color-scheme` for the iframe element and its root. A mail with its own
|
||||
* dark styles gets `dark`, so its `prefers-color-scheme: dark` rules apply
|
||||
* whenever the app shows it dark - not only when the OS is dark too, which is
|
||||
* what `light dark` would mean.
|
||||
*/
|
||||
export function emailColorScheme(isDark: boolean, nativeDarkMode: boolean): 'light' | 'dark' {
|
||||
return isDark && nativeDarkMode ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
const ALWAYS = '(min-width: 0px)';
|
||||
const NEVER = '(max-width: 0.001px)';
|
||||
|
||||
/**
|
||||
* Pins the mail's `prefers-color-scheme` media queries to the theme the app
|
||||
* shows it in. Inside the iframe they would follow the OS, not the app: a
|
||||
* light app on a dark OS would render the mail's dark variant, and the app's
|
||||
* dark mode on a light OS would never trigger it. Only <style> blocks are
|
||||
* rewritten.
|
||||
*/
|
||||
export function resolveColorSchemeMedia(html: string, isDark: boolean): string {
|
||||
if (!/prefers-color-scheme/i.test(html)) return html;
|
||||
return html.replace(/(<style\b[^>]*>)([\s\S]*?)(<\/style>)/gi, (_m, open: string, css: string, close: string) =>
|
||||
open + css.replace(/\(\s*prefers-color-scheme\s*:\s*(dark|light)\s*\)/gi, (_q, scheme: string) =>
|
||||
(scheme.toLowerCase() === 'dark') === isDark ? ALWAYS : NEVER) + close);
|
||||
}
|
||||
|
||||
/** Canvas the converted mail sits on (white maps to this). */
|
||||
export const EMAIL_DARK_BACKGROUND = '#141414';
|
||||
const DARK_TEXT = '#e6e6e6';
|
||||
const DARK_LINK = '#8ab4f8';
|
||||
|
||||
/** Class on <html> that keeps the body hidden until the pass has run. */
|
||||
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.
|
||||
*/
|
||||
export function emailDarkModeCss(): string {
|
||||
return `
|
||||
html { background: ${EMAIL_DARK_BACKGROUND}; }
|
||||
body { color: ${DARK_TEXT}; background: ${EMAIL_DARK_BACKGROUND}; }
|
||||
a { color: ${DARK_LINK}; }
|
||||
@keyframes bulwark-dark-reveal { to { visibility: visible; } }
|
||||
html.${DARK_PENDING_CLASS} body { visibility: hidden; animation: bulwark-dark-reveal 0s 1.5s forwards; }
|
||||
`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colour math (sRGB <-> OKLab/OKLCH, WCAG contrast)
|
||||
|
||||
export interface Rgba { r: number; g: number; b: number; a: number }
|
||||
interface Lch { l: number; c: number; h: number }
|
||||
|
||||
/** Parses the `rgb()` / `rgba()` forms getComputedStyle returns. */
|
||||
export function parseComputedColor(value: string): Rgba | null {
|
||||
const m = value.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i);
|
||||
if (!m) return null;
|
||||
let a = 1;
|
||||
if (m[4] !== undefined) a = m[4].endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
|
||||
return { r: +m[1], g: +m[2], b: +m[3], a };
|
||||
}
|
||||
|
||||
export function formatColor({ r, g, b, a }: Rgba): string {
|
||||
const R = Math.round(r), G = Math.round(g), B = Math.round(b);
|
||||
return a >= 1 ? `rgb(${R}, ${G}, ${B})` : `rgba(${R}, ${G}, ${B}, ${+a.toFixed(3)})`;
|
||||
}
|
||||
|
||||
const toLinear = (v: number) => {
|
||||
const c = v / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
const fromLinear = (v: number) => {
|
||||
const c = v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
||||
return c * 255;
|
||||
};
|
||||
|
||||
function toLch({ r, g, b }: Rgba): Lch {
|
||||
const lr = toLinear(r), lg = toLinear(g), lb = toLinear(b);
|
||||
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
||||
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
||||
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
||||
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
||||
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
||||
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
||||
return { l: L, c: Math.hypot(A, B), h: Math.atan2(B, A) };
|
||||
}
|
||||
|
||||
function lchToLinear({ l: L, c, h }: Lch): [number, number, number] {
|
||||
const A = c * Math.cos(h), B = c * Math.sin(h);
|
||||
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
|
||||
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
|
||||
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
|
||||
return [
|
||||
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
||||
];
|
||||
}
|
||||
|
||||
/** OKLCH back to sRGB, pulling chroma in until the colour fits the gamut. */
|
||||
function fromLch(lch: Lch, a: number): Rgba {
|
||||
let c = lch.c;
|
||||
let lin = lchToLinear({ ...lch, c });
|
||||
for (let i = 0; i < 24 && lin.some((v) => v < -1e-4 || v > 1 + 1e-4); i++) {
|
||||
c *= 0.9;
|
||||
lin = lchToLinear({ ...lch, c });
|
||||
}
|
||||
const [r, g, b] = lin.map((v) => Math.min(255, Math.max(0, fromLinear(Math.min(1, Math.max(0, v))))));
|
||||
return { r, g, b, a };
|
||||
}
|
||||
|
||||
function luminance({ r, g, b }: Rgba): number {
|
||||
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
|
||||
}
|
||||
|
||||
export function contrastRatio(x: Rgba, y: Rgba): number {
|
||||
const lx = luminance(x), ly = luminance(y);
|
||||
return (Math.max(lx, ly) + 0.05) / (Math.min(lx, ly) + 0.05);
|
||||
}
|
||||
|
||||
/** Paints a translucent colour over an opaque one. */
|
||||
function composite(top: Rgba, under: Rgba): Rgba {
|
||||
const a = top.a;
|
||||
return {
|
||||
r: top.r * a + under.r * (1 - a),
|
||||
g: top.g * a + under.g * (1 - a),
|
||||
b: top.b * a + under.b * (1 - a),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lightness white lands on, and the bottom of the "already dark" range. */
|
||||
const BG_WHITE_L = 0.2;
|
||||
const BG_DARK_L = 0.45;
|
||||
/** Chroma from which a background counts as a deliberate, vivid colour. */
|
||||
const VIVID_CHROMA = 0.1;
|
||||
|
||||
/**
|
||||
* Dark counterpart of a background colour. Light colours are mirrored into
|
||||
* the dark range (white → the dark canvas, light grey → a little lighter, so
|
||||
* the mail's card/canvas layering survives), mid greys move down to meet that
|
||||
* range, and vivid or already-dark colours are left alone.
|
||||
*/
|
||||
export function darkenBackground(color: Rgba): Rgba {
|
||||
if (color.a === 0) return color;
|
||||
const lch = toLch(color);
|
||||
if (lch.l <= BG_DARK_L) return color;
|
||||
if (lch.c >= VIVID_CHROMA) return color;
|
||||
let l: number;
|
||||
if (lch.l >= 0.7) {
|
||||
// 1.0 → 0.2, 0.7 → 0.38
|
||||
l = BG_WHITE_L + (1 - lch.l) * 0.6;
|
||||
} else {
|
||||
// 0.45 (kept) … 0.7 (→ 0.38): fold mid greys into the dark range.
|
||||
l = BG_DARK_L - ((lch.l - BG_DARK_L) / (0.7 - BG_DARK_L)) * (BG_DARK_L - 0.38);
|
||||
}
|
||||
return fromLch({ l, c: lch.c, h: lch.h }, color.a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `color` if it already reads against `bg`, otherwise a colour of the
|
||||
* same hue moved away from the background's lightness until it reaches
|
||||
* `minRatio`. Dark text on a dark background is mirrored first (black → near
|
||||
* white, dark grey → light grey), which keeps the sender's hierarchy of
|
||||
* text greys instead of flattening them all to one value.
|
||||
*/
|
||||
export function ensureContrast(color: Rgba, bg: Rgba, minRatio: number): Rgba {
|
||||
if (color.a === 0) return color;
|
||||
const opaque = color.a < 1 ? composite(color, bg) : color;
|
||||
if (contrastRatio(opaque, bg) >= minRatio) return color;
|
||||
const lch = toLch(opaque);
|
||||
const bgL = toLch(bg).l;
|
||||
const lighten = bgL < 0.6;
|
||||
let l = lighten ? Math.max(lch.l, 0.96 - lch.l * 0.5) : Math.min(lch.l, 0.3);
|
||||
let out = fromLch({ ...lch, l }, 1);
|
||||
for (let i = 0; i < 40 && contrastRatio(out, bg) < minRatio; i++) {
|
||||
l = lighten ? Math.min(1, l + 0.02) : Math.max(0, l - 0.02);
|
||||
out = fromLch({ ...lch, l }, 1);
|
||||
if (l === 0 || l === 1) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rewrites every `rgb()`/`rgba()` in a computed gradient. */
|
||||
function mapGradientColors(value: string, map: (c: Rgba) => Rgba): string {
|
||||
return value.replace(/rgba?\([^)]*\)/gi, (m) => {
|
||||
const c = parseComputedColor(m);
|
||||
return c ? formatColor(map(c)) : m;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The DOM pass
|
||||
|
||||
const BORDER_SIDES = ['top', 'right', 'bottom', 'left'] as const;
|
||||
const WHITE: Rgba = { r: 255, g: 255, b: 255, a: 1 };
|
||||
const SKIP_TAGS = new Set(['IMG', 'VIDEO', 'CANVAS', 'PICTURE', 'SOURCE', 'svg', 'SVG', 'STYLE', 'SCRIPT', 'BR', 'WBR', 'HEAD', 'TITLE', 'META', 'LINK']);
|
||||
|
||||
/** True for a background-image that actually paints a picture. */
|
||||
function hasImage(backgroundImage: string): boolean {
|
||||
if (!backgroundImage || backgroundImage === 'none') return false;
|
||||
// The blocker rewrites remote urls to url() (computed as url("")); those
|
||||
// paint nothing.
|
||||
return /url\(/i.test(backgroundImage.replace(/url\(\s*(?:""|''|)\s*\)/gi, ''));
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
el: HTMLElement;
|
||||
color: Rgba | null;
|
||||
bg: Rgba | null;
|
||||
bgImage: string;
|
||||
borders: (Rgba | null)[];
|
||||
}
|
||||
|
||||
interface Surface {
|
||||
/** Opaque colour behind the element's content after conversion. */
|
||||
bg: Rgba;
|
||||
/** The same colour as the sender painted it. */
|
||||
original: Rgba;
|
||||
/** Content sits on a background image: leave its colours as sent. */
|
||||
onImage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts a kept dark fill that stood out from its light surroundings (a
|
||||
* near-black button on a white card) but now sinks into the darkened
|
||||
* surface around it.
|
||||
*/
|
||||
function keepSeparation(next: Rgba, original: Rgba, parent: Surface): Rgba {
|
||||
if (next !== original || original.a < 0.9) return next;
|
||||
if (contrastRatio(original, parent.original) < 3) return next;
|
||||
const target = 1.8;
|
||||
if (contrastRatio(next, parent.bg) >= target) return next;
|
||||
const lch = toLch(next);
|
||||
const parentL = toLch(parent.bg).l;
|
||||
let l = Math.max(lch.l, parentL);
|
||||
let out = next;
|
||||
for (let i = 0; i < 25 && contrastRatio(out, parent.bg) < target; i++) {
|
||||
l += 0.02;
|
||||
out = fromLch({ ...lch, l }, next.a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface EmailDarkModeOptions {
|
||||
/**
|
||||
* The user allowed remote content for this message. Only then may a remote
|
||||
* image be fetched a second time (with CORS) to look at its pixels.
|
||||
*/
|
||||
remoteContentAllowed?: boolean;
|
||||
}
|
||||
|
||||
type ImageVerdict = 'plate' | 'fine' | 'unknown';
|
||||
|
||||
/**
|
||||
* Looks at an image's pixels: does it have see-through areas, and would what
|
||||
* it draws there disappear against `bg`? 'unknown' when the pixels can't be
|
||||
* read (a cross-origin image without CORS).
|
||||
*/
|
||||
function judgeImagePixels(source: HTMLImageElement, doc: Document, bg: Rgba): ImageVerdict {
|
||||
const w = source.naturalWidth, h = source.naturalHeight;
|
||||
if (!w || !h) return 'fine';
|
||||
const scale = Math.min(1, 48 / Math.max(w, h));
|
||||
const cw = Math.max(1, Math.round(w * scale)), ch = Math.max(1, Math.round(h * scale));
|
||||
const canvas = doc.createElement('canvas');
|
||||
canvas.width = cw;
|
||||
canvas.height = ch;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return 'unknown';
|
||||
let data: Uint8ClampedArray;
|
||||
try {
|
||||
ctx.drawImage(source, 0, 0, cw, ch);
|
||||
data = ctx.getImageData(0, 0, cw, ch).data;
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
let clear = 0, drawn = 0, hidden = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3] / 255;
|
||||
if (a < 0.5) { clear++; continue; }
|
||||
drawn++;
|
||||
const px = composite({ r: data[i], g: data[i + 1], b: data[i + 2], a }, bg);
|
||||
if (contrastRatio(px, bg) < 2) hidden++;
|
||||
}
|
||||
const total = data.length / 4;
|
||||
// Opaque images cover any plate; mostly-readable artwork doesn't need one.
|
||||
if (clear / total < 0.02 || drawn === 0) return 'fine';
|
||||
return hidden / drawn > 0.35 ? 'plate' : 'fine';
|
||||
}
|
||||
|
||||
function whenLoaded(img: HTMLImageElement): Promise<boolean> {
|
||||
if (img.complete) return Promise.resolve(img.naturalWidth > 0);
|
||||
return new Promise((resolve) => {
|
||||
img.addEventListener('load', () => resolve(true), { once: true });
|
||||
img.addEventListener('error', () => resolve(false), { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Loads a second copy of a remote image with CORS so its pixels can be read. */
|
||||
function loadCorsCopy(img: HTMLImageElement, doc: Document): Promise<HTMLImageElement | null> {
|
||||
const src = img.currentSrc || img.src;
|
||||
if (!/^https?:/i.test(src)) return Promise.resolve(null);
|
||||
const copy = doc.createElement('img');
|
||||
copy.crossOrigin = 'anonymous';
|
||||
copy.referrerPolicy = 'no-referrer';
|
||||
const loaded = new Promise<HTMLImageElement | null>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(null), 8000);
|
||||
copy.onload = () => { clearTimeout(timer); resolve(copy); };
|
||||
copy.onerror = () => { clearTimeout(timer); resolve(null); };
|
||||
});
|
||||
copy.src = src;
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async function plateIfNeeded(img: HTMLImageElement, surface: Surface, options: EmailDarkModeOptions): Promise<void> {
|
||||
if (!(await whenLoaded(img))) return;
|
||||
const doc = img.ownerDocument;
|
||||
// Spacers and tracking pixels.
|
||||
if (img.naturalWidth <= 2 && img.naturalHeight <= 2) return;
|
||||
let verdict = judgeImagePixels(img, doc, surface.bg);
|
||||
if (verdict === 'unknown' && options.remoteContentAllowed) {
|
||||
const copy = await loadCorsCopy(img, doc);
|
||||
if (copy) verdict = judgeImagePixels(copy, doc, surface.bg);
|
||||
}
|
||||
// Pixels we can't read get the plate: a light box around a logo is better
|
||||
// than a logo that isn't there.
|
||||
if (verdict === 'fine' || !img.isConnected) return;
|
||||
img.style.setProperty('background-color', formatColor(surface.original), 'important');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a rendered email document to dark colours in place. Call once,
|
||||
* after the document has parsed (styles applied); it is a no-op on a second
|
||||
* call because inline overrides then report the converted colours. Image
|
||||
* plates are decided once each image has loaded, after this returns.
|
||||
*/
|
||||
export function applyEmailDarkMode(doc: Document, options: EmailDarkModeOptions = {}): void {
|
||||
const win = doc.defaultView;
|
||||
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)')!;
|
||||
|
||||
// Read everything first, then write: interleaving would force a style
|
||||
// recalc per element.
|
||||
const elements: HTMLElement[] = [body];
|
||||
body.querySelectorAll<HTMLElement>('*').forEach((el) => {
|
||||
if (!SKIP_TAGS.has(el.tagName) && !el.closest('svg')) elements.push(el);
|
||||
});
|
||||
const snaps: Snapshot[] = elements.map((el) => {
|
||||
const cs = win.getComputedStyle(el);
|
||||
return {
|
||||
el,
|
||||
color: parseComputedColor(cs.color),
|
||||
bg: parseComputedColor(cs.backgroundColor),
|
||||
bgImage: cs.backgroundImage,
|
||||
borders: BORDER_SIDES.map((side) =>
|
||||
cs.getPropertyValue(`border-${side}-style`) !== 'none' && parseFloat(cs.getPropertyValue(`border-${side}-width`)) > 0
|
||||
? parseComputedColor(cs.getPropertyValue(`border-${side}-color`))
|
||||
: null),
|
||||
};
|
||||
});
|
||||
|
||||
// The original canvas is white: that is what the light view paints.
|
||||
const root: Surface = { bg: base, original: WHITE, onImage: false };
|
||||
const surfaces = new Map<Element, Surface>();
|
||||
const surfaceOf = (el: Element | null): Surface => {
|
||||
for (let n = el; n; n = n.parentElement) {
|
||||
const s = surfaces.get(n);
|
||||
if (s) return s;
|
||||
}
|
||||
return root;
|
||||
};
|
||||
|
||||
const writes: Array<() => void> = [];
|
||||
for (const snap of snaps) {
|
||||
const { el } = snap;
|
||||
const parent = el === body ? root : surfaceOf(el.parentElement);
|
||||
let surface = parent;
|
||||
|
||||
if (hasImage(snap.bgImage)) {
|
||||
// Picture background: keep the element's own fill (it shows wherever
|
||||
// the picture is transparent or fails to load) and its text as sent.
|
||||
const fill = snap.bg && snap.bg.a > 0 ? composite(snap.bg, parent.bg) : parent.bg;
|
||||
surface = { bg: fill, original: fill, onImage: true };
|
||||
} else {
|
||||
if (snap.bgImage && snap.bgImage !== 'none' && !parent.onImage) {
|
||||
const gradient = mapGradientColors(snap.bgImage, darkenBackground);
|
||||
if (gradient !== snap.bgImage) writes.push(() => el.style.setProperty('background-image', gradient, 'important'));
|
||||
}
|
||||
// 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 (snap.bg && snap.bg.a > 0 && !(parent.onImage && snap.bg.a < 0.9)) {
|
||||
const original = snap.bg;
|
||||
const next = parent.onImage
|
||||
? darkenBackground(original)
|
||||
: keepSeparation(darkenBackground(original), original, parent);
|
||||
surface = {
|
||||
bg: composite(next, parent.bg),
|
||||
original: composite(original, parent.original),
|
||||
onImage: false,
|
||||
};
|
||||
if (next !== original) writes.push(() => el.style.setProperty('background-color', formatColor(next), 'important'));
|
||||
}
|
||||
}
|
||||
surfaces.set(el, surface);
|
||||
|
||||
if (surface.onImage) continue;
|
||||
|
||||
if (snap.color) {
|
||||
const next = ensureContrast(snap.color, surface.bg, 4.5);
|
||||
if (next !== snap.color) writes.push(() => el.style.setProperty('color', formatColor(next), 'important'));
|
||||
}
|
||||
snap.borders.forEach((border, i) => {
|
||||
if (!border || border.a === 0) return;
|
||||
// Borders sit between the element and its parent; a light rule on a
|
||||
// light mail darkens with the backgrounds, a dark one is lifted just
|
||||
// enough to stay visible.
|
||||
const darkened = darkenBackground(border);
|
||||
const next = darkened !== border ? darkened : ensureContrast(border, parent.bg, 1.6);
|
||||
if (next !== border) {
|
||||
writes.push(() => el.style.setProperty(`border-${BORDER_SIDES[i]}-color`, formatColor(next), 'important'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Images keep their pixels, but a transparent one (a black logo, a dark
|
||||
// icon) was drawn for the light background it sat on and can vanish on
|
||||
// the darkened one. Those get that original background back behind them.
|
||||
const plates: Array<{ img: HTMLImageElement; surface: Surface }> = [];
|
||||
body.querySelectorAll('img').forEach((img) => {
|
||||
const surface = surfaceOf(img.parentElement);
|
||||
if (surface.onImage || surface.bg === surface.original) return;
|
||||
if (toLch(surface.original).l < 0.7 || contrastRatio(surface.original, surface.bg) < 3) return;
|
||||
plates.push({ img, surface });
|
||||
});
|
||||
|
||||
for (const write of writes) write();
|
||||
for (const { img, surface } of plates) void plateIfNeeded(img, surface, options);
|
||||
} finally {
|
||||
doc.documentElement.classList.remove(DARK_PENDING_CLASS);
|
||||
}
|
||||
}
|
||||
@@ -807,7 +807,9 @@ export function plainTextToSafeHtml(text: string, linkClass = ''): string {
|
||||
* and hides it if it contains no meaningful visible content.
|
||||
*/
|
||||
export function collapseBlockedImageContainers(html: string): string {
|
||||
const doc = parseHtmlSafely(html);
|
||||
// Wrapped in <body> so a leading <style> stays in the body instead of being
|
||||
// hoisted into <head>, where body.innerHTML below would drop it.
|
||||
const doc = parseHtmlSafely(`<body>${html}</body>`);
|
||||
const blockedImages = doc.querySelectorAll('img[data-blocked-src]');
|
||||
|
||||
blockedImages.forEach((img) => {
|
||||
|
||||
Reference in New Issue
Block a user