UNPKG

@b1dr/themescura

Version:

A design system and theme engine toolkit

627 lines (618 loc) 21.2 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; }; // 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}`; }; exports.convertToVar = convertToVar; exports.cssConverter = cssConverter; exports.deepMerge = deepMerge; exports.defaultSpaceScale = defaultSpaceScale; exports.defaultTypeScale = defaultTypeScale; exports.flattenCss = flattenCss; exports.flattenObject = flattenObject; exports.forObjectReplace = forObjectReplace; exports.generateTheme = generateTheme; exports.getShades = getShades; exports.shiftLCH = shiftLCH; exports.spaceScale = spaceScale; exports.typeScale = typeScale; exports.utopia = utopia;