Skip to content

Commit 37e930f

Browse files
committed
fix: use MUI-style contrast threshold to handle saturated background colors
The previous approach compared WCAG contrast ratios of textPrimary vs textReverse, which produced wrong results on saturated colors like red where both ratios are near-tied. Switch to a threshold-based approach: if the lighter text candidate meets WCAG AA large-text contrast (3.0), use it; otherwise fall back to the darker candidate.
1 parent 27eaac5 commit 37e930f

File tree

2 files changed

+24
-8
lines changed

2 files changed

+24
-8
lines changed

src/lib/utils.test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,28 @@ const LIGHT_TEXT = '#EAEAEA';
44
const DARK_TEXT = '#000000';
55

66
describe('getContrastText', () => {
7-
it('returns textPrimary on dark backgrounds when textPrimary is light', () => {
7+
it('returns light text on dark backgrounds', () => {
88
expect(getContrastText('#000000', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
99
expect(getContrastText('#1A1A1A', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
1010
expect(getContrastText('#121219', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
1111
expect(getContrastText('#2F4185', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
1212
});
1313

14-
it('returns textReverse on light backgrounds when textPrimary is light', () => {
14+
it('returns dark text on light backgrounds', () => {
1515
expect(getContrastText('#FFFFFF', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
1616
expect(getContrastText('#F5F5F5', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
1717
expect(getContrastText('#FCFCFC', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
1818
});
1919

20-
it('picks the text color with better contrast against a vivid background', () => {
21-
expect(getContrastText('#E9041E', LIGHT_TEXT, DARK_TEXT)).toBe(DARK_TEXT);
22-
expect(getContrastText('#E9041E', '#FFFFFF', '#000000')).toBe('#FFFFFF');
20+
it('returns light text on saturated colors where WCAG luminance is ambiguous', () => {
21+
expect(getContrastText('#E60028', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
22+
expect(getContrastText('#E9041E', LIGHT_TEXT, DARK_TEXT)).toBe(LIGHT_TEXT);
23+
expect(getContrastText('#E60028', '#0D0D0D', '#EAEAEA')).toBe('#EAEAEA');
24+
});
25+
26+
it('works regardless of which token is lighter', () => {
27+
expect(getContrastText('#000000', DARK_TEXT, LIGHT_TEXT)).toBe(LIGHT_TEXT);
28+
expect(getContrastText('#FFFFFF', DARK_TEXT, LIGHT_TEXT)).toBe(DARK_TEXT);
2329
});
2430

2531
it('handles 3-character hex shorthand', () => {

src/lib/utils.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,26 @@ const luminanceOf = (hex: string): number => {
6262
return relativeLuminance(r, g, b);
6363
};
6464

65+
// Minimum WCAG contrast ratio to consider a text color readable on a background.
66+
// 3.0 corresponds to WCAG AA for large text — same threshold used by MUI.
67+
const CONTRAST_THRESHOLD = 3;
68+
6569
export const getContrastText = (
6670
bgColor: string,
6771
textPrimary: string,
6872
textReverse: string,
6973
): string | null => {
7074
try {
7175
const bgLum = luminanceOf(bgColor);
72-
const primaryContrast = wcagContrastRatio(luminanceOf(textPrimary), bgLum);
73-
const reverseContrast = wcagContrastRatio(luminanceOf(textReverse), bgLum);
74-
return reverseContrast > primaryContrast ? textReverse : textPrimary;
76+
const primaryLum = luminanceOf(textPrimary);
77+
const reverseLum = luminanceOf(textReverse);
78+
79+
const lighterText = primaryLum >= reverseLum ? textPrimary : textReverse;
80+
const darkerText = primaryLum >= reverseLum ? textReverse : textPrimary;
81+
82+
const lighterContrast = wcagContrastRatio(primaryLum >= reverseLum ? primaryLum : reverseLum, bgLum);
83+
84+
return lighterContrast >= CONTRAST_THRESHOLD ? lighterText : darkerText;
7585
} catch {
7686
return null;
7787
}

0 commit comments

Comments
 (0)