Web Totals

Học từ web lớn #22: Dark mode cho email HTML bằng CIELAB (Outlook)

Outlook Web bật dark mode cho email HTML bất kỳ: chỉ sửa màu thiếu tương phản, đổi độ sáng trong CIELAB, giữ sắc độ, copy ra vẫn giữ màu gốc. Viết lại kèm demo.

hunghg255hunghg25528 min read
#web-patterns#outlook#dark-mode#color#accessibility#email
Học từ web lớn #22: Dark mode cho email HTML bằng CIELAB (Outlook)

Làm dark mode cho giao diện của mình thì dễ: bạn kiểm soát mọi biến CSS. Làm dark mode cho HTML của người khác thì khác hẳn. Một email marketing có bảng lồng bảng, style="color:#333333" khắp nơi, <td bgcolor="#ffffff">, <font color="red"> từ thời Outlook 2003, logo và ảnh sản phẩm. Bạn không thể viết lại CSS cho nó, chỉ có thể sửa màu từng phần tử sau khi nó đã render.

Gặp bài toán này ở đâu? Client email, khung xem trước bài viết dán từ Word/Google Docs, nội dung CMS do nhiều người soạn, widget nhúng bên thứ ba.

Cách nhanh nhất là filter: invert(1) hue-rotate(180deg) cho cả khung. Nó đảo được nền trắng thành đen, nhưng đảo luôn ảnh (bầu trời xanh thành nâu đất) và làm màu thương hiệu lệch hẳn. Outlook Web làm khác: duyệt từng phần tử, chỉ sửa màu nào không đủ tương phản, và sửa trong không gian màu CIELAB để giữ nguyên sắc độ.

Tip

Tóm tắt

  • Với mỗi phần tử: màu chữ có đủ tương phản 4.5:1 với nền dark mode không? Màu nền có đủ tương phản với màu chữ không? Đủ thì để nguyên.
  • Không đủ thì đổi sang CIELAB, giữ a* và b* (sắc độ), chỉ tính lại độ sáng L*. Xanh thương hiệu vẫn là xanh thương hiệu, chỉ sáng hơn.
  • Cất màu gốc vào data-og-* để hoàn tác, và để khi người dùng copy thì tạm trả màu gốc cho clipboard rồi đặt lại ở frame sau.
  • Ảnh không bị đụng tới, vì chỉ color và background-color bị sửa.

Ba khung là cùng một email. Khung giữa đảo màu bằng filter: nhìn ảnh và nút "Xem đơn hàng". Khung phải là cách của Outlook, bảng bên dưới liệt kê đúng 9 màu bị sửa với tỉ lệ tương phản trước và sau. Bôi đen một đoạn ở khung phải, copy rồi dán vào ô cuối: màu dán ra là màu gốc.

#Outlook làm thế nào?

Có ba module liên quan, đều là code của OWA (Outlook Web App):

Màu nền dark mode được truyền vào từ MailMessageBody.tsx là getPaletteAsRawColors().white. Nghe lạ, nhưng comment trong code giải thích: "The color mapped to 'white' is always used for the background in the reading pane. The actual color will be flipped to a dark value when darkMode is enabled." Theme tối của Fluent UI đảo các ô màu trung tính, ô tên white thành màu nền tối.

#Bước 1: kiểm tra tương phản

const VALID_CONTRAST_VALUE = 4.5;

let textColor = Color(computedStyles.color || undefined);
if (useSimpleMethod ? styleColor || attrColor : !isValidContrast(textColor, baseBGColor)) {
  textColor = fixContrast(textColor, baseBGColor, !!useSimpleMethod, baseColor);
  element.style.setProperty('color', textColor.rgb().string(), 'important');
  element.setAttribute(DATA_OG_STYLE_COLOR, styleColor ? styleColor : EMPTY_STRING);
  // Word seems to prioritize color over CSS color so copy/paste has issues without this
  if (attrColor) { element.setAttribute(ATTR_COLOR, textColor.rgb().string()); ... }
}
ts

4.5 là ngưỡng AA của WCAG cho chữ cỡ thường. Màu được đọc từ getComputedStyle, tức màu thật sự đang hiển thị (kể cả màu thừa hưởng từ cha), chứ không chỉ inline style. Màu mới đặt với !important để thắng mọi CSS trong email. Thuộc tính color kiểu cũ cũng được sửa, vì theo comment, Word ưu tiên thuộc tính đó hơn CSS khi dán.

Sau đó làm tương tự với màu nền, nhưng so với màu chữ vừa sửa (không phải với nền dark mode).

#Bước 2: sửa độ sáng trong CIELAB

const baseLValue = baseBGColor.lab().array()[0];
const colorLab = color.lab().array();
const comparisonLValue = comparisonColor.lab().array()[0];

const ceilingDarkLValue = 50; // values above this can't contrast against 100
const floorLightLValue = 50 + baseLValue; // values below this can't contrast against baseLValue
const midpointLValue = (floorLightLValue + ceilingDarkLValue) / 2;

newLValue = colorLab[0] * ((100 - baseLValue) / 100) + baseLValue;
if (comparisonLValue > midpointLValue) { /* lật về phía tối */ } else { /* lật về phía sáng */ }

return Color.lab(newLValue, colorLab[1], colorLab[2]).rgb().alpha(color.alpha());
ts

Dòng cuối là mấu chốt: Color.lab(newL, a, b). Hai kênh a (xanh lá ↔ đỏ) và b (xanh dương ↔ vàng) được giữ nguyên, chỉ L* đổi.

#Bước 3: hoàn tác và copy

Màu gốc được cất vào bốn thuộc tính data-ogsc, data-ogac, data-ogsb, data-ogab (original style color, attribute color, style background, attribute bgcolor). undoHandler trả lại và xoá chúng. handleOnCopy:

if (isDark) { tgt.style.backgroundColor = 'white'; tgt.style.color = 'black'; }
for (let i = 0; i < elems.length; i++) {
  const originalColors = getElementOgColors(element);
  elementModifiedColors.push(getElementColors(element));
  setElementColors(element, originalColors);
}
window.requestAnimationFrame(() => { /* đặt lại màu dark mode */ });
ts

Trình duyệt chụp nội dung clipboard ngay sau khi handler copy chạy xong, trước khi vẽ frame tiếp theo. Đổi màu trong handler, đổi lại trong requestAnimationFrame: clipboard có màu gốc, người dùng không kịp thấy khung nhấp nháy.

#Một chi tiết với nền trong suốt

Phần tử không có nền có getComputedStyle().backgroundColor là rgba(0, 0, 0, 0). Comment của Outlook nói ý định là "default to dark mode background to pass contrast checks". Thực tế thư viện color tính độ chói (luminance) mà bỏ qua kênh alpha (xem luminosity() trong mã nguồn color), nên nền trong suốt bị coi là đen. Đen gần với nền tối nên kết quả vẫn ổn, nhưng là nhờ trùng hợp. Bản viết lại bỏ qua hẳn nền có alpha bằng 0.

#Bên trong: tại sao lại là CIELAB?

sRGB (cái bạn viết trong #rrggbb) mô tả lượng ánh sáng phát ra của ba đèn đỏ, lục, lam. Nó không đều theo cảm nhận: tăng kênh G thêm 20 làm màu sáng lên rõ hơn nhiều so với tăng kênh B thêm 20. Muốn "giữ màu, chỉ đổi độ sáng" trong RGB thì không có trục nào làm đúng việc đó.

CIELAB (thường viết L*a*b*) được thiết kế để khoảng cách số gần với khoảng cách cảm nhận:

  • L* từ 0 (đen) tới 100 (trắng): độ sáng cảm nhận.
  • a*: âm là xanh lá, dương là đỏ. b*: âm là xanh dương, dương là vàng.

Đổi L* và giữ a*, b* gần đúng với "cùng màu đó, sáng hơn/tối hơn". Chuyển đổi đi qua hệ XYZ: sRGB → tuyến tính hoá (bỏ gamma) → XYZ bằng ma trận 3×3 → L*a*b* theo điểm trắng D65.

Công thức sửa L* có hai bước:

  1. Nén L* từ [0, 100] vào [baseL, 100], với baseL là độ sáng của nền dark mode (nền #1f1f1f có L* ≈ 12). Không màu nào được tối hơn chính cái nền.
  2. Lật: nếu màu cần tương phản (nền tối khi sửa chữ, chữ sáng khi sửa nền) sáng hơn "điểm giữa" thì màu này phải tối, ngược lại phải sáng. Lật quanh điểm giữa rồi kéo giãn ra hai phía, để không màu nào rơi vào "vùng chết" quanh giữa, nơi không đủ tương phản với cả hai đầu.
flowchart TD
  E["mỗi phần tử, cha trước con"] --> T{"tương phản chữ với nền dark mode ≥ 4.5?"}
  T -->|"có"| B
  T -->|"không"| L1["sRGB → Lab, tính L* mới, giữ a* b*"]
  L1 --> S1["style color !important, cất màu gốc vào data-ogsc"]
  S1 --> B{"nền có màu và tương phản với chữ ≥ 4.5?"}
  B -->|"có hoặc nền trong suốt"| N["xong"]
  B -->|"không"| L2["tính L* mới cho nền, so với màu chữ"]
  L2 --> S2["style background-color !important, cất vào data-ogsb"]

Thứ tự "cha trước con" quan trọng: querySelectorAll('*') trả về theo thứ tự tài liệu. Một <span> không tự đặt màu sẽ thừa hưởng màu chữ đã sửa của <p> cha, nên khi tới lượt nó, tương phản đã đạt và nó được để nguyên. Nhờ vậy số phần tử bị sửa ít hơn nhiều so với tổng số phần tử.

#Viết lại

Không dùng thư viện: chuyển đổi sRGB ⇄ Lab chỉ khoảng 30 dòng. Phần công thức L*:

src/lib/emailDarkMode.ts
export function fixLightness(L: number, againstL: number, baseL: number): number {
  const ceilingDark = 50; // tối hơn mức này mới tương phản nổi với trắng (L = 100)
  const floorLight = 50 + baseL; // sáng hơn mức này mới tương phản nổi với nền (L = baseL)
  const mid = (floorLight + ceilingDark) / 2;

  let next = L * ((100 - baseL) / 100) + baseL;
  if (againstL > mid) {
    next = Math.min(next, 2 * mid - next);
    next = ((next - baseL) * (ceilingDark - baseL)) / (mid - baseL) + baseL;
  } else {
    next = Math.max(next, 2 * mid - next);
    next = 100 - ((100 - next) * (100 - floorLight)) / (100 - mid);
  }
  return Math.min(100, Math.max(0, next));
}

/** Giữ a*, b* (sắc độ), chỉ thay L*. Đây là lý do màu thương hiệu vẫn "ra màu thương hiệu". */
export function fixContrast(color: Rgba, against: Rgba, base: Rgba): Rgba {
  const [L, A, B] = rgbToLab(color);
  const baseL = rgbToLab(base)[0];
  const againstL = rgbToLab(against)[0];
  return labToRgb([fixLightness(L, againstL, baseL), A, B], color.a);
}
ts

Phần áp lên một phần tử (nửa xử lý màu chữ; nửa màu nền làm tương tự, so với màu chữ đã sửa):

src/lib/emailDarkMode.ts
export function transformElement(element: HTMLElement, base: Rgba): ChangedElement[] {
  const changes: ChangedElement[] = [];
  const cs = getComputedStyle(element);
  let text = parseColor(cs.color);
  if (!text) return changes;

  // 1. Màu chữ so với nền dark mode
  const contrastText = contrast(text, base);
  if (contrastText < MIN_CONTRAST) {
    const fixed = fixContrast(text, base, base);
    if (!element.hasAttribute(OG_STYLE_COLOR)) element.setAttribute(OG_STYLE_COLOR, element.style.color);
    element.style.setProperty('color', toCss(fixed), 'important');
    const attr = element.getAttribute('color'); // <font color="…"> kiểu cũ
    if (attr !== null) {
      if (!element.hasAttribute(OG_ATTR_COLOR)) element.setAttribute(OG_ATTR_COLOR, attr);
      // Word khi dán ưu tiên thuộc tính color hơn CSS, nên sửa cả thuộc tính
      element.setAttribute('color', toCss(fixed));
    }
    changes.push({ element, property: 'color', before: toCss(text), after: toCss(fixed), contrastBefore: contrastText, contrastAfter: contrast(fixed, base) });
    text = fixed;
  }
ts
Xem toàn bộ src/lib/emailDarkMode.ts
src/lib/emailDarkMode.ts
/**
 * Dark mode cho HTML không do mình viết (email, nội dung CMS, bài dán từ Word…).
 * Ý tưởng từ owa-dark-mode-utilities của Outlook Web: chỉ sửa phần tử nào thiếu tương phản,
 * đổi màu trong không gian CIELAB, GIỮ NGUYÊN sắc độ (a*, b*) và chỉ đổi độ sáng L*.
 */

export interface Rgba {
  r: number;
  g: number;
  b: number;
  a: number;
}

// ---- Đọc màu ----

/**
 * Parse màu từ getComputedStyle (luôn ra "rgb(…)" / "rgba(…)") hoặc dạng hex "#rgb", "#rrggbb".
 * Trả về null với giá trị lạ, thay vì ném lỗi như thư viện `color` mà Outlook phải bọc try/catch.
 */
export function parseColor(input: string): Rgba | null {
  const s = input.trim().toLowerCase();
  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s);
  if (hex) {
    const h = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join('') : hex[1];
    return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: 1 };
  }
  const fn = /^rgba?\(([^)]+)\)$/.exec(s);
  if (!fn) return null;
  const parts = fn[1].split(/[\s,/]+/).filter(Boolean).map(Number);
  if (parts.length < 3 || parts.some(Number.isNaN)) return null;
  return { r: parts[0], g: parts[1], b: parts[2], a: parts[3] ?? 1 };
}

export const toCss = ({ r, g, b, a }: Rgba) =>
  a >= 1 ? `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})` : `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${a})`;

// ---- sRGB ⇄ CIELAB (D65) ----

const toLinear = (c: number) => {
  const v = c / 255;
  return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
const fromLinear = (v: number) => {
  const c = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
  return Math.min(255, Math.max(0, c * 255));
};

// điểm trắng D65
const XN = 0.95047;
const YN = 1;
const ZN = 1.08883;
const EPS = 216 / 24389;
const KAPPA = 24389 / 27;

export function rgbToLab({ r, g, b }: Rgba): [number, number, number] {
  const R = toLinear(r);
  const G = toLinear(g);
  const B = toLinear(b);
  const x = (R * 0.4124564 + G * 0.3575761 + B * 0.1804375) / XN;
  const y = (R * 0.2126729 + G * 0.7151522 + B * 0.072175) / YN;
  const z = (R * 0.0193339 + G * 0.119192 + B * 0.9503041) / ZN;
  const f = (t: number) => (t > EPS ? Math.cbrt(t) : (KAPPA * t + 16) / 116);
  const fx = f(x);
  const fy = f(y);
  const fz = f(z);
  return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
}

export function labToRgb([L, A, Bv]: [number, number, number], alpha = 1): Rgba {
  const fy = (L + 16) / 116;
  const fx = fy + A / 500;
  const fz = fy - Bv / 200;
  const inv = (t: number) => (t ** 3 > EPS ? t ** 3 : (116 * t - 16) / KAPPA);
  const x = inv(fx) * XN;
  const y = (L > KAPPA * EPS ? fy ** 3 : L / KAPPA) * YN;
  const z = inv(fz) * ZN;
  const R = x * 3.2404542 - y * 1.5371385 - z * 0.4985314;
  const G = -x * 0.969266 + y * 1.8760108 + z * 0.041556;
  const B = x * 0.0556434 - y * 0.2040259 + z * 1.0572252;
  return { r: fromLinear(R), g: fromLinear(G), b: fromLinear(B), a: alpha };
}

// ---- Tương phản WCAG 2 ----

export function luminance({ r, g, b }: Rgba): number {
  return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}

export function contrast(c1: Rgba, c2: Rgba): number {
  const l1 = luminance(c1);
  const l2 = luminance(c2);
  return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}

/** Ngưỡng AA cho chữ thường. Outlook dùng đúng con số này (VALID_CONTRAST_VALUE = 4.5). */
export const MIN_CONTRAST = 4.5;

/**
 * Tính lại độ sáng L* cho `color` để tương phản được với `against`, trên nền tối có độ sáng `baseL`.
 * Công thức theo fixContrast() của Outlook:
 * 1. Nén L* từ [0, 100] vào [baseL, 100]: không màu nào tối hơn chính nền dark mode.
 * 2. Màu cần so sánh mà sáng thì lật màu này về phía tối, ngược lại lật về phía sáng,
 *    rồi kéo giãn để tránh "vùng chết" quanh giữa (do baseL không bằng 0).
 */
export function fixLightness(L: number, againstL: number, baseL: number): number {
  const ceilingDark = 50; // tối hơn mức này mới tương phản nổi với trắng (L = 100)
  const floorLight = 50 + baseL; // sáng hơn mức này mới tương phản nổi với nền (L = baseL)
  const mid = (floorLight + ceilingDark) / 2;

  let next = L * ((100 - baseL) / 100) + baseL;
  if (againstL > mid) {
    next = Math.min(next, 2 * mid - next);
    next = ((next - baseL) * (ceilingDark - baseL)) / (mid - baseL) + baseL;
  } else {
    next = Math.max(next, 2 * mid - next);
    next = 100 - ((100 - next) * (100 - floorLight)) / (100 - mid);
  }
  return Math.min(100, Math.max(0, next));
}

/** Giữ a*, b* (sắc độ), chỉ thay L*. Đây là lý do màu thương hiệu vẫn "ra màu thương hiệu". */
export function fixContrast(color: Rgba, against: Rgba, base: Rgba): Rgba {
  const [L, A, B] = rgbToLab(color);
  const baseL = rgbToLab(base)[0];
  const againstL = rgbToLab(against)[0];
  return labToRgb([fixLightness(L, againstL, baseL), A, B], color.a);
}

// ---- Áp lên DOM, nhớ màu gốc để hoàn tác và để copy ----

// Cùng tên thuộc tính với Outlook (owa-content-colors-constants)
const OG_STYLE_COLOR = 'data-ogsc';
const OG_ATTR_COLOR = 'data-ogac';
const OG_STYLE_BG = 'data-ogsb';
const OG_ATTR_BG = 'data-ogab';

export interface ChangedElement {
  element: HTMLElement;
  property: 'color' | 'background-color';
  before: string;
  after: string;
  contrastBefore: number;
  contrastAfter: number;
}

/**
 * Sửa một phần tử. Phải gọi theo thứ tự cha trước con (thứ tự của querySelectorAll):
 * con không tự đặt màu sẽ thừa hưởng màu đã sửa của cha, nên không cần sửa lại.
 */
export function transformElement(element: HTMLElement, base: Rgba): ChangedElement[] {
  const changes: ChangedElement[] = [];
  const cs = getComputedStyle(element);
  let text = parseColor(cs.color);
  if (!text) return changes;

  // 1. Màu chữ so với nền dark mode
  const contrastText = contrast(text, base);
  if (contrastText < MIN_CONTRAST) {
    const fixed = fixContrast(text, base, base);
    if (!element.hasAttribute(OG_STYLE_COLOR)) element.setAttribute(OG_STYLE_COLOR, element.style.color);
    element.style.setProperty('color', toCss(fixed), 'important');
    const attr = element.getAttribute('color'); // <font color="…"> kiểu cũ
    if (attr !== null) {
      if (!element.hasAttribute(OG_ATTR_COLOR)) element.setAttribute(OG_ATTR_COLOR, attr);
      // Word khi dán ưu tiên thuộc tính color hơn CSS, nên sửa cả thuộc tính
      element.setAttribute('color', toCss(fixed));
    }
    changes.push({ element, property: 'color', before: toCss(text), after: toCss(fixed), contrastBefore: contrastText, contrastAfter: contrast(fixed, base) });
    text = fixed;
  }

  // 2. Màu nền so với màu chữ (đã sửa). Nền trong suốt thì bỏ qua: nó để lộ nền dark mode.
  //    (Bản gốc không bỏ qua mà để thư viện `color` coi rgba(0,0,0,0) là đen, kết quả tình cờ đúng.)
  const bg = parseColor(cs.backgroundColor);
  if (bg && bg.a > 0) {
    const contrastBg = contrast(bg, text);
    if (contrastBg < MIN_CONTRAST) {
      const fixed = fixContrast(bg, text, base);
      if (!element.hasAttribute(OG_STYLE_BG)) element.setAttribute(OG_STYLE_BG, element.style.backgroundColor);
      element.style.setProperty('background-color', toCss(fixed), 'important');
      const attr = element.getAttribute('bgcolor'); // <td bgcolor="…">
      if (attr !== null) {
        if (!element.hasAttribute(OG_ATTR_BG)) element.setAttribute(OG_ATTR_BG, attr);
        element.setAttribute('bgcolor', toCss(fixed));
      }
      changes.push({ element, property: 'background-color', before: toCss(bg), after: toCss(fixed), contrastBefore: contrastBg, contrastAfter: contrast(fixed, text) });
    }
  }
  return changes;
}

/** Áp dark mode cho cả cây. `root` phải đang hiển thị trên nền `baseColor` (màu nền dark mode). */
export function applyDarkMode(root: HTMLElement, baseColor: string): ChangedElement[] {
  const base = parseColor(baseColor);
  if (!base) throw new Error(`Màu nền không hợp lệ: ${baseColor}`);
  const all = [root, ...root.querySelectorAll<HTMLElement>('*')];
  return all.flatMap((el) => transformElement(el, base));
}

interface SavedColors {
  styleColor: string;
  styleBg: string;
  attrColor: string | null;
  attrBg: string | null;
}

const readColors = (el: HTMLElement): SavedColors => ({
  styleColor: el.style.getPropertyValue('color'),
  styleBg: el.style.getPropertyValue('background-color'),
  attrColor: el.getAttribute('color'),
  attrBg: el.getAttribute('bgcolor'),
});

function writeColors(el: HTMLElement, c: Partial<SavedColors>, important = false) {
  const prio = important ? 'important' : '';
  if (c.styleColor !== undefined) el.style.setProperty('color', c.styleColor, prio);
  if (c.styleBg !== undefined) el.style.setProperty('background-color', c.styleBg, prio);
  if (c.attrColor !== undefined) c.attrColor === null ? el.removeAttribute('color') : el.setAttribute('color', c.attrColor);
  if (c.attrBg !== undefined) c.attrBg === null ? el.removeAttribute('bgcolor') : el.setAttribute('bgcolor', c.attrBg);
}

/** Màu gốc đã cất trong data-og-*, chỉ những gì đã bị sửa. */
function originalColors(el: HTMLElement): Partial<SavedColors> {
  const out: Partial<SavedColors> = {};
  if (el.hasAttribute(OG_STYLE_COLOR)) out.styleColor = el.getAttribute(OG_STYLE_COLOR)!;
  if (el.hasAttribute(OG_STYLE_BG)) out.styleBg = el.getAttribute(OG_STYLE_BG)!;
  if (el.hasAttribute(OG_ATTR_COLOR)) out.attrColor = el.getAttribute(OG_ATTR_COLOR);
  if (el.hasAttribute(OG_ATTR_BG)) out.attrBg = el.getAttribute(OG_ATTR_BG);
  return out;
}

/** Hoàn tác toàn bộ: trả màu gốc và dọn các thuộc tính data-og-*. */
export function undoDarkMode(root: HTMLElement) {
  for (const el of [root, ...root.querySelectorAll<HTMLElement>('*')]) {
    const og = originalColors(el);
    if (Object.keys(og).length === 0) continue;
    writeColors(el, og);
    [OG_STYLE_COLOR, OG_STYLE_BG, OG_ATTR_COLOR, OG_ATTR_BG].forEach((a) => el.removeAttribute(a));
  }
}

/**
 * Gắn vào sự kiện `copy` của khung email. Clipboard được chụp ĐỒNG BỘ ngay sau handler, nên:
 * trả màu gốc ngay trong handler → trình duyệt chép màu gốc → frame sau đặt lại màu dark mode.
 * Người dùng dán sang Word/Gmail sẽ không bị "chữ trắng trên nền trắng".
 */
export function copyWithOriginalColors(root: HTMLElement, lightBackground = 'white', lightText = 'black') {
  // chỉ đụng vào phần tử đã bị dark mode sửa (có data-og-*)
  const elements = [...root.querySelectorAll<HTMLElement>('*')].filter(
    (el) => Object.keys(originalColors(el)).length > 0,
  );
  const saved = elements.map(readColors);
  const rootStyle = { bg: root.style.backgroundColor, color: root.style.color };
  // khung ngoài đang tô nền tối/chữ sáng bằng CSS của app: tạm đổi về nền sáng
  root.style.backgroundColor = lightBackground;
  root.style.color = lightText;
  elements.forEach((el) => writeColors(el, originalColors(el)));

  requestAnimationFrame(() => {
    root.style.backgroundColor = rootStyle.bg;
    root.style.color = rootStyle.color;
    // màu dark mode được đặt với !important, nên đặt lại cũng phải !important
    elements.forEach((el, i) => writeColors(el, saved[i], true));
  });
}
ts

#Đi qua code

parseColor không ném lỗi. Outlook bọc cả hàm trong try/catch vì Color() ném lỗi với giá trị CSS lạ. Ở đây đọc từ getComputedStyle, vốn luôn trả về dạng rgb(…)/rgba(…), nên một parser nhỏ là đủ; giá trị lạ trả null và phần tử được bỏ qua.

Kết quả với email trong demo (nền dark mode #1f1f1f):

Màu gốc Vai trò Sau khi sửa Tương phản
#0b57d0 xanh thương hiệu chữ tiêu đề rgb(131, 159, 255) 2.58 → 6.58
#d93025 đỏ chữ khuyến mãi rgb(255, 94, 72) 3.45 → 5.44
#fde293 vàng nền chữ highlight rgb(59, 45, 0) 1.27 → 13.41
#ffffff nền bảng rgb(31, 31, 31) 1.23 → 13.45

Xanh vẫn là xanh, đỏ vẫn đỏ, vàng highlight thành nâu đậm (vẫn là "vàng tối"). Nói chính xác: a*, b* được giữ khi tính, nhưng màu sáng lên có thể vượt ra ngoài những gì sRGB hiển thị được và bị cắt về [0, 255], nên độ bão hoà giảm đi một chút; góc màu (hue) chỉ lệch vài độ. Nút "Xem đơn hàng" (nền xanh, chữ trắng) không bị đụng tới vì tương phản giữa chúng đã đạt 6.6.

!important hai chiều. Màu sửa được đặt với !important. Khi copy, writeColors trả màu gốc không có !important (đúng như trong email), và ở frame sau đặt lại màu dark mode có !important.

Chỉ đụng phần tử có data-og-* khi copy. Bản Outlook duyệt mọi phần tử và ghi lại màu hiện tại của tất cả. Kết quả như nhau, nhưng duyệt ít hơn và không vô tình thêm !important vào inline style của phần tử chưa từng bị sửa.

Khác bản gốc:

  • Tự viết chuyển đổi Lab và tương phản, không dùng thư viện color.
  • Bỏ qua nền trong suốt thay vì để nó thành đen một cách ngầm định.
  • Không có chế độ useSimpleMethod (Outlook có cờ này để chỉ đổi màu nào được khai báo tường minh và chỉ lật L*).
  • Ghi nhớ màu gốc chỉ một lần (if (!hasAttribute)), nên áp dark mode hai lần liên tiếp không làm mất màu gốc.

#Tại sao không làm cách khác?

filter: invert(1) hue-rotate(180deg): một dòng CSS, nhưng đảo cả ảnh, video, logo; muốn chừa ảnh lại thì phải đảo ngược lần nữa cho từng <img>, và ảnh nền CSS thì không chừa được. Màu cũng không giữ đúng: hue-rotate xoay sắc độ trong một phép biến đổi xấp xỉ, không phải trong không gian cảm nhận.

Đảo độ sáng trong HSL: L của HSL không phải độ sáng cảm nhận. Vàng hsl(60 100% 50%) và xanh dương hsl(240 100% 50%) cùng L = 50% nhưng vàng sáng hơn rất nhiều. Đảo theo HSL cho kết quả lúc quá sáng, lúc quá tối.

color-scheme: dark hoặc media query: chỉ ảnh hưởng màu mặc định của trình duyệt (nền trang, form control). Màu đã ghi inline trong email không bị đổi.

Đổi tất cả màu thay vì chỉ màu thiếu tương phản: sẽ đổi luôn những tổ hợp vốn đã đẹp (nút xanh chữ trắng, banner màu), làm email khác xa ý người gửi.

#Edge case và cạm bẫy

  • Ảnh có chữ đen trên nền trong suốt (logo PNG): không sửa được bằng màu CSS, sẽ chìm vào nền tối. Có thể đặt một nền sáng nhẹ phía sau ảnh, nhưng đó là một quyết định thiết kế riêng.
  • Gradient và background-image: chỉ background-color được xét. Chữ đặt trên ảnh nền vẫn có thể thiếu tương phản.
  • Màu bán trong suốt: tương phản tính trên RGB bỏ qua alpha, giống Outlook. Muốn chính xác phải trộn màu với nền phía sau trước khi tính.
  • Nội dung thay đổi sau khi áp (ảnh tải xong, nội dung lazy): phải chạy lại cho phần tử mới; vì màu gốc chỉ cất một lần nên chạy lại an toàn.
  • Hiệu năng: mỗi phần tử một lần getComputedStyle và ghi style, gây tính lại style nhiều lần. Email trong demo tốn chưa tới 1ms; email cả nghìn phần tử nên chạy trong lúc chưa hiển thị hoặc chia nhỏ (xem hàng đợi rAF).
  • Thuộc tính data-og-* lọt vào clipboard: HTML dán ra vẫn chứa chúng (Outlook cũng vậy). Vô hại, nhưng nếu bạn lưu HTML lại thì nên lọc.
  • Nội dung không tin cậy: demo hiển thị phần dán vào trong <iframe sandbox="" srcdoc> để HTML lạ không chạy được script. Email thật luôn phải qua sanitizer (như DOMPurify) trước khi chèn vào trang.

#Dùng trong dự án

import { applyDarkMode, copyWithOriginalColors, undoDarkMode } from './emailDarkMode';

const body = document.querySelector<HTMLElement>('.message-body')!;
body.style.background = '#1f1f1f';
body.style.color = '#e8e8e8';
applyDarkMode(body, '#1f1f1f');
body.addEventListener('copy', () => copyWithOriginalColors(body));

// người dùng bấm "Xem màu gốc"
undoDarkMode(body);
ts

Màu nền truyền vào phải đúng là màu đang hiển thị sau khung email, vì toàn bộ phép tính tương phản dựa vào nó.

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { contrast, fixContrast, labToRgb, parseColor, rgbToLab, toCss } from './emailDarkMode';

const base = parseColor('#1f1f1f')!;

describe('Lab', () => {
  it('đổi qua đổi lại không lệch', () => {
    const c = parseColor('#0b57d0')!;
    expect(toCss(labToRgb(rgbToLab(c)))).toBe('rgb(11, 87, 208)');
  });
});

describe('fixContrast', () => {
  it('chữ tối trên nền tối thành sáng và đạt 4.5', () => {
    for (const hex of ['#000000', '#333333', '#0b57d0', '#d93025', '#188038', '#7a7a7a']) {
      const fixed = fixContrast(parseColor(hex)!, base, base);
      expect(contrast(fixed, base)).toBeGreaterThanOrEqual(4.5);
    }
  });
  it('giữ góc màu (hue) gần như không đổi', () => {
    const hue = ([, a, b]: number[]) => (Math.atan2(b, a) * 180) / Math.PI;
    for (const hex of ['#0b57d0', '#d93025', '#188038']) {
      const c = parseColor(hex)!;
      const diff = Math.abs(hue(rgbToLab(c)) - hue(rgbToLab(fixContrast(c, base, base))));
      expect(diff).toBeLessThan(6);
    }
  });
  it('nền trắng dưới chữ đã sáng thì thành tối', () => {
    const text = fixContrast(parseColor('#000')!, base, base);
    const bg = fixContrast(parseColor('#fff')!, text, base);
    expect(contrast(bg, text)).toBeGreaterThanOrEqual(4.5);
    expect(rgbToLab(bg)[0]).toBeLessThan(20);
  });
});
ts

Test so góc màu (hue, atan2(b*, a*)) thay vì so từng kênh a*, b*: màu sáng lên có thể ra ngoài gam màu sRGB và bị cắt về [0, 255], làm độ bão hoà giảm (xanh #0b57d0: a* từ 27 còn 16), nhưng góc màu chỉ lệch khoảng 4,5°. Phần applyDarkMode cần getComputedStyle thật nên kiểm bằng Playwright.

#So với thư viện và chuẩn web

  • CSS Color 4 có sẵn hàm lab() và oklch(), CSS Color 5 có cú pháp màu tương đối như oklch(from var(--c) calc(l + 0.3) c h). Chúng hợp để viết theme mới (xem bài OKLCH), nhưng không giúp được HTML có sẵn màu inline: bạn vẫn phải duyệt phần tử bằng JS.
  • OKLCH/OKLab là bản cải tiến của CIELAB, đều đặn hơn với màu xanh dương. Thay rgbToLab bằng OKLab là một nâng cấp hợp lý; công thức lật độ sáng giữ nguyên ý tưởng.
  • Một số client email có thuộc tính hay meta riêng cho dark mode (ví dụ <meta name="color-scheme"> trong email). Người viết email nên khai báo; client vẫn phải xử lý email không khai báo, và đó là phần việc của bài này.

Bài này thuộc series Học từ mã nguồn các web lớn (65 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Mở đầu

Phần 1: trải nghiệm người dùng

Phần 2: hiệu năng và đo lường

Phần 3: nhiều tab, phiên đăng nhập, cập nhật

Phần 4: kiến trúc

Phần 5: soạn thảo và hiển thị nội dung

Phần 6: tương tác

Phần 7: mạng, dữ liệu và async

Phần 8: hiệu năng và vận hành

Phần 9: thuật toán và dữ liệu phía client

Phần 10: thời gian, ngôn ngữ, định dạng

Phần 11: tương tác và giao diện

Phần 12: media

Phần 13: đo lường, bảo mật, vận hành

Code + demo: examples/web-patterns · Mở trên StackBlitz