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
|
||||
|
||||
Reference in New Issue
Block a user