UNPKG

radix-theme-generator

Version:

A utility for generating themes for the Radix Themes UI library

496 lines 23.3 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; var __spreadArray = (this && this.__spreadArray) || function (to, from, pack) { if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) { if (ar || !(i in from)) { if (!ar) ar = Array.prototype.slice.call(from, 0, i); ar[i] = from[i]; } } return to.concat(ar || Array.prototype.slice.call(from)); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.generateRadixColors = exports.lightColors = void 0; var RadixColors = __importStar(require("@radix-ui/colors")); var bezier_easing_1 = __importDefault(require("bezier-easing")); var colorjs_io_1 = __importDefault(require("colorjs.io")); var types_1 = require("../types"); var arrayOf12 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; exports.lightColors = Object.fromEntries(types_1.scaleNames.map(function (scaleName) { return [ scaleName, Object.values(RadixColors["".concat(scaleName, "P3")]).map(function (str) { return new colorjs_io_1.default(str).to("oklch"); }), ]; })); var darkColors = Object.fromEntries(types_1.scaleNames.map(function (scaleName) { return [ scaleName, Object.values(RadixColors["".concat(scaleName, "DarkP3")]).map(function (str) { return new colorjs_io_1.default(str).to("oklch"); }), ]; })); var lightGrayColors = Object.fromEntries(types_1.grayScaleNames.map(function (scaleName) { return [ scaleName, Object.values(RadixColors["".concat(scaleName, "P3")]).map(function (str) { return new colorjs_io_1.default(str).to("oklch"); }), ]; })); var darkGrayColors = Object.fromEntries(types_1.grayScaleNames.map(function (scaleName) { return [ scaleName, Object.values(RadixColors["".concat(scaleName, "DarkP3")]).map(function (str) { return new colorjs_io_1.default(str).to("oklch"); }), ]; })); var generateRadixColors = function (_a) { var appearance = _a.appearance, args = __rest(_a, ["appearance"]); var allScales = appearance === "light" ? exports.lightColors : darkColors; var grayScales = appearance === "light" ? lightGrayColors : darkGrayColors; var backgroundColor = new colorjs_io_1.default(args.background).to("oklch"); var grayBaseColor = new colorjs_io_1.default(args.gray).to("oklch"); var grayScaleColors = getScaleFromColor(grayBaseColor, grayScales, backgroundColor); var accentBaseColor = new colorjs_io_1.default(args.accent).to("oklch"); var accentScaleColors = getScaleFromColor(accentBaseColor, allScales, backgroundColor); // Enforce srgb for the background color var backgroundHex = backgroundColor.to("srgb").toString({ format: "hex" }); // Make sure we use the tint from the gray scale for when base is pure white or black var accentBaseHex = accentBaseColor.to("srgb").toString({ format: "hex" }); if (accentBaseHex === "#000" || accentBaseHex === "#fff") { accentScaleColors = grayScaleColors.map(function (color) { return color.clone(); }); } var _b = getStep9Colors(accentScaleColors, accentBaseColor), accent9Color = _b[0], accentContrastColor = _b[1]; accentScaleColors[8] = accent9Color; accentScaleColors[9] = getButtonHoverColor(accent9Color, [accentScaleColors]); // Limit saturation of the text colors accentScaleColors[10].coords[1] = Math.min(Math.max(accentScaleColors[8].coords[1], accentScaleColors[7].coords[1]), accentScaleColors[10].coords[1]); accentScaleColors[11].coords[1] = Math.min(Math.max(accentScaleColors[8].coords[1], accentScaleColors[7].coords[1]), accentScaleColors[11].coords[1]); var accentScaleHex = accentScaleColors.map(function (color) { return color.to("srgb").toString({ format: "hex" }); }); var accentScaleWideGamut = accentScaleColors.map(toOklchString); var accentScaleAlphaHex = accentScaleHex.map(function (color) { return getAlphaColorSrgb(color, backgroundHex); }); var accentScaleAlphaWideGamutString = accentScaleHex.map(function (color) { return getAlphaColorP3(color, backgroundHex); }); var accentContrastColorHex = accentContrastColor .to("srgb") .toString({ format: "hex" }); var grayScaleHex = grayScaleColors.map(function (color) { return color.to("srgb").toString({ format: "hex" }); }); var grayScaleWideGamut = grayScaleColors.map(toOklchString); var grayScaleAlphaHex = grayScaleHex.map(function (color) { return getAlphaColorSrgb(color, backgroundHex); }); var grayScaleAlphaWideGamutString = grayScaleHex.map(function (color) { return getAlphaColorP3(color, backgroundHex); }); var accentSurfaceHex = appearance === "light" ? getAlphaColorSrgb(accentScaleHex[1], backgroundHex, 0.8) : getAlphaColorSrgb(accentScaleHex[1], backgroundHex, 0.5); var accentSurfaceWideGamutString = appearance === "light" ? getAlphaColorP3(accentScaleWideGamut[1], backgroundHex, 0.8) : getAlphaColorP3(accentScaleWideGamut[1], backgroundHex, 0.5); return { accentScale: accentScaleHex, accentScaleAlpha: accentScaleAlphaHex, accentScaleWideGamut: accentScaleWideGamut, accentScaleAlphaWideGamut: accentScaleAlphaWideGamutString, accentContrast: accentContrastColorHex, grayScale: grayScaleHex, grayScaleAlpha: grayScaleAlphaHex, grayScaleWideGamut: grayScaleWideGamut, grayScaleAlphaWideGamut: grayScaleAlphaWideGamutString, graySurface: appearance === "light" ? "#ffffffcc" : "rgba(0, 0, 0, 0.05)", graySurfaceWideGamut: appearance === "light" ? "color(display-p3 1 1 1 / 80%)" : "color(display-p3 0 0 0 / 5%)", accentSurface: accentSurfaceHex, accentSurfaceWideGamut: accentSurfaceWideGamutString, background: backgroundHex, }; }; exports.generateRadixColors = generateRadixColors; function getStep9Colors(scale, accentBaseColor) { var referenceBackgroundColor = scale[0]; var distance = accentBaseColor.deltaEOK(referenceBackgroundColor) * 100; // If the accent base color is close to the page background color, it's likely // white on white or black on black, so we want to return something that makes sense instead if (distance < 25) { return [scale[8], getTextColor(scale[8])]; } return [accentBaseColor, getTextColor(accentBaseColor)]; } function getButtonHoverColor(source, scales) { var _a = source.coords, L = _a[0], C = _a[1], H = _a[2]; var newL = L > 0.4 ? L - 0.03 / (L + 0.1) : L + 0.03 / (L + 0.1); var newC = L > 0.4 && !isNaN(H) ? C * 0.93 + 0 : C; var buttonHoverColor = new colorjs_io_1.default("oklch", [newL, newC, H]); // Find closest in-scale color to donate the chroma and hue. // Especially useful when the source color is pure white or black, // but the gray scale is tinted. var closestColor = buttonHoverColor; var minDistance = Infinity; scales.forEach(function (scale) { for (var _i = 0, scale_1 = scale; _i < scale_1.length; _i++) { var color = scale_1[_i]; var distance = buttonHoverColor.deltaEOK(color); if (distance < minDistance) { minDistance = distance; closestColor = color; } } }); buttonHoverColor.coords[1] = closestColor.coords[1]; buttonHoverColor.coords[2] = closestColor.coords[2]; return buttonHoverColor; } function getScaleFromColor(source, scales, backgroundColor) { var allColors = []; Object.entries(scales).forEach(function (_a) { var name = _a[0], scale = _a[1]; for (var _i = 0, scale_2 = scale; _i < scale_2.length; _i++) { var color = scale_2[_i]; var distance = source.deltaEOK(color); allColors.push({ scale: name, distance: distance, color: color }); } }); allColors.sort(function (a, b) { return a.distance - b.distance; }); // Remove non-unique scales var closestColors = allColors.filter(function (color, i, arr) { return i === arr.findIndex(function (value) { return value.scale === color.scale; }); }); // If the next two closest colors are both grays, remove the second one until it’s not a gray anymore. // This is because up next we will be comparing how close the two closest colors are to the source color, // and since the grays are all extremely close to each other, we won’t get any useful data from the second // closest color if it’s also a gray. var grayScaleNamesStr = types_1.grayScaleNames; var allAreGrays = closestColors.every(function (color) { return grayScaleNamesStr.includes(color.scale); }); if (!allAreGrays && grayScaleNamesStr.includes(closestColors[0].scale)) { while (grayScaleNamesStr.includes(closestColors[1].scale)) { closestColors.splice(1, 1); } } var colorA = closestColors[0]; var colorB = closestColors[1]; // Light trigonometry ahead. // // We want to determine the color that is the closest to the source color. Sometimes it makes sense // to proportionally mix the two closest colors together, but sometimes it is not useful at all. // Color coords are spatial in 3D, however we can treat the data we have as a 2D projection that is good enough. // // Case 1: // If the distances between the source color, the 1st closest color (A) and the 2nd closest color (B) form // a triangle where NEITHER angle A nor B are larger than 90 degrees, then we want to mix the 1st and the 2nd // closest colors in the same proportion as distances AD and BD are to each other. Mixing the two would result // in a color that would be closer to the source color than either of the two original closest colors. // Example: source color is a desaturated blue, which is between "indigo" and "slate" scales. // // C ← Source color // /|⟍ // / | ⟍ // b / | ⟍ a // / | ⟍ // / | ⟍ // A --- D -------- B // ↑ // The color we want to use as the base, which is a mix of A and B. // // Case 2: // If the distances between the source color, the 1st closest color (A) and the 2nd closest color (B) form // a triangle where EITHER angle A or B are larger than 90 degrees, then we don’t care about point B because it’s // directionally the same as A, as mixing A and B can’t provide us with a color that is any closer to the source. // Example: source color is a saturated blue, with "blue" being the closest scale, and "indigo" just being further. // // C ← Source color // \⟍ // \ ⟍ // \ ⟍ a // b \ ⟍ // \ ⟍ // A ------- B // ↑ // The color we want to use as the base, which is not influenced by B. // We’ll need all the lengths of the triangle sides, named after the angles they look at: var a = colorB.distance; var b = colorA.distance; var c = colorA.color.deltaEOK(colorB.color); // We can get the ratios of AD to BD lengths with trigonometry using tangents, // as the ratio of the tangents of the opposite angles will match. var cosA = (Math.pow(b, 2) + Math.pow(c, 2) - Math.pow(a, 2)) / (2 * b * c); var radA = Math.acos(cosA); var sinA = Math.sin(radA); var cosB = (Math.pow(a, 2) + Math.pow(c, 2) - Math.pow(b, 2)) / (2 * a * c); var radB = Math.acos(cosB); var sinB = Math.sin(radB); // Tangent of angle C in the ACD triangle var tanC1 = cosA / sinA; // Tangent of angle C in the BCD triangle var tanC2 = cosB / sinB; // The ratio of the tangents corresponds to the ratio of the distances AD to BD // In the end, it means how much of scale B we want to mix into scale A. // If it’s "0" or less, this is an obtuse triangle from case 2, and we use just scale A. var ratio = Math.max(0, tanC1 / tanC2) * 0.5; // The base scale is going to be a mix of the two closest scales, with the mix ratio we determined before var scaleA = scales[colorA.scale]; var scaleB = scales[colorB.scale]; var scale = arrayOf12.map(function (i) { return new colorjs_io_1.default(colorjs_io_1.default.mix(scaleA[i], scaleB[i], ratio)).to("oklch"); }); // Get the closest color from the pre-mixed scale we created var baseColor = scale .slice() .sort(function (a, b) { return source.deltaEOK(a) - source.deltaEOK(b); })[0]; // Note the chroma difference between the source color and the base color var ratioC = source.coords[1] / baseColor.coords[1]; // Modify hue and chroma of the scale to match the source color scale.forEach(function (color) { color.coords[1] = Math.min(source.coords[1] * 1.5, color.coords[1] * ratioC); color.coords[2] = source.coords[2]; }); // Light mode if (scale[0].coords[0] > 0.5) { var lightnessScale_1 = scale.map(function (_a) { var coords = _a.coords; return coords[0]; }); var backgroundL_1 = Math.max(0, Math.min(1, backgroundColor.coords[0])); var newLightnessScale_1 = transposeProgressionStart(backgroundL_1, __spreadArray([1], lightnessScale_1, true), lightModeEasing); // Remove the step we added newLightnessScale_1.shift(); newLightnessScale_1.forEach(function (lightness, i) { scale[i].coords[0] = lightness; }); return scale; } // Dark mode var ease = __spreadArray([], darkModeEasing, true); var referenceBackgroundColorL = scale[0].coords[0]; var backgroundColorL = Math.max(0, Math.min(1, backgroundColor.coords[0])); // If background is lighter than step 0, we want to gradually change the easing to linear var ratioL = backgroundColorL / referenceBackgroundColorL; if (ratioL > 1) { var maxRatio = 1.5; for (var i = 0; i < ease.length; i++) { var metaRatio = (ratioL - 1) * (maxRatio / (maxRatio - 1)); ease[i] = ratioL > maxRatio ? 0 : Math.max(0, ease[i] * (1 - metaRatio)); } } var lightnessScale = scale.map(function (_a) { var coords = _a.coords; return coords[0]; }); var backgroundL = backgroundColor.coords[0]; var newLightnessScale = transposeProgressionStart(backgroundL, lightnessScale, ease); newLightnessScale.forEach(function (lightness, i) { scale[i].coords[0] = lightness; }); return scale; } function getTextColor(background) { var white = new colorjs_io_1.default("oklch", [1, 0, 0]); if (Math.abs(white.contrastAPCA(background)) < 40) { var _a = background.coords, L = _a[0], C = _a[1], H = _a[2]; return new colorjs_io_1.default("oklch", [0.25, Math.max(0.08 * C, 0.04), H]); } return white; } // target = background * (1 - alpha) + foreground * alpha // alpha = (target - background) / (foreground - background) // Expects 0-1 numbers for the RGB channels function getAlphaColor(targetRgb, backgroundRgb, rgbPrecision, alphaPrecision, targetAlpha) { var _a = targetRgb.map(function (c) { return Math.round(c * rgbPrecision); }), tr = _a[0], tg = _a[1], tb = _a[2]; var _b = backgroundRgb.map(function (c) { return Math.round(c * rgbPrecision); }), br = _b[0], bg = _b[1], bb = _b[2]; if (tr === undefined || tg === undefined || tb === undefined || br === undefined || bg === undefined || bb === undefined) { throw Error("Color is undefined"); } // Is the background color lighter, RGB-wise, than target color? // Decide whether we want to add as little color or as much color as possible, // darkening or lightening the background respectively. // If at least one of the bits of the target RGB value // is lighter than the background, we want to lighten it. var desiredRgb = 0; if (tr > br) { desiredRgb = rgbPrecision; } else if (tg > bg) { desiredRgb = rgbPrecision; } else if (tb > bb) { desiredRgb = rgbPrecision; } var alphaR = (tr - br) / (desiredRgb - br); var alphaG = (tg - bg) / (desiredRgb - bg); var alphaB = (tb - bb) / (desiredRgb - bb); var isPureGray = [alphaR, alphaG, alphaB].every(function (alpha) { return alpha === alphaR; }); // No need for precision gymnastics with pure grays, and we can get cleaner output if (!targetAlpha && isPureGray) { // Convert back to 0-1 values var V = desiredRgb / rgbPrecision; return [V, V, V, alphaR]; } var clampRgb = function (n) { return isNaN(n) ? 0 : Math.min(rgbPrecision, Math.max(0, n)); }; var clampA = function (n) { return isNaN(n) ? 0 : Math.min(alphaPrecision, Math.max(0, n)); }; var maxAlpha = targetAlpha !== null && targetAlpha !== void 0 ? targetAlpha : Math.max(alphaR, alphaG, alphaB); var A = clampA(Math.ceil(maxAlpha * alphaPrecision)) / alphaPrecision; var R = clampRgb(((br * (1 - A) - tr) / A) * -1); var G = clampRgb(((bg * (1 - A) - tg) / A) * -1); var B = clampRgb(((bb * (1 - A) - tb) / A) * -1); R = Math.ceil(R); G = Math.ceil(G); B = Math.ceil(B); var blendedR = blendAlpha(R, A, br); var blendedG = blendAlpha(G, A, bg); var blendedB = blendAlpha(B, A, bb); // Correct for rounding errors in light mode if (desiredRgb === 0) { if (tr <= br && tr !== blendedR) { R = tr > blendedR ? R + 1 : R - 1; } if (tg <= bg && tg !== blendedG) { G = tg > blendedG ? G + 1 : G - 1; } if (tb <= bb && tb !== blendedB) { B = tb > blendedB ? B + 1 : B - 1; } } // Correct for rounding errors in dark mode if (desiredRgb === rgbPrecision) { if (tr >= br && tr !== blendedR) { R = tr > blendedR ? R + 1 : R - 1; } if (tg >= bg && tg !== blendedG) { G = tg > blendedG ? G + 1 : G - 1; } if (tb >= bb && tb !== blendedB) { B = tb > blendedB ? B + 1 : B - 1; } } // Convert back to 0-1 values R = R / rgbPrecision; G = G / rgbPrecision; B = B / rgbPrecision; return [R, G, B, A]; } // Important – I empirically discovered that this rounding is how the browser actually overlays // transparent RGB bits over each other. It does NOT round the whole result altogether. function blendAlpha(foreground, alpha, background, round) { if (round === void 0) { round = true; } if (round) { return (Math.round(background * (1 - alpha)) + Math.round(foreground * alpha)); } return background * (1 - alpha) + foreground * alpha; } function getAlphaColorSrgb(targetColor, backgroundColor, targetAlpha) { var _a = getAlphaColor(new colorjs_io_1.default(targetColor).to("srgb").coords, new colorjs_io_1.default(backgroundColor).to("srgb").coords, 255, 255, targetAlpha), r = _a[0], g = _a[1], b = _a[2], a = _a[3]; return formatHex(new colorjs_io_1.default("srgb", [r, g, b], a).toString({ format: "hex" })); } function getAlphaColorP3(targetColor, backgroundColor, targetAlpha) { var _a = getAlphaColor(new colorjs_io_1.default(targetColor).to("p3").coords, new colorjs_io_1.default(backgroundColor).to("p3").coords, // Not sure why, but the resulting P3 alpha colors are blended in the browser most precisely when // rounded to 255 integers too. Is the browser using 0-255 rather than 0-1 under the hood for P3 too? 255, 1000, targetAlpha), r = _a[0], g = _a[1], b = _a[2], a = _a[3]; return (new colorjs_io_1.default("p3", [r, g, b], a) .toString({ precision: 4 }) // Important: in non-browser environments colorjs.io outputs a different format for some reason .replace("color(p3 ", "color(display-p3 ")); } // Format shortform hex to longform function formatHex(str) { if (!str.startsWith("#")) { return str; } if (str.length === 4) { var hash = str.charAt(0); var r = str.charAt(1); var g = str.charAt(2); var b = str.charAt(3); return hash + r + r + g + g + b + b; } if (str.length === 5) { var hash = str.charAt(0); var r = str.charAt(1); var g = str.charAt(2); var b = str.charAt(3); var a = str.charAt(4); return hash + r + r + g + g + b + b + a + a; } return str; } var darkModeEasing = [1, 0, 1, 0]; var lightModeEasing = [0, 2, 0, 2]; function transposeProgressionStart(to, arr, curve) { return arr.map(function (n, i, arr) { var lastIndex = arr.length - 1; var diff = arr[0] - to; var fn = bezier_easing_1.default.apply(void 0, curve); return n - diff * fn(1 - i / lastIndex); }); } function transposeProgressionEnd(to, arr, curve) { return arr.map(function (n, i, arr) { var lastIndex = arr.length - 1; var diff = arr[lastIndex] - to; var fn = bezier_easing_1.default.apply(void 0, curve); return n - diff * fn(i / lastIndex); }); } // Convert to OKLCH string with percentage for the lightness channel // https://github.com/radix-ui/themes/issues/420 function toOklchString(color) { var L = +(color.coords[0] * 100).toFixed(1); return color .to("oklch") .toString({ precision: 4 }) .replace(/(\S+)(.+)/, "oklch(".concat(L, "%$2")); } //# sourceMappingURL=generateRadixColors.js.map