From b61751714283fe4239496e787292a5b5113f930a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Wallstr=C3=B6m?= <21986391+FredrikWallstrom@users.noreply.github.com> Date: Fri, 8 May 2026 14:13:52 +0200 Subject: [PATCH 1/3] feat(util): add adaptColorContrast utility --- src/util/adapt-color-contrast.e2e.ts | 114 ++++++++++++++ src/util/adapt-color-contrast.spec.ts | 86 +++++++++++ src/util/adapt-color-contrast.ts | 209 ++++++++++++++++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 src/util/adapt-color-contrast.e2e.ts create mode 100644 src/util/adapt-color-contrast.spec.ts create mode 100644 src/util/adapt-color-contrast.ts diff --git a/src/util/adapt-color-contrast.e2e.ts b/src/util/adapt-color-contrast.e2e.ts new file mode 100644 index 0000000000..eda3970c98 --- /dev/null +++ b/src/util/adapt-color-contrast.e2e.ts @@ -0,0 +1,114 @@ +import { adaptColorContrast } from './adapt-color-contrast'; + +function makeP( + host: HTMLElement, + attrs: { style?: string } = {}, + text = 'x' +): HTMLParagraphElement { + const p = document.createElement('p'); + if (attrs.style) { + p.setAttribute('style', attrs.style); + } + p.textContent = text; + host.append(p); + + return p; +} + +describe('adaptColorContrast', () => { + let host: HTMLDivElement; + + beforeEach(() => { + host = document.createElement('div'); + host.style.backgroundColor = 'rgb(255, 255, 255)'; + document.body.append(host); + }); + + afterEach(() => { + host.remove(); + }); + + it('does nothing when there are no inline color styles', () => { + const plain = makeP(host, {}, 'plain'); + const bold = makeP(host, { style: 'font-weight: bold' }, 'bold'); + const beforePlain = plain.outerHTML; + const beforeBold = bold.outerHTML; + adaptColorContrast(host); + expect(plain.outerHTML).toBe(beforePlain); + expect(bold.outerHTML).toBe(beforeBold); + }); + + it('leaves elements with `color: inherit` alone', () => { + const p = makeP(host, { style: 'color: inherit' }); + adaptColorContrast(host); + expect(p.style.color).toBe('inherit'); + }); + + it('strips low-contrast color from inline style', () => { + const p = makeP(host, { style: 'color: rgb(250, 250, 250)' }); + adaptColorContrast(host); + expect(p.style.color).toBe(''); + }); + + it('removes the style attribute entirely when color was its only declaration', () => { + const p = makeP(host, { style: 'color: rgb(250, 250, 250)' }); + adaptColorContrast(host); + expect(p.hasAttribute('style')).toBe(false); + }); + + it('preserves other inline declarations when stripping color', () => { + const p = makeP(host, { + style: 'color: rgb(250, 250, 250); font-weight: bold; padding: 4px', + }); + adaptColorContrast(host); + expect(p.style.color).toBe(''); + expect(p.style.fontWeight).toBe('bold'); + expect(p.style.padding).toBe('4px'); + }); + + it('leaves brand colors that pass the contrast threshold alone', () => { + const p = makeP(host, { style: 'color: rgb(31, 73, 125)' }); + adaptColorContrast(host); + expect(p.style.color).toBe('rgb(31, 73, 125)'); + }); + + it('preserves a saturated red against a dark surface', () => { + host.style.backgroundColor = 'rgb(20, 20, 20)'; + const p = makeP(host, { style: 'color: rgb(200, 38, 19)' }); + adaptColorContrast(host); + expect(p.style.color).toBe('rgb(200, 38, 19)'); + }); + + it('strips fully transparent colors so the surface color inherits', () => { + const p = makeP(host, { style: 'color: transparent' }); + adaptColorContrast(host); + expect(p.style.color).toBe(''); + }); + + it('strips black text on a dark surface and not on a light one', () => { + const p = makeP(host, { style: 'color: rgb(0, 0, 0)' }); + adaptColorContrast(host); + expect(p.style.color).toBe('rgb(0, 0, 0)'); + + host.style.backgroundColor = 'rgb(20, 20, 20)'; + adaptColorContrast(host); + expect(p.style.color).toBe(''); + }); + + it('walks across an ancestor with no background to find the surface', () => { + const inner = document.createElement('div'); + host.append(inner); + const p = makeP(inner, { style: 'color: rgb(250, 250, 250)' }); + adaptColorContrast(host); + expect(p.style.color).toBe(''); + }); + + it('does not strip when an ancestor has a background-image', () => { + const inner = document.createElement('div'); + inner.style.backgroundImage = 'linear-gradient(to right, red, blue)'; + host.append(inner); + const p = makeP(inner, { style: 'color: rgb(250, 250, 250)' }); + adaptColorContrast(host); + expect(p.style.color).toBe('rgb(250, 250, 250)'); + }); +}); diff --git a/src/util/adapt-color-contrast.spec.ts b/src/util/adapt-color-contrast.spec.ts new file mode 100644 index 0000000000..621379d220 --- /dev/null +++ b/src/util/adapt-color-contrast.spec.ts @@ -0,0 +1,86 @@ +import { + compositeOver, + contrastRatio, + parseColor, + relativeLuminance, +} from './adapt-color-contrast'; + +describe('parseColor', () => { + it.each([ + ['rgb(0, 0, 0)', { r: 0, g: 0, b: 0, a: 1 }], + ['rgb(255,255,255)', { r: 255, g: 255, b: 255, a: 1 }], + ['rgba(10, 20, 30, 0.5)', { r: 10, g: 20, b: 30, a: 0.5 }], + ['rgba(10, 20, 30, 1)', { r: 10, g: 20, b: 30, a: 1 }], + ])('parses "%s"', (input, expected) => { + expect(parseColor(input)).toEqual(expected); + }); + + it('treats "transparent" as fully transparent black', () => { + expect(parseColor('transparent')).toEqual({ r: 0, g: 0, b: 0, a: 0 }); + }); + + it.each(['', 'red', '#fff', 'hsl(0, 0%, 0%)', 'currentColor', null as any])( + 'returns null for unsupported input "%s"', + (input) => { + expect(parseColor(input)).toBeNull(); + } + ); +}); + +describe('relativeLuminance', () => { + it('returns 1 for pure white', () => { + expect(relativeLuminance({ r: 255, g: 255, b: 255 })).toBeCloseTo(1, 5); + }); + + it('returns 0 for pure black', () => { + expect(relativeLuminance({ r: 0, g: 0, b: 0 })).toBeCloseTo(0, 5); + }); + + it('is monotonic across grayscale', () => { + const dark = relativeLuminance({ r: 50, g: 50, b: 50 }); + const mid = relativeLuminance({ r: 128, g: 128, b: 128 }); + const light = relativeLuminance({ r: 200, g: 200, b: 200 }); + expect(dark).toBeLessThan(mid); + expect(mid).toBeLessThan(light); + }); +}); + +describe('contrastRatio', () => { + it('returns 21 for black on white (and vice versa)', () => { + const black = relativeLuminance({ r: 0, g: 0, b: 0 }); + const white = relativeLuminance({ r: 255, g: 255, b: 255 }); + expect(contrastRatio(black, white)).toBeCloseTo(21, 1); + expect(contrastRatio(white, black)).toBeCloseTo(21, 1); + }); + + it('returns 1 for identical colors', () => { + const l = relativeLuminance({ r: 128, g: 128, b: 128 }); + expect(contrastRatio(l, l)).toBe(1); + }); + + it('passes WCAG AA for a dark navy on white', () => { + const navy = relativeLuminance({ r: 0x1f, g: 0x49, b: 0x7d }); + const white = relativeLuminance({ r: 255, g: 255, b: 255 }); + expect(contrastRatio(navy, white)).toBeGreaterThan(4.5); + }); +}); + +describe('compositeOver', () => { + it('returns the foreground when alpha is 1', () => { + const fg = { r: 100, g: 150, b: 200, a: 1 }; + const bg = { r: 0, g: 0, b: 0 }; + expect(compositeOver(fg, bg)).toEqual({ r: 100, g: 150, b: 200 }); + }); + + it('returns the background when alpha is 0', () => { + const fg = { r: 100, g: 150, b: 200, a: 0 }; + const bg = { r: 50, g: 60, b: 70 }; + expect(compositeOver(fg, bg)).toEqual({ r: 50, g: 60, b: 70 }); + }); + + it('blends 50/50 when alpha is 0.5', () => { + const fg = { r: 0, g: 0, b: 0, a: 0.5 }; + const bg = { r: 255, g: 255, b: 255 }; + expect(compositeOver(fg, bg)).toEqual({ r: 128, g: 128, b: 128 }); + }); +}); diff --git a/src/util/adapt-color-contrast.ts b/src/util/adapt-color-contrast.ts new file mode 100644 index 0000000000..d1453a8c50 --- /dev/null +++ b/src/util/adapt-color-contrast.ts @@ -0,0 +1,209 @@ +const MIN_CONTRAST_RATIO = 3; +const RGBA_PATTERN = + /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/i; +const FALLBACK_BACKGROUND: RGB = { r: 255, g: 255, b: 255 }; + +interface RGB { + r: number; + g: number; + b: number; +} + +interface RGBA extends RGB { + a: number; +} + +/** + * Walk descendants of `root` and remove inline `color` declarations that + * fail a 3:1 contrast ratio against their resolved background. + * @param root - Element or ShadowRoot whose descendants should be evaluated. + */ +export function adaptColorContrast(root: Element | ShadowRoot): void { + if (!root) { + return; + } + const elements = root.querySelectorAll('[style*="color"]'); + for (const el of elements) { + evaluateAndMaybeStrip(el); + } +} + +function evaluateAndMaybeStrip(el: HTMLElement): void { + const inlineColor = el.style.color; + if (!inlineColor || isInheritKeyword(inlineColor)) { + return; + } + + const fg = parseColor(getComputedStyle(el).color); + if (!fg) { + return; + } + + const bg = resolveBackground(el); + if (!bg) { + return; + } + + const composed = compositeOver(fg, bg); + const ratio = contrastRatio( + relativeLuminance(composed), + relativeLuminance(bg) + ); + if (ratio >= MIN_CONTRAST_RATIO) { + return; + } + + el.style.removeProperty('color'); + if (!el.style.cssText) { + el.removeAttribute('style'); + } +} + +function isInheritKeyword(value: string): boolean { + const v = value.trim().toLowerCase(); + + return v === 'inherit' || v === 'currentcolor' || v === 'unset'; +} + +type BackgroundReading = RGBA | 'image' | 'transparent'; + +function resolveBackground(start: Element): RGB | null { + let current: Element | null = start; + while (current) { + const reading = readBackgroundAt(current); + if (reading === 'image') { + return null; + } + if (reading !== 'transparent') { + return finalizeBackground(reading, nextAncestor(current)); + } + current = nextAncestor(current); + } + + return defaultBodyBackground(); +} + +function readBackgroundAt(el: Element): BackgroundReading { + const cs = getComputedStyle(el); + if (cs.backgroundImage && cs.backgroundImage !== 'none') { + return 'image'; + } + const parsed = parseColor(cs.backgroundColor); + if (!parsed || parsed.a === 0) { + return 'transparent'; + } + + return parsed; +} + +function finalizeBackground(reading: RGBA, parent: Element | null): RGB | null { + if (reading.a >= 1) { + return { r: reading.r, g: reading.g, b: reading.b }; + } + if (!parent) { + return compositeOver(reading, defaultBodyBackground()); + } + const upper = resolveBackground(parent); + if (!upper) { + return null; + } + + return compositeOver(reading, upper); +} + +function defaultBodyBackground(): RGB { + if (!document.body) { + return FALLBACK_BACKGROUND; + } + const parsed = parseColor(getComputedStyle(document.body).backgroundColor); + if (parsed && parsed.a > 0) { + return { r: parsed.r, g: parsed.g, b: parsed.b }; + } + + return FALLBACK_BACKGROUND; +} + +function nextAncestor(node: Element | null): Element | null { + if (!node) { + return null; + } + if (node.parentElement) { + return node.parentElement; + } + const root = node.getRootNode(); + if (root instanceof ShadowRoot) { + return root.host; + } + + return null; +} + +/** + * Parse a CSS color string (`rgb(...)`, `rgba(...)`, or `transparent`). + * @param value - The color string to parse. + */ +export function parseColor(value: string): RGBA | null { + if (!value) { + return null; + } + if (value === 'transparent') { + return { r: 0, g: 0, b: 0, a: 0 }; + } + const match = RGBA_PATTERN.exec(value); + if (!match) { + return null; + } + const a = match[4] === undefined ? 1 : Number.parseFloat(match[4]); + + return { + r: Number.parseInt(match[1], 10), + g: Number.parseInt(match[2], 10), + b: Number.parseInt(match[3], 10), + a, + }; +} + +/** + * Source-over alpha blend of `fg` onto opaque `bg`. + * @param fg - Foreground color with alpha in [0..1]. + * @param bg - Opaque background color. + */ +export function compositeOver(fg: RGBA, bg: RGB): RGB { + const a = fg.a; + + return { + r: Math.round(fg.r * a + bg.r * (1 - a)), + g: Math.round(fg.g * a + bg.g * (1 - a)), + b: Math.round(fg.b * a + bg.b * (1 - a)), + }; +} + +/** + * Relative luminance of an sRGB color per WCAG 2.x. Returns [0..1]. + * @param rgb - The color to measure. + */ +export function relativeLuminance(rgb: RGB): number { + const channel = (c: number) => { + const s = c / 255; + + return s <= 0.039_28 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }; + + return ( + 0.2126 * channel(rgb.r) + + 0.7152 * channel(rgb.g) + + 0.0722 * channel(rgb.b) + ); +} + +/** + * WCAG contrast ratio between two relative luminances. Returns [1..21]. + * @param l1 - First luminance. + * @param l2 - Second luminance. + */ +export function contrastRatio(l1: number, l2: number): number { + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + + return (lighter + 0.05) / (darker + 0.05); +} From 80e73bfdf9bc19231d050e714f316696671b8418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Wallstr=C3=B6m?= <21986391+FredrikWallstrom@users.noreply.github.com> Date: Fri, 8 May 2026 15:05:39 +0200 Subject: [PATCH 2/3] feat(markdown): add opt-in adaptColorContrast prop --- etc/lime-elements.api.md | 4 ++ .../markdown-adapt-color-contrast.tsx | 47 +++++++++++++++++++ src/components/markdown/markdown.tsx | 27 +++++++++++ 3 files changed, 78 insertions(+) create mode 100644 src/components/markdown/examples/markdown-adapt-color-contrast.tsx diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index 827c8417fb..580991dac6 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -617,6 +617,7 @@ export namespace Components { "value"?: any; } export interface LimelMarkdown { + "adaptColorContrast": boolean; "lazyLoadImages": boolean; "removeEmptyParagraphs": boolean; "value": string; @@ -2811,6 +2812,7 @@ export namespace JSX { } export interface LimelMarkdown { + "adaptColorContrast"?: boolean; "lazyLoadImages"?: boolean; "removeEmptyParagraphs"?: boolean; "value"?: string; @@ -2820,6 +2822,8 @@ export namespace JSX { // (undocumented) export interface LimelMarkdownAttributes { + // (undocumented) + "adaptColorContrast": boolean; // (undocumented) "lazyLoadImages": boolean; // (undocumented) diff --git a/src/components/markdown/examples/markdown-adapt-color-contrast.tsx b/src/components/markdown/examples/markdown-adapt-color-contrast.tsx new file mode 100644 index 0000000000..81844e1e5a --- /dev/null +++ b/src/components/markdown/examples/markdown-adapt-color-contrast.tsx @@ -0,0 +1,47 @@ +import { Component, h, Host } from '@stencil/core'; + +const markdown = ` +

Light grey — fails contrast.

+

Saturated red — passes contrast.

+

Dark navy — passes contrast.

+

Off-white — fails contrast.

+`; + +const surface = { + background: '#ffffff', + color: '#222222', + padding: '0.5rem 0.75rem', +}; + +/** + * Adapting color contrast + * + * Setting `adaptColorContrast` to `true` removes inline `color` + * declarations whose contrast against the resolved surface falls below + * WCAG 3:1. Both renders below sit on a forced white surface; the + * low-contrast lines disappear in the default render and become readable + * when adaptation is on. Colors that already pass are kept untouched. + */ +@Component({ + tag: 'limel-example-markdown-adapt-color-contrast', + shadow: true, +}) +export class MarkdownAdaptColorContrastExample { + public render() { + return ( + +

Default

+
+ +
+

Adapted

+
+ +
+
+ ); + } +} diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index a9132fb3f6..203cd38590 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -6,6 +6,7 @@ import { ImageIntersectionObserver } from './image-intersection-observer'; import { hydrateCustomElements } from './hydrate-custom-elements'; import { morphChildren } from './morph-dom'; import { DEFAULT_MARKDOWN_WHITELIST } from './default-whitelist'; +import { adaptColorContrast } from '../../util/adapt-color-contrast'; /** * The Markdown component receives markdown syntax @@ -36,6 +37,7 @@ import { DEFAULT_MARKDOWN_WHITELIST } from './default-whitelist'; * @exampleComponent limel-example-markdown-custom-component * @exampleComponent limel-example-markdown-custom-component-with-json-props * @exampleComponent limel-example-markdown-remove-empty-paragraphs + * @exampleComponent limel-example-markdown-adapt-color-contrast * @exampleComponent limel-example-markdown-composite */ @Component({ @@ -91,6 +93,22 @@ export class Markdown { @Prop({ reflect: true }) public removeEmptyParagraphs = true; + /** + * Adapt rendered inline `color:` declarations to the surrounding + * surface. After each markdown re-render the component walks the + * rendered DOM and removes any inline `color` whose contrast against + * the resolved background falls below WCAG 3:1, letting the surface's + * themed text color inherit through. Brand colors that already meet + * contrast are left alone. + * + * Default `false` so the component remains a neutral renderer; turn + * this on for surfaces that render externally-authored content + * (e.g. imported email bodies) where the host application's theme + * drives the surrounding text color. + */ + @Prop({ reflect: true }) + public adaptColorContrast = false; + @Watch('value') public async textChanged() { try { @@ -126,6 +144,10 @@ export class Markdown { // rehype-sanitize can't inspect values inside JSON strings. hydrateCustomElements(this.rootElement, combinedWhitelist); + if (this.adaptColorContrast) { + adaptColorContrast(this.rootElement); + } + this.setupImageIntersectionObserver(); } catch (error) { console.error(error); @@ -142,6 +164,11 @@ export class Markdown { return this.textChanged(); } + @Watch('adaptColorContrast') + public handleAdaptColorContrastChange() { + return this.textChanged(); + } + private rootElement: HTMLDivElement; private imageIntersectionObserver: ImageIntersectionObserver | null = null; private cachedConsumerWhitelist?: CustomElementDefinition[]; From 812fb8f4b80a5e39806d284901d709a0b66b6659 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Wallstr=C3=B6m?= <21986391+FredrikWallstrom@users.noreply.github.com> Date: Fri, 8 May 2026 15:05:40 +0200 Subject: [PATCH 3/3] feat(email-viewer): adapt color contrast in rendered body --- src/components/email-viewer/email-viewer.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/email-viewer/email-viewer.tsx b/src/components/email-viewer/email-viewer.tsx index a9e06c341b..95e43c45b0 100644 --- a/src/components/email-viewer/email-viewer.tsx +++ b/src/components/email-viewer/email-viewer.tsx @@ -14,6 +14,7 @@ import { Email, EmailAttachment, EmailHeaderType } from './email-viewer.types'; import { applyRemoteImagesPolicy, containsRemoteImages } from './remote-images'; import { splitEmailAddressList } from './split-email-address-list'; import { formatBytes } from '../../util/format-bytes'; +import { adaptColorContrast } from '../../util/adapt-color-contrast'; /** * This is a private component, used to render `.eml` files inside @@ -90,6 +91,14 @@ export class EmailViewer { } } + private bodyElement?: HTMLDivElement; + + public componentDidRender() { + if (this.bodyElement?.isConnected) { + adaptColorContrast(this.bodyElement); + } + } + public render() { return ( @@ -146,7 +155,14 @@ export class EmailViewer { this.getAllowRemoteImages() ); - return
; + return ( +
(this.bodyElement = el as HTMLDivElement)} + /> + ); } private renderBodyText() {