Let the theme be forced onto mail that styles itself
Appearance gained "Apply the theme to messages too" some time ago, and it themes an HTML message only when the message brings no colours of its own. That predicate is the right default and it almost never passes: one `color:#FFFFFF` on one button label opts a whole message out, so in real mail — receipts, shipping notices, anything from a template — the switch did nothing at all and the reader kept a bright white card on a dark UI. A second switch, off by default and only meaningful with the first on, forces the palette over the sender's colours. It cannot be done perfectly, which is why it is a separate, explicit choice: the same bargain a dark-reader extension makes. What it does is tell two kinds of colour apart. A *sheet* the design sits on — the white 600px wrapper — is neutralised, and a *painted surface* — a call to action, a footer banner — is kept whole so its label stays legible on it. Relative luminance decides, at 0.5: white wrappers sit at 1.0, a blue button near 0.09. Only the painted ones are marked, with data-ihm-keep, and one rule in EMAIL_BASE_CSS neutralises everything else. Nothing the sender wrote is removed, so the switch is reversible, colours arriving from a <style> block are covered as well as inline ones, and print still pins the tokens to ink on white. The mock grew the message this is about: an outer wrapper on bgcolor="#ffffff", a <style> block, a coloured button, a grey footer. Without one, neither the bug nor the fix could be seen. Verified in a browser against the mock: with only the first switch on the card is still white; with both, the wrapper computes to transparent, body text follows the theme, and the button keeps white-on-blue. Two surfaces marked, which are the two the message paints. Closes #290
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { htmlDeclaresColors, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
|
||||
import { LIGHT_SURFACE_LUMINANCE, htmlDeclaresColors, markKeptSurfaces, relativeLuminance, sanitizeEditorHtml, sanitizeEmailHtml } from "../html";
|
||||
|
||||
describe("sanitizeEmailHtml", () => {
|
||||
it("removes scripts and event handlers", () => {
|
||||
@@ -51,6 +51,84 @@ describe("htmlDeclaresColors", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Forcing the theme onto mail that styles itself — issue #290.
|
||||
*
|
||||
* The switch above it leaves nearly all HTML mail alone, because one colour
|
||||
* anywhere opts a message out. What this half has to get right is telling a
|
||||
* sheet the design sits on from a surface painted on top of it: neutralise the
|
||||
* first and the white card goes away, keep the second and a button keeps a
|
||||
* label you can still read.
|
||||
*/
|
||||
describe("relativeLuminance", () => {
|
||||
it("reads the forms mail actually uses", () => {
|
||||
expect(relativeLuminance("#ffffff")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("#FFF")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("#000000")).toBeCloseTo(0, 5);
|
||||
expect(relativeLuminance("white")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("rgb(255, 255, 255)")).toBeCloseTo(1, 5);
|
||||
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("has nothing to say about a colour it cannot read", () => {
|
||||
// Not a failure: the caller treats null as "no deliberate surface", which
|
||||
// is the safe way round — an unreadable colour must not keep a white sheet.
|
||||
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
|
||||
expect(relativeLuminance("var(--brand)")).toBeNull();
|
||||
expect(relativeLuminance("")).toBeNull();
|
||||
});
|
||||
|
||||
it("treats a fully transparent colour as painting nothing", () => {
|
||||
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
|
||||
expect(relativeLuminance("transparent")).toBeNull();
|
||||
});
|
||||
|
||||
it("puts a white wrapper above the threshold and a call to action below it", () => {
|
||||
expect(relativeLuminance("#ffffff")!).toBeGreaterThanOrEqual(LIGHT_SURFACE_LUMINANCE);
|
||||
expect(relativeLuminance("#1155CC")!).toBeLessThan(LIGHT_SURFACE_LUMINANCE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markKeptSurfaces", () => {
|
||||
const frag = (html: string) => {
|
||||
const d = document.createElement("div");
|
||||
d.innerHTML = html;
|
||||
return d;
|
||||
};
|
||||
|
||||
it("keeps a coloured button and drops the white sheet around it", () => {
|
||||
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
|
||||
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
|
||||
const d = frag('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
|
||||
expect(markKeptSurfaces(d)).toBe(1);
|
||||
expect(d.querySelector("table")!.hasAttribute("data-ihm-keep")).toBe(false);
|
||||
expect(d.querySelector("td")!.hasAttribute("data-ihm-keep")).toBe(true);
|
||||
// The label is not marked itself; the CSS keeps it because it is inside
|
||||
// something that is, which is what stops white-on-blue turning unreadable.
|
||||
expect(d.querySelector("a")!.hasAttribute("data-ihm-keep")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads an inline background as well as the attribute", () => {
|
||||
const d = frag('<div style="background-color:#111827">dark</div><div style="background:#f8f8ff">sheet</div>');
|
||||
expect(markKeptSurfaces(d)).toBe(1);
|
||||
expect(d.querySelectorAll("[data-ihm-keep]").length).toBe(1);
|
||||
expect((d.querySelector("[data-ihm-keep]") as HTMLElement).textContent).toBe("dark");
|
||||
});
|
||||
|
||||
it("marks nothing in mail that paints no backgrounds", () => {
|
||||
const d = frag('<p style="color:#333">text</p><a href="https://x.io">link</a>');
|
||||
expect(markKeptSurfaces(d)).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves the sender's own markup alone, so the switch is reversible", () => {
|
||||
const d = frag('<table><tr><td bgcolor="#1155CC" style="color:#fff">Buy</td></tr></table>');
|
||||
markKeptSurfaces(d);
|
||||
const td = d.querySelector("td")!;
|
||||
expect(td.getAttribute("bgcolor")).toBe("#1155CC");
|
||||
expect(td.style.color).toBe("rgb(255, 255, 255)");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A shadow root scopes selectors, not layout. Mail CSS saying `position:fixed`
|
||||
* is still positioned against the viewport, so a sender could paint over the
|
||||
|
||||
@@ -191,12 +191,28 @@ export const EMAIL_BASE_CSS = `
|
||||
.ihm-email-root.themed a { color: var(--link, #0f766e); }
|
||||
.ihm-email-root.themed hr { border-color: var(--border, #e3e7ec); }
|
||||
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
|
||||
|
||||
/* "Even mail that styles itself" — the second, opt-in switch, applied on top of
|
||||
.themed. Everything the sender coloured is neutralised except the surfaces
|
||||
marked by markKeptSurfaces() and their contents, so a white wrapper table
|
||||
stops being a bright card while a blue button keeps its white label. The
|
||||
sender's markup is untouched; this is all cascade, so the switch is
|
||||
reversible and print still pins the tokens to ink on white. */
|
||||
.ihm-email-root.forced { color: var(--fg, #1f2937) !important; background: var(--bg-elev, #fff) !important; }
|
||||
.ihm-email-root.forced *:not([data-ihm-keep]):not([data-ihm-keep] *) { color: inherit !important; background-color: transparent !important; }
|
||||
.ihm-email-root.forced a:not([data-ihm-keep]):not([data-ihm-keep] *) { color: var(--link, #0f766e) !important; }
|
||||
`;
|
||||
|
||||
/**
|
||||
* Does this message paint itself? Mail that sets a background or text colour
|
||||
* has a design of its own, and forcing a dark palette on half of it is worse
|
||||
* than leaving it alone — so those keep the light card they were built for.
|
||||
*
|
||||
* The bar is deliberately low, and that is the point of the second switch
|
||||
* (`themeStyledMessages`): in real mail this is true of very nearly everything.
|
||||
* One `color:#FFFFFF` on one button label is enough, so a template that is
|
||||
* plain in every way a reader would notice still counts as painting itself.
|
||||
* See `markKeptSurfaces` for what the opt-in does about it.
|
||||
*/
|
||||
export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
|
||||
const haystack = `${bodyStyle} ${html}`;
|
||||
@@ -207,6 +223,88 @@ export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- forcing the theme onto mail that styles itself ---------- */
|
||||
|
||||
/**
|
||||
* Relative luminance per WCAG 2.x, or `null` when the colour cannot be read.
|
||||
*
|
||||
* Only what actually turns up in mail is parsed: hex in three, six or eight
|
||||
* digits, `rgb()`/`rgba()`, and the handful of names senders still write out.
|
||||
* Anything else is `null`, which the caller treats as "not a deliberate
|
||||
* surface" — the safe way round, because the failure it avoids is a white
|
||||
* sheet surviving the switch the reader just turned on.
|
||||
*/
|
||||
const NAMED: Record<string, string> = {
|
||||
white: "#ffffff", ivory: "#fffff0", snow: "#fffafa", whitesmoke: "#f5f5f5",
|
||||
ghostwhite: "#f8f8ff", floralwhite: "#fffaf0", seashell: "#fff5ee", beige: "#f5f5dc",
|
||||
linen: "#faf0e6", lightgray: "#d3d3d3", lightgrey: "#d3d3d3", gainsboro: "#dcdcdc",
|
||||
silver: "#c0c0c0", gray: "#808080", grey: "#808080", black: "#000000",
|
||||
navy: "#000080", darkblue: "#00008b", maroon: "#800000", teal: "#008080",
|
||||
};
|
||||
|
||||
export function relativeLuminance(color: string): number | null {
|
||||
const raw = color.trim().toLowerCase();
|
||||
if (!raw || raw === "transparent" || raw === "inherit" || raw === "initial" || raw === "none") return null;
|
||||
let r: number, g: number, b: number, a = 1;
|
||||
const named = NAMED[raw];
|
||||
const hex = (named ?? raw).match(/^#([0-9a-f]{3,8})$/);
|
||||
if (hex) {
|
||||
const h = hex[1]!;
|
||||
if (h.length === 3) [r, g, b] = [h[0]! + h[0]!, h[1]! + h[1]!, h[2]! + h[2]!].map((x) => parseInt(x, 16)) as [number, number, number];
|
||||
else if (h.length === 6 || h.length === 8) {
|
||||
r = parseInt(h.slice(0, 2), 16); g = parseInt(h.slice(2, 4), 16); b = parseInt(h.slice(4, 6), 16);
|
||||
if (h.length === 8) a = parseInt(h.slice(6, 8), 16) / 255;
|
||||
} else return null;
|
||||
} else {
|
||||
const m = raw.match(/^rgba?\(\s*([0-9.]+)[\s,]+([0-9.]+)[\s,]+([0-9.]+)(?:[\s,/]+([0-9.%]+))?\s*\)$/);
|
||||
if (!m) return null;
|
||||
r = Number(m[1]); g = Number(m[2]); b = Number(m[3]);
|
||||
if (m[4] !== undefined) a = m[4].endsWith("%") ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
|
||||
}
|
||||
if ([r, g, b, a].some((n) => !Number.isFinite(n))) return null;
|
||||
// A fully transparent colour paints nothing, whatever its channels say.
|
||||
if (a === 0) return null;
|
||||
const lin = (c: number) => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
|
||||
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Above this, a background is a sheet the message is laid on rather than a
|
||||
* thing drawn on top of it. White wrappers sit at 1.0; the blue of a call to
|
||||
* action lands near 0.09, mid-grey near 0.22.
|
||||
*/
|
||||
export const LIGHT_SURFACE_LUMINANCE = 0.5;
|
||||
|
||||
/**
|
||||
* Mark the surfaces that must survive being themed, and count them.
|
||||
*
|
||||
* The reader has asked for their palette on mail that brings its own, which
|
||||
* cannot be done perfectly — this is the same bargain a dark-reader extension
|
||||
* makes. What it can do is tell the two kinds of colour apart: a **sheet** the
|
||||
* design sits on, which is what reads as a bright card and is neutralised, and
|
||||
* a **painted surface** — a button, a banner — which is kept whole so its
|
||||
* label stays legible on it.
|
||||
*
|
||||
* Only the second is marked, with `data-ihm-keep`, and one CSS rule in
|
||||
* EMAIL_BASE_CSS neutralises everything that is not marked or inside something
|
||||
* marked. Nothing the sender wrote is removed, so turning the switch off puts
|
||||
* the message back exactly as it was — and a colour that arrived from a
|
||||
* `<style>` block rather than an attribute is covered too, which is most of
|
||||
* them in modern templates.
|
||||
*/
|
||||
export function markKeptSurfaces(root: ParentNode): number {
|
||||
let kept = 0;
|
||||
for (const el of Array.from(root.querySelectorAll<HTMLElement>("*"))) {
|
||||
const declared = el.getAttribute("bgcolor") ?? el.style?.backgroundColor ?? "";
|
||||
if (!declared) continue;
|
||||
const lum = relativeLuminance(declared);
|
||||
if (lum === null || lum >= LIGHT_SURFACE_LUMINANCE) continue;
|
||||
el.setAttribute("data-ihm-keep", "");
|
||||
kept++;
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
export const TEXT_EMAIL_CSS = `
|
||||
:host { display:block; }
|
||||
.ihm-text-root { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 13.5px; line-height:1.55; white-space: pre-wrap; overflow-wrap: anywhere; color: inherit; }
|
||||
|
||||
Reference in New Issue
Block a user