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>
94 lines
No EOL
3 KiB
JavaScript
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
|