Add function for finding contrasting colors

This commit is contained in:
Kuba Orlik 2026-08-09 22:10:30 +02:00
parent 5d73487327
commit 95d13656ec
3 changed files with 124 additions and 2 deletions

16
package-lock.json generated
View File

@ -26,6 +26,7 @@
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/simplemde": "^1.11.11", "@types/simplemde": "^1.11.11",
"@types/wcag-contrast": "^3.0.3",
"a11y.css": "^5.3.0", "a11y.css": "^5.3.0",
"c8": "^10.1.3", "c8": "^10.1.3",
"colord": "^2.9.3", "colord": "^2.9.3",
@ -54,7 +55,8 @@
"stimulus": "^3.2.2", "stimulus": "^3.2.2",
"tempstream": "^0.4.6", "tempstream": "^0.4.6",
"throttle-debounce": "^5.0.2", "throttle-debounce": "^5.0.2",
"turndown": "^7.2.0" "turndown": "^7.2.0",
"wcag-contrast": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.54.1", "@playwright/test": "1.54.1",
@ -2044,6 +2046,12 @@
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==" "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="
}, },
"node_modules/@types/wcag-contrast": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/wcag-contrast/-/wcag-contrast-3.0.3.tgz",
"integrity": "sha512-oprevfwJSLfpQK4KaWsRKJuNoebV76+xhmbXiWJGy+FkS34LpCgCMNIwRXWTb8xmmSxUE2ycFOYE7uyRVRm3LA==",
"license": "MIT"
},
"node_modules/@types/webidl-conversions": { "node_modules/@types/webidl-conversions": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@ -14611,6 +14619,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/wcag-contrast/-/wcag-contrast-3.0.0.tgz", "resolved": "https://registry.npmjs.org/wcag-contrast/-/wcag-contrast-3.0.0.tgz",
"integrity": "sha512-RWbpg/S7FOXDCwqC2oFhN/vh8dHzj0OS6dpyOSDHyQFSmqmR+lAUStV/ziTT1GzDqL9wol+nZQB4vCi5yEak+w==", "integrity": "sha512-RWbpg/S7FOXDCwqC2oFhN/vh8dHzj0OS6dpyOSDHyQFSmqmR+lAUStV/ziTT1GzDqL9wol+nZQB4vCi5yEak+w==",
"license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"relative-luminance": "^2.0.0" "relative-luminance": "^2.0.0"
} }
@ -16637,6 +16646,11 @@
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==" "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="
}, },
"@types/wcag-contrast": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/wcag-contrast/-/wcag-contrast-3.0.3.tgz",
"integrity": "sha512-oprevfwJSLfpQK4KaWsRKJuNoebV76+xhmbXiWJGy+FkS34LpCgCMNIwRXWTb8xmmSxUE2ycFOYE7uyRVRm3LA=="
},
"@types/webidl-conversions": { "@types/webidl-conversions": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",

View File

@ -109,6 +109,7 @@
"@types/leaflet": "^1.9.8", "@types/leaflet": "^1.9.8",
"@types/object-hash": "^3.0.6", "@types/object-hash": "^3.0.6",
"@types/simplemde": "^1.11.11", "@types/simplemde": "^1.11.11",
"@types/wcag-contrast": "^3.0.3",
"a11y.css": "^5.3.0", "a11y.css": "^5.3.0",
"c8": "^10.1.3", "c8": "^10.1.3",
"colord": "^2.9.3", "colord": "^2.9.3",
@ -137,7 +138,8 @@
"stimulus": "^3.2.2", "stimulus": "^3.2.2",
"tempstream": "^0.4.6", "tempstream": "^0.4.6",
"throttle-debounce": "^5.0.2", "throttle-debounce": "^5.0.2",
"turndown": "^7.2.0" "turndown": "^7.2.0",
"wcag-contrast": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.54.1", "@playwright/test": "1.54.1",

View File

@ -1,4 +1,5 @@
import { colord } from "colord"; import { colord } from "colord";
import { hex as measureHexContrast } from "wcag-contrast";
export const colors: Record<string, Record<string, string>> = { export const colors: Record<string, Record<string, string>> = {
basic: { basic: {
@ -68,3 +69,108 @@ export function shade(color: string, shade_step: number /* 0-9 */) {
shifted = shifted.rotate(hue_step * steps); shifted = shifted.rotate(hue_step * steps);
return shifted.toHex(); return shifted.toHex();
} }
export function getColorVariables(): string[] {
return Object.entries(colors).flatMap(([group, values]) =>
Object.keys(values).map((name) => `--color-${group}-${name}`)
);
}
function filterCandidates(
candidates: { color: string; contrast: number; shadeStep: number }[],
minimumContrast: number,
min_brightness: number,
max_brightness: number
) {
return candidates
.filter(({ contrast }) => contrast >= minimumContrast)
.filter(({ color }) => {
const brightness = colord(color).brightness();
return brightness > min_brightness && brightness < max_brightness;
})
.sort((a, b) => b.contrast - a.contrast);
}
export function getFgColorVariable(
bgColorName: string,
minimumContrast: number = 4.5
): string {
if (bgColorName.startsWith("--color-")) {
bgColorName = bgColorName.slice("--color-".length);
}
if (bgColorName.startsWith("--")) {
bgColorName = bgColorName.slice(2);
}
const [group, ...nameParts] = bgColorName.split("-");
const name = nameParts.join("-");
if (!group || !name || !colors[group]?.[name]) {
throw new Error(`Unknown color: ${bgColorName}`);
}
// Prefer an explicitly defined text-on token.
const explicitTextName = `text-on-${name}`;
if (colors[group]?.[explicitTextName]) {
return `--color-${group}-${explicitTextName}`;
}
const background = colors[group][name];
// Check both ends of the generated scale. This gives us the closest
// suitable shade rather than blindly choosing black or white.
const candidates = Array.from({ length: 10 }, (_, shadeStep) => {
shadeStep += 0;
const color = shade(background, shadeStep);
const contrast = measureHexContrast(
colord(color).toHex(),
colord(background).toHex()
);
const candidate = {
shadeStep,
contrast,
color,
};
return candidate;
});
let suitable = filterCandidates(candidates, minimumContrast, 0.2, 0.8);
if (suitable.length === 0) {
suitable = filterCandidates(candidates, minimumContrast, 0.1, 0.9);
}
let shadeStep;
if (suitable.length > 0) {
shadeStep = suitable[0]!.shadeStep;
} else {
const darkest = shade(background, 0);
const lightest = shade(background, 9);
const contrast_d = measureHexContrast(colord(darkest).toHex(), background);
const contrast_l = measureHexContrast(colord(lightest).toHex(), background);
if (contrast_l < minimumContrast && contrast_d < minimumContrast) {
if (colord(background).brightness() > 0.5) {
return `--color-basic-black`;
} else {
return `--color-basic-white`;
}
} else if (contrast_l >= minimumContrast && contrast_d >= minimumContrast) {
// pick the smaller contrast so it's prettier than just near black/white
if (contrast_l > contrast_d) {
shadeStep = 0;
} else {
shadeStep = 9;
}
} else if (contrast_l > minimumContrast) {
shadeStep = 9;
} else if (contrast_d > minimumContrast) {
shadeStep = 0;
} else {
shadeStep = 9;
// fallback;
}
}
return `--color-${group}-${name}-${String(shadeStep).padStart(2, "0")}`;
}