mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
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
476 lines
19 KiB
TypeScript
476 lines
19 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|