sc-theme-builder/lib/bootstrapColor.js
2026-07-01 20:07:28 -05:00

107 lines
2.9 KiB
JavaScript

// 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,
};