diff --git a/package-lock.json b/package-lock.json index eb8e325..b93b15b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@types/leaflet": "^1.9.8", "@types/object-hash": "^3.0.6", "@types/simplemde": "^1.11.11", + "@types/wcag-contrast": "^3.0.3", "a11y.css": "^5.3.0", "c8": "^10.1.3", "colord": "^2.9.3", @@ -54,7 +55,8 @@ "stimulus": "^3.2.2", "tempstream": "^0.4.6", "throttle-debounce": "^5.0.2", - "turndown": "^7.2.0" + "turndown": "^7.2.0", + "wcag-contrast": "^3.0.0" }, "devDependencies": { "@playwright/test": "1.54.1", @@ -2044,6 +2046,12 @@ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", "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": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -14611,6 +14619,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/wcag-contrast/-/wcag-contrast-3.0.0.tgz", "integrity": "sha512-RWbpg/S7FOXDCwqC2oFhN/vh8dHzj0OS6dpyOSDHyQFSmqmR+lAUStV/ziTT1GzDqL9wol+nZQB4vCi5yEak+w==", + "license": "BSD-2-Clause", "dependencies": { "relative-luminance": "^2.0.0" } @@ -16637,6 +16646,11 @@ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", "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": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", diff --git a/package.json b/package.json index b76fc24..0d3d9b6 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "@types/leaflet": "^1.9.8", "@types/object-hash": "^3.0.6", "@types/simplemde": "^1.11.11", + "@types/wcag-contrast": "^3.0.3", "a11y.css": "^5.3.0", "c8": "^10.1.3", "colord": "^2.9.3", @@ -137,7 +138,8 @@ "stimulus": "^3.2.2", "tempstream": "^0.4.6", "throttle-debounce": "^5.0.2", - "turndown": "^7.2.0" + "turndown": "^7.2.0", + "wcag-contrast": "^3.0.0" }, "devDependencies": { "@playwright/test": "1.54.1", diff --git a/src/back/colors.ts b/src/back/colors.ts index 1bf8b05..edccb2d 100644 --- a/src/back/colors.ts +++ b/src/back/colors.ts @@ -1,4 +1,5 @@ import { colord } from "colord"; +import { hex as measureHexContrast } from "wcag-contrast"; export const colors: Record> = { basic: { @@ -68,3 +69,108 @@ export function shade(color: string, shade_step: number /* 0-9 */) { shifted = shifted.rotate(hue_step * steps); 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")}`; +}