@b1dr/themescura
Version:
A design system and theme engine toolkit
612 lines (604 loc) • 20.9 kB
JavaScript
// Function to generate a nested object of a Type that has string values at leafs
// Take function f to generate type y and replace the string value with y
// Ex: { palette: { primary: "#FF5370" } } => { palette: { primary: f(keys, values) } }
const forObjectReplace = (obj, replace) => {
// Nested
const generatedObject = JSON.parse(JSON.stringify(obj));
const generate = (obj, prefix) => {
Object.keys(obj).forEach((key) => {
if (typeof obj[key] === "object") {
generate(obj[key], [...prefix, key]);
}
else {
obj[key] = replace([...prefix, key], obj[key]);
}
});
};
generate(generatedObject, []);
return generatedObject;
};
// Flattens a nested object of a theme to a flat object of css variables
// Ex: { palette: { primary: "#FF5370" } } => { "--palette-primary": "#FF5370" }
const flattenObject = (theme, newKey) => {
const flattenedObject = {};
const flatten = (obj, prefix) => {
Object.keys(obj).forEach((key) => {
if (typeof obj[key] === "object") {
flatten(obj[key], [...prefix, key]);
}
else {
const newItem = newKey([...prefix, key], obj[key]);
flattenedObject[newItem[0]] = newItem[1];
}
});
};
flatten(theme, []);
return flattenedObject;
};
function isObject(item) {
return (item && typeof item === 'object' && !Array.isArray(item));
}
const deepMerge = (theme1, theme2) => {
// Deep merge as deep object
let output = Object.assign({}, theme1);
if (isObject(theme1) && isObject(theme2)) {
Object.keys(theme2).forEach(key => {
if (isObject(theme2[key])) {
if (!(key in theme1))
Object.assign(output, { [key]: theme2[key] });
else
output[key] = deepMerge(theme1[key], theme2[key]);
}
else {
Object.assign(output, { [key]: theme2[key] });
}
});
}
return output;
};
// Lifted from: https://github.com/trys/utopia-core/
// Helpers
const lerp = (x, y, a) => x * (1 - a) + y * a;
const clamp = (a, min = 0, max = 1) => Math.min(max, Math.max(min, a));
const invlerp = (x, y, a) => clamp((a - x) / (y - x));
const range = (x1, y1, x2, y2, a) => lerp(x2, y2, invlerp(x1, y1, a));
const roundValue = (n) => Math.round((n + Number.EPSILON) * 10000) / 10000;
const sortNumberAscending = (a, b) => Number(a) - Number(b);
// Clamp
const calculateClamp = ({ maxSize, minSize, minWidth, maxWidth, usePx = false, relativeTo = "viewport-width", }) => {
const isNegative = minSize > maxSize;
const min = isNegative ? maxSize : minSize;
const max = isNegative ? minSize : maxSize;
const divider = usePx ? 1 : 16;
const unit = usePx ? "px" : "rem";
const relativeUnits = {
viewport: "vi",
"viewport-width": "vw",
container: "cqi",
};
const relativeUnit = relativeUnits[relativeTo] || relativeUnits.viewport;
const slope = (maxSize / divider - minSize / divider) /
(maxWidth / divider - minWidth / divider);
const intersection = -1 * (minWidth / divider) * slope + minSize / divider;
return `clamp(${roundValue(min / divider)}${unit}, ${roundValue(intersection)}${unit} + ${roundValue(slope * 100)}${relativeUnit}, ${roundValue(max / divider)}${unit})`;
};
/**
* checkWCAG
* Check if the clamp confirms to WCAG 1.4.4
* Many thanks to Maxwell Barvian, creator of fluid.style for this calculation
* @link https://barvian.me
* @returns number[] | null
*/
function checkWCAG({ min, max, minWidth, maxWidth, }) {
if (minWidth > maxWidth) {
// need to flip because our checks assume minWidth < maxWidth
const oldMinScreen = minWidth;
minWidth = maxWidth;
maxWidth = oldMinScreen;
const oldmin = min;
min = max;
max = oldmin;
}
const slope = (max - min) / (maxWidth - minWidth);
const intercept = min - minWidth * slope;
const lh = (5 * min - 2 * intercept) / (2 * slope);
const rh = (5 * intercept - 2 * max) / (-1 * slope);
const lh2 = (3 * intercept) / slope;
// this assumes minWidth < maxWidth, hence the flip above
// These were generated by creating piecewise functions for z5 (the font at 500% zoom in Chrome/Firefox)
// and 2*z1 (2*the font at 100% zoom; the WCAG requirement), then solving for
// z5 < 2*z1
let failRange = [];
if (maxWidth < 5 * minWidth) {
if (minWidth < lh && lh < maxWidth) {
failRange.push(Math.max(lh, minWidth), maxWidth);
}
if (5 * min < 2 * max) {
failRange.push(maxWidth, 5 * minWidth);
}
if (5 * minWidth < rh && rh < 5 * maxWidth) {
failRange.push(5 * minWidth, Math.min(rh, 5 * maxWidth));
}
}
else {
if (minWidth < lh && lh < 5 * minWidth) {
failRange.push(Math.max(lh, minWidth), 5 * minWidth);
}
if (5 * minWidth < lh2 && lh2 < maxWidth) {
failRange.push(Math.max(lh2, 5 * minWidth), maxWidth);
}
if (maxWidth < rh && rh < 5 * maxWidth) {
failRange.push(maxWidth, Math.min(rh, 5 * maxWidth));
}
}
// Clean up range
if (failRange.length) {
failRange = [failRange[0], failRange[failRange.length - 1]];
if (Math.abs(failRange[1] - failRange[0]) < 0.1)
failRange = null; // rounding errors, ignore
}
return failRange;
}
const calculateClamps = ({ minWidth, maxWidth, pairs = [], relativeTo, }) => {
return pairs.map(([minSize, maxSize]) => {
return {
label: `${minSize}-${maxSize}`,
clamp: calculateClamp({
minSize,
maxSize,
minWidth,
maxWidth,
relativeTo,
}),
clampPx: calculateClamp({
minSize,
maxSize,
minWidth,
maxWidth,
relativeTo,
usePx: true,
}),
};
});
};
// Type
const calculateTypeSize = (config, viewport, step) => {
const scale = range(config.minWidth, config.maxWidth, config.minTypeScale, config.maxTypeScale, viewport);
const fontSize = range(config.minWidth, config.maxWidth, config.minFontSize, config.maxFontSize, viewport);
return fontSize * Math.pow(scale, step);
};
const mapStepToLabel = (step, labelGroup = "utopia") => {
if (labelGroup === "utopia")
return step.toString();
if (step < -2)
return `${-1 * (step + 1)}xs`;
if (step === -2)
return "xs";
if (labelGroup === "tailwind") {
if (step === -1)
return "sm";
if (step === 0)
return "base";
if (step === 1)
return "lg";
}
if (labelGroup === "tshirt") {
if (step === -1)
return "s";
if (step === 0)
return "m";
if (step === 1)
return "l";
}
if (step === 2)
return "xl";
if (step > 2)
return `${step - 1}xl`;
return step.toString();
};
const calculateTypeStep = (config, step) => {
const minFontSize = calculateTypeSize(config, config.minWidth, step);
const maxFontSize = calculateTypeSize(config, config.maxWidth, step);
const wcag = checkWCAG({
min: minFontSize,
max: maxFontSize,
minWidth: config.minWidth,
maxWidth: config.maxWidth,
});
return {
step,
label: mapStepToLabel(step, config.labelStyle),
minFontSize: roundValue(minFontSize),
maxFontSize: roundValue(maxFontSize),
wcagViolation: wcag?.length
? {
from: Math.round(wcag[0]),
to: Math.round(wcag[1]),
}
: null,
clamp: calculateClamp({
minSize: minFontSize,
maxSize: maxFontSize,
minWidth: config.minWidth,
maxWidth: config.maxWidth,
relativeTo: config.relativeTo,
}),
};
};
const calculateTypeScale = (config) => {
const positiveSteps = Array.from({ length: config.positiveSteps || 0 })
.map((_, i) => calculateTypeStep(config, i + 1))
.reverse();
const negativeSteps = Array.from({ length: config.negativeSteps || 0 }).map((_, i) => calculateTypeStep(config, -1 * (i + 1)));
return [...positiveSteps, calculateTypeStep(config, 0), ...negativeSteps];
};
// Space
const calculateSpaceSize = (config, multiplier, step) => {
const minSize = Math.round(config.minSize * multiplier);
const maxSize = Math.round(config.maxSize * multiplier);
let label = "S";
if (step === 1) {
label = "M";
}
else if (step === 2) {
label = "L";
}
else if (step === 3) {
label = "XL";
}
else if (step > 3) {
label = `${step - 2}XL`;
}
else if (step === -1) {
label = "XS";
}
else if (step < 0) {
label = `${Math.abs(step)}XS`;
}
return {
label: label.toLowerCase(),
minSize: roundValue(minSize),
maxSize: roundValue(maxSize),
multiplier,
clamp: calculateClamp({
minSize,
maxSize,
minWidth: config.minWidth,
maxWidth: config.maxWidth,
relativeTo: config.relativeTo,
}),
clampPx: calculateClamp({
minSize,
maxSize,
minWidth: config.minWidth,
maxWidth: config.maxWidth,
relativeTo: config.relativeTo,
usePx: true,
}),
};
};
const calculateOneUpPairs = (config, sizes) => {
return [...sizes.reverse()]
.map((size, i, arr) => {
if (!i)
return null;
const prev = arr[i - 1];
return {
label: `${prev.label}-${size.label}`,
minSize: prev.minSize,
maxSize: size.maxSize,
clamp: calculateClamp({
minSize: prev.minSize,
maxSize: size.maxSize,
minWidth: config.minWidth,
maxWidth: config.maxWidth,
relativeTo: config.relativeTo,
}),
clampPx: calculateClamp({
minSize: prev.minSize,
maxSize: size.maxSize,
minWidth: config.minWidth,
maxWidth: config.maxWidth,
relativeTo: config.relativeTo,
usePx: true,
}),
};
})
.filter((size) => !!size);
};
const calculateCustomPairs = (config, sizes) => {
return (config.customSizes || [])
.map((label) => {
const [keyA, keyB] = label.split("-");
if (!keyA || !keyB)
return null;
const a = sizes.find((x) => x.label === keyA);
const b = sizes.find((x) => x.label === keyB);
if (!a || !b)
return null;
return {
label: `${keyA}-${keyB}`,
minSize: a.minSize,
maxSize: b.maxSize,
clamp: calculateClamp({
minWidth: config.minWidth,
maxWidth: config.maxWidth,
minSize: a.minSize,
maxSize: b.maxSize,
relativeTo: config.relativeTo,
}),
clampPx: calculateClamp({
minWidth: config.minWidth,
maxWidth: config.maxWidth,
minSize: a.minSize,
maxSize: b.maxSize,
relativeTo: config.relativeTo,
usePx: true,
}),
};
})
.filter((size) => !!size);
};
const calculateSpaceScale = (config) => {
const positiveSteps = [...(config.positiveSteps || [])]
.sort(sortNumberAscending)
.map((multiplier, i) => calculateSpaceSize(config, multiplier, i + 1))
.reverse();
const negativeSteps = [...(config.negativeSteps || [])]
.sort(sortNumberAscending)
.reverse()
.map((multiplier, i) => calculateSpaceSize(config, multiplier, -1 * (i + 1)));
const sizes = [
...positiveSteps,
calculateSpaceSize(config, 1, 0),
...negativeSteps,
];
const oneUpPairs = calculateOneUpPairs(config, sizes);
const customPairs = calculateCustomPairs(config, sizes);
return {
sizes,
oneUpPairs,
customPairs,
};
};
var u = /*#__PURE__*/Object.freeze({
__proto__: null,
calculateClamp: calculateClamp,
calculateClamps: calculateClamps,
calculateSpaceScale: calculateSpaceScale,
calculateTypeScale: calculateTypeScale,
checkWCAG: checkWCAG
});
const getFlatRecord = (objs, keyKey, valKey) => {
return objs.reduce((acc, obj) => {
const newKey = String(obj[keyKey]);
const newVal = String(obj[valKey]);
return { ...acc, [newKey]: newVal };
}, {});
};
const defaultTypeScale = {
minWidth: 320,
maxWidth: 1400,
minFontSize: 16,
maxFontSize: 21,
minTypeScale: 1.2,
maxTypeScale: 1.3,
negativeSteps: 2,
positiveSteps: 5,
};
const typeScale = (params) => {
const scale = calculateTypeScale({
...defaultTypeScale,
...params,
});
return getFlatRecord(scale, "step", "clamp");
};
const defaultSpaceScale = {
minWidth: 320,
maxWidth: 1400,
minSize: 16,
maxSize: 21,
negativeSteps: [0.75, 0.5, 0.25],
positiveSteps: [1.5, 3, 6, 9],
customSizes: ["s-xl"],
};
const spaceScale = (params) => {
const scale = calculateSpaceScale({
...defaultSpaceScale,
...params,
});
return {
...getFlatRecord(scale.sizes, "label", "clamp"),
...getFlatRecord(scale.oneUpPairs, "label", "clamp"),
...getFlatRecord(scale.customPairs, "label", "clamp"),
};
};
// --- Easing Helper Functions ---
/**
* A generic cubic-bezier function.
* @param p1x - The x-coordinate of the first control point.
* @param p1y - The y-coordinate of the first control point.
* @param p2x - The x-coordinate of the second control point.
* @param p2y - The y-coordinate of the second control point.
* @returns A function that takes a progress value 't' (from 0 to 1) and returns the eased value.
*/
const cubicBezier = (p1x, p1y, p2x, p2y) => {
const cx = 3 * p1x;
const bx = 3 * (p2x - p1x) - cx;
const ax = 1 - cx - bx;
const cy = 3 * p1y;
const by = 3 * (p2y - p1y) - cy;
const ay = 1 - cy - by;
const sampleCurveX = (t) => ((ax * t + bx) * t + cx) * t;
const sampleCurveY = (t) => ((ay * t + by) * t + cy) * t;
const sampleCurveDerivativeX = (t) => (3 * ax * t + 2 * bx) * t + cx;
const solveCurveX = (x, epsilon) => {
let t0 = x;
for (let i = 0; i < 8; i++) {
const x2 = sampleCurveX(t0) - x;
if (Math.abs(x2) < epsilon)
return t0;
const d2 = sampleCurveDerivativeX(t0);
if (Math.abs(d2) < 1e-6)
break;
t0 = t0 - x2 / d2;
}
return t0;
};
return (t) => sampleCurveY(solveCurveX(t, 1e-6));
};
const easingFunctions = {
linear: (t) => t,
// Standard ease-in-out curve
"slow-fast-slow": cubicBezier(0.42, 0, 0.58, 1),
// Custom curve that starts and ends fast
"fast-slow-fast": cubicBezier(0.1, 0.7, 0.9, 0.3),
};
/**
* Applies a selected easing function to a progress value.
* @param progress - The linear progress value (0 to 1).
* @param easeType - The type of easing to apply.
* @returns The eased progress value.
*/
const applyEasing = (progress, easeType) => {
return easingFunctions[easeType](progress);
};
// --- Color Helper Functions (largely unchanged, but included for context) ---
// color is oklch(l% c h)
const getLCH = (color) => {
const val = {
l: 0,
c: 0,
h: 0,
};
color.split(" ").forEach((v, i) => {
if (i === 0) {
val.l = parseFloat(v.replace("%", "").replace("oklch(", ""));
}
if (i === 1)
val.c = parseFloat(v);
else
val.h = parseFloat(v.replace(")", ""));
});
// console.log({ color, val })
return val;
};
const shiftLCH = (color, shift) => {
const c = getLCH(color);
const shifted = {
lightness: shift.lightness !== undefined ? (c.l + shift.lightness).toFixed(2) : c.l,
chroma: shift.chroma !== undefined ? (c.c + shift.chroma).toFixed(2) : c.c,
hue: shift.hue !== undefined ? (c.h + shift.hue).toFixed(2) : c.h,
};
return `oklch(${shifted.lightness}% ${shifted.chroma} ${shifted.hue})`;
};
const getShades = (color, steps = [2, 2], shift = {
lightness: 5,
}, easing = "linear") => {
const [darkerSteps, lighterSteps] = steps;
let shades = [];
// Generate darker shades
for (let i = darkerSteps; i > 0; i--) {
const progress = i / darkerSteps;
const easedProgress = applyEasing(progress, easing);
const shifted = {
lightness: shift.lightness ? -shift.lightness * easedProgress : undefined,
chroma: shift.chroma ? -shift.chroma * easedProgress : undefined,
hue: shift.hue ? -shift.hue * easedProgress : undefined,
};
shades.push(shiftLCH(color, shifted));
}
// Add the original color
shades.push(color);
// Generate lighter shades
for (let i = 1; i <= lighterSteps; i++) {
const progress = i / lighterSteps;
const easedProgress = applyEasing(progress, easing);
const shifted = {
lightness: shift.lightness ? shift.lightness * easedProgress : undefined,
chroma: shift.chroma ? shift.chroma * easedProgress : undefined,
hue: shift.hue ? shift.hue * easedProgress : undefined,
};
shades.push(shiftLCH(color, shifted));
}
return shades;
};
const utopia = u;
const newKey = (keys, value) => [`--${keys.join("-")}`, value];
const joinVariables = (vars) => Object.entries(vars).map(([k, v]) => `${k}: ${v};`).join('\n ');
/**
* Input a nested object and prefix
* Returns a new object with the keys replaced with css variables made from the prefix and the keys
*/
const convertToVar = (theme, prefix = '') => forObjectReplace(theme, (keys) => `var(--${prefix}${keys.join("-")})`);
/**
* Flatten a nested object to a flat object,
* The keys are replaced and joined with the newKey function
* that gives a key starting with -- and joined with -
*/
const flattenCss = (theme) => flattenObject(theme, newKey);
/**
* Generate a theme from a palette
* @param palette
* @param baseFn
* @param modeFn
* The baseFn and modeFn are functions convert their respective parts of the palette
*/
// export declare const generateTheme: <
// BFn extends Fn,
// MFn extends Fn,
// T extends ThemeFn<BFn, MFn>
// >(
// palette: PaletteFn<BFn, MFn>,
// baseFn: BFn,
// modeFn: MFn) => T;
/**
* Generate a theme from a palette
* @param palette
* @param baseFn
* @param modeFn
* The baseFn and modeFn are functions convert their respective parts of the palette
*/
const generateTheme = (palette, baseFn, modeFn) => {
return {
id: palette.id,
name: palette.name,
base: baseFn(palette.base),
vars: {
light: modeFn(palette.vars.light, 'light'),
dark: modeFn(palette.vars.dark, 'dark'),
}
};
};
/**
* Final css should be
* :root { // Base vars }
* @media (prefers-color-scheme: dark) {
* :root { // Dark mode vars }
* }
* @media (prefers-color-scheme: light) {
* :root { // Light mode vars }
* }
* .dark-mode { // This is added to the body tag }
* .light-mode { // This is added to the body tag }
* The class is selected last to override preference
* when it is set specifically by user
*/
const cssConverter = (theme) => {
const baseCssVars = flattenObject(theme.base, newKey);
const modeVars = {
dark: flattenObject(theme.vars.dark, newKey),
light: flattenObject(theme.vars.light, newKey),
};
const baseStyles = `:root { \n ${joinVariables(baseCssVars)} \n } \n`;
const modeVarsStyles = ['dark', 'light'].map(key => {
const value = modeVars[key];
return `\n .${key}-mode { \n ${joinVariables(value)} \n } \n `;
}).join('\n');
const mediaVarsStyles = ['dark', 'light'].map(key => {
const value = modeVars[key];
return `\n @media (prefers-color-scheme: ${key}) { :root { \n ${joinVariables(value)} \n } } \n`;
}).join('\n');
return `${baseStyles} ${mediaVarsStyles} ${modeVarsStyles}`;
};
export { convertToVar, cssConverter, deepMerge, defaultSpaceScale, defaultTypeScale, flattenCss, flattenObject, forObjectReplace, generateTheme, getShades, shiftLCH, spaceScale, typeScale, utopia };