mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +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
127 lines
3.4 KiB
TypeScript
127 lines
3.4 KiB
TypeScript
type RGB = { r: number; g: number; b: number; a?: number };
|
|
|
|
const namedColors: Record<string, string> = {
|
|
transparent: 'rgba(0,0,0,0)',
|
|
black: '#000000',
|
|
white: '#ffffff',
|
|
red: '#ff0000',
|
|
green: '#008000',
|
|
blue: '#0000ff',
|
|
yellow: '#ffff00',
|
|
cyan: '#00ffff',
|
|
magenta: '#ff00ff',
|
|
gray: '#808080',
|
|
grey: '#808080',
|
|
silver: '#c0c0c0',
|
|
maroon: '#800000',
|
|
olive: '#808000',
|
|
lime: '#00ff00',
|
|
aqua: '#00ffff',
|
|
teal: '#008080',
|
|
navy: '#000080',
|
|
fuchsia: '#ff00ff',
|
|
purple: '#800080',
|
|
};
|
|
|
|
export function parseColor(colorString: string): RGB | null {
|
|
if (!colorString || typeof colorString !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
const color = colorString.trim().toLowerCase();
|
|
|
|
if (color === 'inherit' || color === 'currentcolor') {
|
|
return null;
|
|
}
|
|
|
|
if (namedColors[color]) {
|
|
return parseColor(namedColors[color]);
|
|
}
|
|
|
|
if (color === 'transparent') {
|
|
return { r: 0, g: 0, b: 0, a: 0 };
|
|
}
|
|
|
|
const hexMatch = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
if (hexMatch) {
|
|
const hex = hexMatch[1];
|
|
if (hex.length === 3) {
|
|
return {
|
|
r: parseInt(hex[0] + hex[0], 16),
|
|
g: parseInt(hex[1] + hex[1], 16),
|
|
b: parseInt(hex[2] + hex[2], 16),
|
|
};
|
|
}
|
|
return {
|
|
r: parseInt(hex.substr(0, 2), 16),
|
|
g: parseInt(hex.substr(2, 2), 16),
|
|
b: parseInt(hex.substr(4, 2), 16),
|
|
};
|
|
}
|
|
|
|
const rgbMatch = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/);
|
|
if (rgbMatch) {
|
|
const r = parseInt(rgbMatch[1], 10);
|
|
const g = parseInt(rgbMatch[2], 10);
|
|
const b = parseInt(rgbMatch[3], 10);
|
|
const a = rgbMatch[4] ? parseFloat(rgbMatch[4]) : undefined;
|
|
|
|
if (r > 255 || g > 255 || b > 255 || r < 0 || g < 0 || b < 0) {
|
|
return null;
|
|
}
|
|
if (a !== undefined && (a < 0 || a > 1)) {
|
|
return null;
|
|
}
|
|
|
|
return { r, g, b, a };
|
|
}
|
|
|
|
const hslMatch = color.match(/^hsla?\((\d+),\s*([\d.]+)%,\s*([\d.]+)%(?:,\s*([\d.]+))?\)$/);
|
|
if (hslMatch) {
|
|
const h = parseInt(hslMatch[1], 10) / 360;
|
|
const s = parseFloat(hslMatch[2]) / 100;
|
|
const l = parseFloat(hslMatch[3]) / 100;
|
|
const a = hslMatch[4] ? parseFloat(hslMatch[4]) : undefined;
|
|
|
|
const hue2rgb = (p: number, q: number, t: number) => {
|
|
if (t < 0) t += 1;
|
|
if (t > 1) t -= 1;
|
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
if (t < 1 / 2) return q;
|
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
return p;
|
|
};
|
|
|
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
const p = 2 * l - q;
|
|
|
|
return {
|
|
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
|
g: Math.round(hue2rgb(p, q, h) * 255),
|
|
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
|
a,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function getLuminance(r: number, g: number, b: number): number {
|
|
const [rs, gs, bs] = [r, g, b].map((c) => {
|
|
const val = c / 255;
|
|
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
|
|
});
|
|
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
|
|
}
|
|
|
|
/**
|
|
* Text colour for a label on a solid fill: white while white keeps 3:1
|
|
* contrast with the fill, near-black on lighter fills.
|
|
*/
|
|
export function readableTextOn(colorString: string): string {
|
|
const rgb = parseColor(colorString);
|
|
if (!rgb) return '#ffffff';
|
|
const contrastWithWhite = 1.05 / (getLuminance(rgb.r, rgb.g, rgb.b) + 0.05);
|
|
return contrastWithWhite >= 3 ? '#ffffff' : '#111827';
|
|
}
|