ui-theme/dist/utils/contrast.js
Natalie aaf23fa33f feat(@cocotte/ui-theme): extract UI theme package to @ct/@packages
Re-scoped from @lilith/ui-theme to @cocotte/ui-theme. In-set cross-package deps
re-pointed to @cocotte; out-of-set @lilith deps preserved (same Verdaccio).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 13:04:11 -04:00

94 lines
No EOL
3 KiB
JavaScript

/**
* WCAG 2.1 Contrast Ratio Utilities
*
* Pure math functions for calculating contrast ratios between colors.
* Used for automated accessibility testing of theme color tokens.
*
* @see https://www.w3.org/TR/WCAG21/#contrast-minimum
*/
/**
* Check if a string is a valid hex color (#fff, #ffffff, #fffa, #ffffff80).
* Rejects rgba(), hsl(), named colors, etc.
*/
export function isHexColor(value) {
return /^#([0-9a-fA-F]{3,8})$/.test(value);
}
/**
* Check if a string is an opaque hex color (#rgb or #rrggbb only).
* Rejects alpha-bearing hex (#rgba, #rrggbbaa) since contrast
* calculation requires compositing against a known background.
*/
export function isOpaqueHexColor(value) {
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value);
}
/**
* Parse a hex color string to RGB tuple.
* Handles shorthand (#fff), full (#ffffff), and alpha (#ffffff80).
* Strips alpha channel — contrast calculation uses only RGB.
*
* @throws {Error} If input is not a valid hex color
*/
export function hexToRgb(hex) {
if (!isHexColor(hex)) {
throw new Error(`Not a hex color: "${hex}". Only #rgb, #rrggbb, #rrggbbaa formats supported.`);
}
let h = hex.slice(1); // Remove #
// Expand shorthand: #fff → ffffff, #fffa → ffffffaa
if (h.length === 3 || h.length === 4) {
h = h
.slice(0, 3)
.split('')
.map((c) => c + c)
.join('');
}
else {
// Strip alpha from 8-digit hex
h = h.slice(0, 6);
}
const num = parseInt(h, 16);
return [(num >> 16) & 0xff, (num >> 8) & 0xff, num & 0xff];
}
/**
* Calculate relative luminance per WCAG 2.1.
* Applies sRGB linearization then weighted sum.
*
* @see https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
*/
export function relativeLuminance(r, g, b) {
const [rs, gs, bs] = [r, g, b].map((c) => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
/**
* Calculate WCAG 2.1 contrast ratio between two hex colors.
* Returns a number >= 1, where 21:1 is maximum (black on white).
*
* @example contrastRatio('#000000', '#ffffff') // 21
*/
export function contrastRatio(color1, color2) {
const [r1, g1, b1] = hexToRgb(color1);
const [r2, g2, b2] = hexToRgb(color2);
const l1 = relativeLuminance(r1, g1, b1);
const l2 = relativeLuminance(r2, g2, b2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
const WCAG_THRESHOLDS = {
AA: 4.5,
'AA-large': 3.0,
AAA: 7.0,
};
/**
* Check whether a foreground/background pair meets a WCAG contrast level.
*
* - AA: 4.5:1 (normal text < 18pt)
* - AA-large: 3.0:1 (large text >= 18pt, UI components)
* - AAA: 7.0:1 (enhanced contrast)
*/
export function meetsWCAG(fg, bg, level) {
return contrastRatio(fg, bg) >= WCAG_THRESHOLDS[level];
}
//# sourceMappingURL=contrast.js.map