Files
beenvoice/apps/mobile/lib/action-contrast.ts

57 lines
2.0 KiB
TypeScript

const DARK_ACTION_FOREGROUND = "#18181B";
const LIGHT_ACTION_FOREGROUND = "#FFFFFF";
const MIN_TEXT_CONTRAST = 4.5;
function parseHexColor(color: string): [number, number, number] | null {
const value = color.trim().replace(/^#/, "");
const expanded =
value.length === 3
? value
.split("")
.map((character) => character.repeat(2))
.join("")
: value.slice(0, 6);
if (expanded.length !== 6 || !/^[0-9a-f]+$/i.test(expanded)) return null;
return [
Number.parseInt(expanded.slice(0, 2), 16),
Number.parseInt(expanded.slice(2, 4), 16),
Number.parseInt(expanded.slice(4, 6), 16),
];
}
function luminance(color: string): number | null {
const rgb = parseHexColor(color);
if (!rgb) return null;
const channels = rgb.map((channel) => {
const value = channel / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
}
function contrastRatio(foreground: string, background: string): number | null {
const foregroundLuminance = luminance(foreground);
const backgroundLuminance = luminance(background);
if (foregroundLuminance == null || backgroundLuminance == null) return null;
const lighter = Math.max(foregroundLuminance, backgroundLuminance);
const darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
/** Chooses a readable action label/icon color while preserving a valid requested color. */
export function resolveActionForeground(background: string, requested: string): string {
const requestedContrast = contrastRatio(requested, background);
if (requestedContrast == null || requestedContrast >= MIN_TEXT_CONTRAST) {
return requested;
}
const darkContrast = contrastRatio(DARK_ACTION_FOREGROUND, background) ?? 0;
const lightContrast = contrastRatio(LIGHT_ACTION_FOREGROUND, background) ?? 0;
return darkContrast >= lightContrast ? DARK_ACTION_FOREGROUND : LIGHT_ACTION_FOREGROUND;
}