Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions etc/lime-elements.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,7 @@ export namespace Components {
"value"?: any;
}
export interface LimelMarkdown {
"adaptColorContrast": boolean;
"lazyLoadImages": boolean;
"removeEmptyParagraphs": boolean;
"value": string;
Expand Down Expand Up @@ -2811,6 +2812,7 @@ export namespace JSX {
}

export interface LimelMarkdown {
"adaptColorContrast"?: boolean;
"lazyLoadImages"?: boolean;
"removeEmptyParagraphs"?: boolean;
"value"?: string;
Expand All @@ -2820,6 +2822,8 @@ export namespace JSX {

// (undocumented)
export interface LimelMarkdownAttributes {
// (undocumented)
"adaptColorContrast": boolean;
// (undocumented)
"lazyLoadImages": boolean;
// (undocumented)
Expand Down
18 changes: 17 additions & 1 deletion src/components/email-viewer/email-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,6 +91,14 @@ export class EmailViewer {
}
}

private bodyElement?: HTMLDivElement;

public componentDidRender() {
if (this.bodyElement?.isConnected) {
adaptColorContrast(this.bodyElement);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public render() {
return (
<Host>
Expand Down Expand Up @@ -146,7 +155,14 @@ export class EmailViewer {
this.getAllowRemoteImages()
);

return <div class="body" innerHTML={innerHtml} part="email-body" />;
return (
<div
class="body"
innerHTML={innerHtml}
part="email-body"
ref={(el) => (this.bodyElement = el as HTMLDivElement)}
/>
);
}

private renderBodyText() {
Expand Down
47 changes: 47 additions & 0 deletions src/components/markdown/examples/markdown-adapt-color-contrast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Component, h, Host } from '@stencil/core';

const markdown = `
<p style="color: rgb(245, 245, 245)">Light grey — fails contrast.</p>
<p style="color: rgb(200, 38, 19)">Saturated red — passes contrast.</p>
<p style="color: rgb(31, 73, 125)">Dark navy — passes contrast.</p>
<p style="color: rgb(220, 220, 220)">Off-white — fails contrast.</p>
`;

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 (
<Host>
<h4>Default</h4>
<div style={surface}>
<limel-markdown value={markdown} />
</div>
<h4>Adapted</h4>
<div style={surface}>
<limel-markdown
value={markdown}
adaptColorContrast={true}
/>
</div>
</Host>
);
}
}
27 changes: 27 additions & 0 deletions src/components/markdown/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
@Prop({ reflect: true })
public adaptColorContrast = false;
Comment on lines +96 to +110
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing cleanup after every render sounds rather inefficient to me. Why can't it be done as pre-processing by the component providing the bad input in the first place? 🙂

Copy link
Copy Markdown
Contributor Author

@FredrikWallstrom FredrikWallstrom May 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Believe me, me and @Kiarokh has been discussing this back and forth 😄

See https://github.com/Lundalogik/lime-crm-building-blocks/pull/1423, and #4066

Pre-processing the "real field value" doesn't feel right too me since we will mutate users content which will then be lost on round-trips. I rather see a visual solution.

We would love to hear your thoughts how to solve this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. I'm going to dig into your arguments, and see if there are really no better solutions. Please give me over the weekend before you merge this. This component unfortunately has a history of being the go to place for people to try to cram patches to fix their broken email HTML, and I really want that to stop. But if this is truly the best solution in this particular case, I will approve it.

Comment thread
coderabbitai[bot] marked this conversation as resolved.

@Watch('value')
public async textChanged() {
try {
Expand Down Expand Up @@ -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);
Expand All @@ -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[];
Expand Down
114 changes: 114 additions & 0 deletions src/util/adapt-color-contrast.e2e.ts
Original file line number Diff line number Diff line change
@@ -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)');
});
});
86 changes: 86 additions & 0 deletions src/util/adapt-color-contrast.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading