@xypnox/themescura
Version:
A design system and theme engine toolkit
495 lines (486 loc) • 17.3 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;
};
// Types
// 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' }) => {
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 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,
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),
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 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 scale.reduce((acc, size) => ({
...acc,
[size.step]: size.clamp,
}), {});
};
// 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 invertLightness = (color) => {
const c = getLCH(color);
return `oklch(${100 - c.l}% ${c.c} ${c.h})`;
};
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,
}) => {
const totalSteps = steps.reduce((acc, step) => acc + step, 0);
const shades = Array.from({ length: totalSteps })
.map((_, i) => i)
.reduce((acc, _step, i) => {
const isNegative = i < steps[0];
const idx = isNegative ? i - steps[0] : i - steps[0] + 1;
const shifted = {
lightness: shift.lightness !== undefined
? (isNegative ? shift.lightness : shift.lightness) * idx
: undefined,
chroma: shift.chroma !== undefined
? (isNegative ? shift.chroma : shift.chroma) * idx
: undefined,
hue: shift.hue !== undefined
? (isNegative ? shift.hue : shift.hue) * idx
: undefined,
};
return [...acc, shiftLCH(color, shifted)];
}, []);
// insert the original color in the middle
shades.splice(steps[0], 0, color);
// console.log({ shades })
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}`;
};
exports.convertToVar = convertToVar;
exports.cssConverter = cssConverter;
exports.deepMerge = deepMerge;
exports.defaultTypeScale = defaultTypeScale;
exports.flattenCss = flattenCss;
exports.flattenObject = flattenObject;
exports.forObjectReplace = forObjectReplace;
exports.generateTheme = generateTheme;
exports.getShades = getShades;
exports.invertLightness = invertLightness;
exports.shiftLCH = shiftLCH;
exports.typeScale = typeScale;
exports.utopia = utopia;