colorizr
Version:
Manipulate colors like a boss
94 lines (78 loc) • 3.05 kB
text/typescript
import { MESSAGES } from '~/modules/constants';
import { invariant } from '~/modules/invariant';
import { resolveColor } from '~/modules/parsed-color';
import { round } from '~/modules/utils';
import { isString } from '~/modules/validators';
// APCA W3 Constants (version 0.0.98G-4g)
// Based on official SAPC-APCA implementation from Myndex/SAPC-APCA
export const APCA_VERSION = '0.0.98G-4g';
// Exponents
const mainTRC = 2.4; // sRGB gamma (simpler than WCAG piecewise)
const normBG = 0.56; // Normal polarity: dark text on light bg
const normTXT = 0.57;
const revBG = 0.65; // Reverse polarity: light text on dark bg
const revTXT = 0.62;
// sRGB coefficients
const sRco = 0.2126729;
const sGco = 0.7151522;
const sBco = 0.072175;
// Black soft clamp
const blkThreshold = 0.022; // Threshold for soft clamp
const blkClamp = 1.414; // Clamp exponent
// Scaling
const scaleBoW = 1.14; // Black on white scale
const scaleWoB = 1.14; // White on black scale
const loBoWOffset = 0.027; // Low contrast offset
const loWoBOffset = 0.027;
const loClip = 0.1; // Clip contrasts below |10|
const deltaYmin = 0.0005; // Minimum Y difference
/**
* Apply soft clamp to black levels.
* Improves contrast prediction for very dark colors.
*/
function softClamp(Y: number): number {
return Y > blkThreshold ? Y : Y + (blkThreshold - Y) ** blkClamp;
}
/**
* Convert sRGB values to luminance (Y) for APCA.
* Uses simple gamma 2.4, unlike WCAG's piecewise function.
*/
function sRGBtoY(r: number, g: number, b: number): number {
return sRco * (r / 255) ** mainTRC + sGco * (g / 255) ** mainTRC + sBco * (b / 255) ** mainTRC;
}
/**
* Calculate APCA contrast between foreground and background colors.
*
* APCA (Accessible Perceptual Contrast Algorithm) is polarity-aware:
* - Positive values indicate dark text on light background
* - Negative values indicate light text on dark background
*
* @param background - The background color string.
* @param foreground - The foreground/text color string.
* @returns Lc (Lightness contrast) value, roughly -108 to +106.
*/
export default function apcaContrast(background: string, foreground: string): number {
invariant(isString(background), MESSAGES.inputString);
invariant(isString(foreground), MESSAGES.inputString);
const bg = resolveColor(background).rgb;
const fg = resolveColor(foreground).rgb;
const txtY = softClamp(sRGBtoY(fg.r, fg.g, fg.b));
const bgY = softClamp(sRGBtoY(bg.r, bg.g, bg.b));
// Return 0 for extremely low ΔY
if (Math.abs(bgY - txtY) < deltaYmin) {
return 0;
}
// Calculate SAPC based on polarity
// Normal polarity: dark text on light background
// Reverse polarity: light text on dark background
const SAPC =
bgY > txtY
? (bgY ** normBG - txtY ** normTXT) * scaleBoW
: (bgY ** revBG - txtY ** revTXT) * scaleWoB;
// Apply low contrast clipping and offset
if (Math.abs(SAPC) < loClip) {
return 0;
}
const Lc = SAPC > 0 ? (SAPC - loBoWOffset) * 100 : (SAPC + loWoBOffset) * 100;
return round(Lc, 5);
}