// Faithful JS port of the Bootstrap 5.3 Sass color functions the button-variant // mixin uses, so the "deep overlay" can recompute the per-component // --bs-btn-* variables Bootstrap bakes at compile time. Pure module. // // shade-color(c, w%) = mix(black, c, w) (darken) // tint-color(c, w%) = mix(white, c, w) (lighten) // color-contrast(bg) -> #ffffff or #000000 (WCAG, min ratio 4.5, white first) const WHITE = { r: 255, g: 255, b: 255 }; const BLACK = { r: 0, g: 0, b: 0 }; const MIN_CONTRAST_RATIO = 4.5; // "#rgb" / "#rrggbb" -> {r,g,b}; null for anything else (named/rgb()/var()). function parseHexColor(s) { if (typeof s !== "string") { return null; } const v = s.trim(); const m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(v); const m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(v); if (m6) { return { r: parseInt(m6[1], 16), g: parseInt(m6[2], 16), b: parseInt(m6[3], 16) }; } if (m3) { return { r: parseInt(m3[1] + m3[1], 16), g: parseInt(m3[2] + m3[2], 16), b: parseInt(m3[3] + m3[3], 16) }; } return null; } function clampByte(n) { return Math.min(255, Math.max(0, Math.round(n))); } function toHex(c) { return "#" + [c.r, c.g, c.b].map((n) => clampByte(n).toString(16).padStart(2, "0")).join(""); } function toRgbString(c) { return `${clampByte(c.r)}, ${clampByte(c.g)}, ${clampByte(c.b)}`; } // Sass mix($c1, $c2, $weight): each channel = c1*w + c2*(1-w). weight is a fraction. function mix(c1, c2, weight) { return { r: c1.r * weight + c2.r * (1 - weight), g: c1.g * weight + c2.g * (1 - weight), b: c1.b * weight + c2.b * (1 - weight), }; } function shade(c, percent) { return mix(BLACK, c, percent / 100); } function tint(c, percent) { return mix(WHITE, c, percent / 100); } function channelLuminance(c255) { const c = c255 / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } function relLuminance(c) { return 0.2126 * channelLuminance(c.r) + 0.7152 * channelLuminance(c.g) + 0.0722 * channelLuminance(c.b); } function contrastRatio(a, b) { const la = relLuminance(a) + 0.05; const lb = relLuminance(b) + 0.05; return la > lb ? la / lb : lb / la; } // color-contrast($bg): Bootstrap checks white first then black, returning the // first foreground whose contrast exceeds the min ratio (4.5), else the higher. function colorContrast(bg) { const c = typeof bg === "string" ? parseHexColor(bg) : bg; if (!c) { return "#000000"; } const rw = contrastRatio(c, WHITE); const rb = contrastRatio(c, BLACK); if (rw > MIN_CONTRAST_RATIO) { return "#ffffff"; } if (rb > MIN_CONTRAST_RATIO) { return "#000000"; } return rw >= rb ? "#ffffff" : "#000000"; } module.exports = { WHITE, BLACK, parseHexColor, toHex, toRgbString, mix, shade, tint, relLuminance, contrastRatio, colorContrast, };