UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

305 lines (283 loc) 10.5 kB
/** * Copyright © schukai GmbH 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 schukai GmbH. * * 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}`; }