@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
305 lines (283 loc) • 10.5 kB
JavaScript
/**
* Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
* Node module: @schukai/monster
*
* This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
* The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
*
* For those who do not wish to adhere to the AGPLv3, a commercial license is available.
* Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
* For more information about purchasing a commercial license, please contact Volker Schukai.
*
* SPDX-License-Identifier: AGPL-3.0
*/
/**
* accessibleColor
*
* `accessibleColor(baseColor [, minContrast])` returns a second
* color that meets the given WCAG contrast-ratio against
* `baseColor`.
*
* • `baseColor` String – 3- or 6-digit HEX with or without “#”.
* • `minContrast` Number – target ratio (default **4.5** for WCAG-AA).
* • Return value String – always a 6-digit HEX “#rrggbb”.
*
* HOW IT WORKS
* ------------
* 1. Fast path: tries pure **white** and **black**.
* 2. If both fail, keeps the original hue/saturation and shifts
* lightness in 5 % steps until the ratio is reached.
* 3. Last resort: picks the better of white / black if neither
* reaches the exact ratio after 100 %.
*
* TYPICAL USE-CASES
* -----------------
* • **Dynamic theming**: users pick a brand color → generate safe
* text/background automatically.
* • **Badges / chips**: text on colored pills that must stay legible.
* • **Charts**: ensure labels on bars/slices have enough contrast.
* • **Map markers**: pick a halo color that keeps icons readable.
* • **Data tables**: alternating row colors that adapt to theme.
*
* BASIC EXAMPLES
* --------------
* // Default AA (4.5 : 1)
* const badgeFg = '#007bff'; // brand blue
* const badgeBg = accessibleColor(badgeFg); // → '#ffffff'
*
* // Pick a background for an arbitrary text color
* const text = '#e52929';
* const bg = accessibleColor(text); // may return '#ffffff'
*
* // Request AAA (7 : 1) for small text
* const hiVis = accessibleColor('#888888', 7); // → '#000000'
*
* EXTENDED EXAMPLES
* -----------------
* // 1) Generate theme object
* const base = '#1abc9c';
* const theme = {
* primary: base,
* onPrimary: accessibleColor(base), // safe text color
* border: accessibleColor(base, 3), // borders need only 3 : 1
* };
*
* // 2) Build badge component
* function Badge({ color, children }) {
* const background = color;
* const foreground = accessibleColor(color); // text or icon
* return `<span style="
* background:${background};
* color:${foreground};
* padding:0.25em 0.5em;
* border-radius:0.25rem;">${children}</span>`;
* }
*
* // 3) Ensure chart label contrast
* const segmentColor = '#ffcc00';
* const labelColor = accessibleColor(segmentColor, 3); // 3 : 1 OK for large labels
*
* NOTES & TIPS
* ------------
* • `minContrast = 3` is allowed for:
* – Large / bold text (≥ 24 px normal or ≥ 19 px bold).
* – Non-text UI parts (icons, borders).
* • The algorithm preserves hue & saturation whenever possible,
* so resulting colors match the original palette.
* • Performance: pure JS, no DOM required – safe in workers.
* • Edge-case: middle-gray “#777777” – function will return black
* because white misses 4.5 : 1 by a hair (~4.3 : 1).
*
* @param color
* @param minContrast
* @returns {string|string}
* @summary This function generates a color that meets the WCAG contrast ratio requirements against a given base color.
*/
export function accessibleColor(color, minContrast = 4.5) {
const baseColor = toHex(color);
const baseHex = normalizeHex(baseColor);
const white = "#ffffff";
const black = "#000000";
const baseLum = luminance(baseHex);
// 1) Quick check: try pure white or pure black first
if (contrastRatio(baseLum, luminance(white)) >= minContrast) return white;
if (contrastRatio(baseLum, luminance(black)) >= minContrast) return black;
// 2) Fallback: keep hue/saturation but shift lightness until contrast is OK
let { h, s, l } = hexToHsl(baseHex);
const direction = l < 0.5 ? +1 : -1; // brighten or darken?
for (let i = 1; i <= 20; i++) {
// max 20 × 5 % = 100 %
l = clamp(l + direction * 0.05, 0, 1);
const candidate = hslToHex({ h, s, l });
if (contrastRatio(luminance(candidate), baseLum) >= minContrast) {
return candidate;
}
}
// 3) Last resort: pick the better of (almost-good) white/black
return contrastRatio(baseLum, luminance(white)) >
contrastRatio(baseLum, luminance(black))
? white
: black;
}
/**
* Normalizes a given hexadecimal color code to ensure it is in the standard 6-character and lowercase format, prefixed with a '#'.
*
* @param {string} hex - The hexadecimal color code, which can be in 3-character or 6-character format, with or without the leading '#'.
* @return {string} - The normalized 6-character hexadecimal color code in lowercase, prefixed with a '#'.
*/
function normalizeHex(hex) {
hex = hex.toString().replace(/^#/, "");
if (hex.length === 3)
hex = hex
.split("")
.map((c) => c + c)
.join("");
return "#" + hex.toLowerCase();
}
/**
* Converts a hexadecimal color code to an RGB object.
*
* @param {string} hex - A hexadecimal color string (e.g., "#ff0000" or "ff0000").
* @return {Object} An object representing the RGB color with properties:
* - r: The red component (0-255).
* - g: The green component (0-255).
* - b: The blue component (0-255).
*/
function hexToRgb(hex) {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return {
r: parseInt(m[1], 16),
g: parseInt(m[2], 16),
b: parseInt(m[3], 16),
};
}
/**
* Calculates the relative luminance of a color based on its hexadecimal representation.
* The luminance is calculated using the standard formula for relative luminance
* in the sRGB color space.
*
* @param {string} hex - The hexadecimal color code (e.g., "#FFFFFF" or "#000000").
* @return {number} The relative luminance value, ranging from 0 (darkest) to 1 (lightest).
*/
function luminance(hex) {
const { r, g, b } = hexToRgb(hex);
const srgb = [r, g, b]
.map((v) => v / 255)
.map((v) =>
v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4),
);
return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
}
/**
* Calculates the contrast ratio between two luminance values.
*
* The contrast ratio is determined using a formula defined by the Web Content Accessibility Guidelines (WCAG).
* This ratio helps evaluate the readability of text or visual elements against a background.
*
* @param {number} l1 - The relative luminance of the lighter element.
* @param {number} l2 - The relative luminance of the darker element.
* @returns {number} The contrast ratio, a value ranging from 1 to 21.
*/
const contrastRatio = (l1, l2) =>
(Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
/**
* Converts RGB color values to HSL color space.
*
* @param {Object} rgb - The RGB color object.
* @param {number} rgb.r - The red component (0-255).
* @param {number} rgb.g - The green component (0-255).
* @param {number} rgb.b - The blue component (0-255).
* @return {Object} An object with the HSL values.
* @return {number} return.h - The hue component (0-1).
* @return {number} return.s - The saturation component (0-1).
* @return {number} return.l - The lightness component (0-1).
*/
function rgbToHsl({ r, g, b }) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b);
let h,
s,
l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return { h, s, l };
}
/**
* Converts an HSL color value to its hexadecimal representation.
*
* @param {Object} hsl - An object representing the HSL color.
* @param {number} hsl.h - Hue, a number between 0 and 1.
* @param {number} hsl.s - Saturation, a number between 0 and 1.
* @param {number} hsl.l - Lightness, a number between 0 and 1.
* @return {string} The hexadecimal color string in the format "#RRGGBB".
*/
function hslToHex({ h, s, l }) {
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let r, g, b;
if (s === 0) {
r = g = b = l;
} // achromatic
else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
const toHex = (x) => ("0" + Math.round(x * 255).toString(16)).slice(-2);
return "#" + toHex(r) + toHex(g) + toHex(b);
}
// Keep number between 0 and 1
const clamp = (val, min, max) => Math.min(max, Math.max(min, val));
/**
* Converts any CSS color to full 6-digit HEX "#rrggbb".
* Falls back to throwing an Error if the input is not a valid color.
*
* Works in all browsers (DOM required). For Node.js use a library like
* `color` or `tinycolor2`, or adapt the parser accordingly.
*/
function toHex(color) {
// Use the browser to parse the color for us
const temp = new Option().style; // <option> exists in all browsers
temp.color = color;
// Invalid → empty string
if (!temp.color) {
throw new Error(`"${color}" is not a valid CSS color`);
}
// temp.color is now normalized, e.g. "rgb(255, 0, 0)" or "rgba(0, 0, 0, 0.5)"
const m = temp.color.match(/\d+/g).map(Number); // [r, g, b, (a)]
const [r, g, b] = m;
// If alpha channel < 1 the color is translucent; composite onto white
const a = m[3] !== undefined ? m[3] / 255 || 0 : 1;
const toWhite = (c) => Math.round(a * c + (1 - a) * 255);
const rr = (a < 1 ? toWhite(r) : r).toString(16).padStart(2, "0");
const gg = (a < 1 ? toWhite(g) : g).toString(16).padStart(2, "0");
const bb = (a < 1 ? toWhite(b) : b).toString(16).padStart(2, "0");
return `#${rr}${gg}${bb}`;
}