UNPKG

@xypnox/themescura

Version:

A design system and theme engine toolkit

495 lines (486 loc) 17.3 kB
'use strict'; // 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;