From 5d7a2bb228f816705c254a295db1818364bdf7e5 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Tue, 20 Jan 2026 15:21:19 +0200 Subject: [PATCH 1/6] add lush shadows --- index.d.ts | 2 +- index.js | 14 +- src/lush.js | 409 ++++++++++++++++++ src/utils.js | 114 +++++ test/demo/index.html | 52 +++ .../replaces-lush-shadow-function.css | 3 + .../replaces-lush-shadow-function.out.css | 6 + 7 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 src/lush.js create mode 100644 src/utils.js create mode 100644 test/fixtures/replaces-lush-shadow-function.css create mode 100644 test/fixtures/replaces-lush-shadow-function.out.css diff --git a/index.d.ts b/index.d.ts index 154197a..0595337 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,7 +1,7 @@ import type { PluginCreator, AtRule } from 'postcss' export function renderShadows( - type: 'linear' | 'sharp' | 'soft', + type: 'linear' | 'sharp' | 'soft' | 'lush', inset: boolean, x: number, y: number, diff --git a/index.js b/index.js index cd18781..4a05468 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,5 @@ +import { replaceLushFunctions } from './src/lush.js' + function easeInQuad(x) { return x * x } @@ -193,11 +195,21 @@ function findClosingParenAndParseParams(text, openParenIndex) { let plugin = () => { return { Declaration(decl) { - if (decl.value.includes('-shadow(')) { + if (decl.value.includes('--sharp-shadow(')) { replaceFunctions(decl, 'sharp') + } + + if (decl.value.includes('--soft-shadow(')) { replaceFunctions(decl, 'soft') + } + + if (decl.value.includes('--linear-shadow(')) { replaceFunctions(decl, 'linear') } + + if (decl.value.includes('--lush-shadow(')) { + replaceLushFunctions(decl) + } }, postcssPlugin: 'postcss-smooth-shadow' } diff --git a/src/lush.js b/src/lush.js new file mode 100644 index 0000000..ae321d5 --- /dev/null +++ b/src/lush.js @@ -0,0 +1,409 @@ +import { + clamp, + getValuesForBezierCurve, + normalize, + range, + roundTo +} from './utils.js' + +/** + * @param {{ value: string }} decl + */ +export function replaceLushFunctions(decl) { + let { color, crispy, inset, offsetX, offsetY, oomph, resolution, variant } = + parseLushShadow(decl.value) + + let [low, medium, high] = generateShadows({ + color, + crispy, + inset, + lightSource: { + x: offsetX, + y: offsetY + }, + oomph, + resolution + }) + + let before = + decl.raws.between && decl.raws.before ? `${decl.raws.before} ` : '' + let between = + decl.raws.between && decl.raws.before ? `,${decl.raws.before} ` : ', ' + + switch (variant) { + case 'high': + decl.value = before + high.flat().join(between) + break + case 'low': + decl.value = before + low.flat().join(between) + break + case 'medium': + decl.value = before + medium.flat().join(between) + break + } +} + +/** + * Parses --lush-shadow(variant, light-x, light-y, oomph, crispy, resolution, color) + * @param {string} value - e.g. "--lush-shadow(high 0.24 -0.21 0.52 0.76 0.75 oklch(0 0 0 / 40%))" + * @returns {Object|null} + */ +function parseLushShadow(value) { + // Strip wrapper + let content = value + .replace(/^--lush-shadow\s*\(/, '') + .replace(/\)\s*$/, '') + .trim() + + if (!content) return null + + let parts = [] + let current = '' + let parenDepth = 0 + let inColor = false + + // Tokenize respecting nested color functions/var() + for (let char of content) { + if (char === '(') { + parenDepth++ + current += char + if (parenDepth === 1 && !/^[a-zA-Z-]+$/.test(current.trim())) { + inColor = true + } + } else if (char === ')') { + parenDepth-- + current += char + if (parenDepth === 0) inColor = false + } else if (char === ' ' && parenDepth === 0 && !inColor) { + if (current.trim()) parts.push(current.trim()) + current = '' + } else { + current += char + } + } + if (current.trim()) parts.push(current.trim()) + + // Result structure – numbers are now actual numbers + let result = { + color: null, // string + crispy: null, // number | null + inset: false, + offsetX: null, // number | null + offsetY: null, // number | null + oomph: null, // number | null + resolution: null, // number | null + variant: null + } + + let i = 0 + + // 1. Variant (required, first token) + if (parts.length === 0) return null + result.variant = parts[i++] + if (!result.variant || /^[0-9.-]/.test(result.variant)) { + return null // probably missing variant name + } + + // 2. Optional inset + if (i < parts.length && parts[i] === 'inset') { + result.inset = true + i++ + } + + // 3. Numeric parameters → convert to number + let numFields = ['offsetX', 'offsetY', 'oomph', 'crispy', 'resolution'] + let numIdx = 0 + + while (i < parts.length && numIdx < numFields.length) { + let token = parts[i] + // Very permissive number check (including scientific notation) + let num = Number(token) + if (!isNaN(num) && token.trim() !== '') { + result[numFields[numIdx]] = num + numIdx++ + i++ + } else { + break + } + } + + // 4. Remaining = color (can contain spaces inside functions) + if (i < parts.length) { + result.color = parts.slice(i).join(' ') + } + + return result +} + +/** + * We'll generate a set of 3 shadows: small, medium, large. + * Each shadow will have multiple layers, depending on the size. + * A small shadow might only have 2 shadows, a large might have 6. + * Though, this is affected by the `layers` property + * + * @param {{ + * inset: boolean; + * color: string; + * crispy: number; + * lightSource: { + * x: number; + * y: number; + * }; + * oomph: number; + * resolution: number; + * }} props + * @returns + */ +function generateShadows({ + color, + crispy, + inset, + lightSource, + oomph, + resolution +}) { + let output = [] + + let SHADOW_LAYER_LIMITS = { + large: { + max: 10, + min: 3 + }, + medium: { + max: 5, + min: 2 + }, + small: { + max: 3, + min: 2 + } + } + + for (let size of ['small', 'medium', 'large']) { + let numOfLayers = Math.round( + normalize( + resolution, + 0, + 1, + SHADOW_LAYER_LIMITS[size].min, + SHADOW_LAYER_LIMITS[size].max + ) + ) + + let layersForSize = [] + + range(numOfLayers).forEach(layerIndex => { + let opacity = calculateShadowOpacity({ + crispy, + layerIndex, + maxLayers: SHADOW_LAYER_LIMITS[size].max, + minLayers: SHADOW_LAYER_LIMITS[size].min, + numOfLayers, + oomph + }) + + let { x, y } = calculateShadowOffsets({ + crispy, + layerIndex, + lightSource, + numOfLayers, + oomph, + size + }) + + let blurRadius = calculateBlurRadius({ + crispy, + layerIndex, + numOfLayers, + oomph, + size, + x, + y + }) + + let spread = calculateSpread({ + crispy, + layerIndex, + numOfLayers, + oomph + }) + + let spreadString = spread !== 0 ? `${spread}px ` : '' + + let insetPrefix = inset ? 'inset ' : '' + + layersForSize.push([ + `${x}px ${y}px ${blurRadius}px ${spreadString}${insetPrefix}hsl(from ${color} h s l / ${opacity})` + ]) + }) + + output.push(layersForSize) + } + + return output +} + +/** + * @param {{ + * crispy: number; + * layerIndex: number; + * maxLayers: number; + * minLayers: number; + * numOfLayers: number; + * oomph: number; + * }} props + * @returns + */ +function calculateShadowOpacity({ + crispy, + layerIndex, + maxLayers, + minLayers, + numOfLayers, + oomph +}) { + let baseOpacity = normalize(oomph, 0, 1, 0.4, 1.25) + + let initialOpacityMultiplier = normalize(crispy, 0, 1, 0, 1) + let finalOpacityMultiplier = normalize(crispy, 0, 1, 1, 0) + + // Crispy determines which shadows are more visible, and + // which shadows are less visible. + let layerOpacityMultiplier = normalize( + layerIndex, + 0, + numOfLayers, + initialOpacityMultiplier, + finalOpacityMultiplier + ) + + let opacity = baseOpacity * layerOpacityMultiplier + + // So, here's the problem. + // The `resolution` param lets us change how many layers are + // generated. Every additional layer should reduce the opacity + // of all layers, so that "resolution" doesn't change the + // perceived opacity. + let averageLayers = (minLayers + maxLayers) / 2 + let ratio = averageLayers / numOfLayers + + let layerOpacity = opacity * ratio + + layerOpacity *= 0.3 + + return clamp(roundTo(layerOpacity, 2), 0, 1) +} + +/** + * @param {{ + * crispy: number; + * layerIndex: number; + * lightSource: { + * x: number; + * y: number; + * }; + * numOfLayers: number; + * oomph: number; + * size: number; + * }} param0 + * @returns + */ +function calculateShadowOffsets({ + crispy, + layerIndex, + lightSource, + numOfLayers, + oomph, + size +}) { + let maxOffsetBySize = { + large: normalize(oomph, 0, 1, 50, 150), + medium: normalize(oomph, 0, 1, 15, 25), + small: normalize(oomph, 0, 1, 3, 5) + } + + // We don't want to use linear interpolation here because we want + // the shadows to cluster near the front and fall off. Otherwise, + // the most opaque part of the shadow is in the middle of the + // group, rather than being near the element. + // We'll use a bezier curve and pluck points along it. + let curve = { + controlPoint1: [ + normalize(crispy, 0, 1, 0.25, 0), + normalize(crispy, 0, 1, 0.25, 0) + ], + controlPoint2: [ + normalize(crispy, 0, 1, 0.25, 0), + normalize(crispy, 0, 1, 0.25, 0) + ], + endPoint: [1, 0], + startPoint: [0, 1] + } + let t = layerIndex / (numOfLayers - 1) + let [ratio] = getValuesForBezierCurve(curve, t) + + let max = maxOffsetBySize[size] + + // Now, for x/y offset... we have this lightSource value, with + // X and Y from -1 to 1. + let xOffsetMin = normalize(lightSource.x, -1, 1, 1, -1) + let xOffsetMax = normalize(lightSource.x, -1, 1, max, max * -1) + let yOffsetMin = normalize(lightSource.y, -1, 1, 1, -1) + let yOffsetMax = normalize(lightSource.y, -1, 1, max, max * -1) + + let x = roundTo(normalize(ratio, 0, 1, xOffsetMin, xOffsetMax), 1) + let y = roundTo(normalize(ratio, 0, 1, yOffsetMin, yOffsetMax), 1) + + return { x, y } +} + +/** + * @param {{ + * crispy: number; + * layerIndex: number; + * numOfLayers: number; + * oomph: number; + * size: number; + * x: number; + * y: number; + * }} props + * @returns + */ +function calculateBlurRadius({ crispy, x, y }) { + // The blur radius should depend on the x/y offset. + // Calculate the hypothenuse length and use it as the blur radius? + let hypothenuse = (x ** 2 + y ** 2) ** 0.5 + + let radius = normalize(crispy, 0, 1, hypothenuse * 1.5, hypothenuse * 0.75) + + return roundTo(radius, 1) +} + +/** + * @param {{ + * crispy: number; + * layerIndex: number; + * numOfLayers: number; + * oomph: number; + * }} props + * @returns + */ +function calculateSpread({ crispy, layerIndex, numOfLayers }) { + // return 0; + + if (layerIndex === 0) { + return 0 + } + + let maxReduction = normalize(crispy, 0, 1, 0, -5) + + let actualReduction = normalize( + layerIndex + 1, + 1, + numOfLayers, + 0, + maxReduction + ) + + return roundTo(actualReduction, 1) +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..7b2a9e8 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,114 @@ +/** + * @param {number} value + * @param {number} currentScaleMin + * @param {number} currentScaleMax + * @param {number} newScaleMin + * @param {number} newScaleMax + * @returns {number} + */ +export function normalize( + value, + currentScaleMin, + currentScaleMax, + newScaleMin = 0, + newScaleMax = 1 +) { + // First, normalize the value between 0 and 1. + let standardNormalization = + (value - currentScaleMin) / (currentScaleMax - currentScaleMin) + + // Next, transpose that value to our desired scale. + return (newScaleMax - newScaleMin) * standardNormalization + newScaleMin +} + +/** + * @param {number} value + * @param {number} min + * @param {number} max + * @returns {number} + */ +export function clamp(value, min = 0, max = 1) { + // We might be passing in "inverted" values, eg: + // clamp(someVal, 10, 5); + // + // This is especially common with `clampedNormalize`. + // In these cases, we'll flip the min/max so that the function works as expected. + let actualMin = Math.min(min, max) + let actualMax = Math.max(min, max) + + return Math.max(actualMin, Math.min(actualMax, value)) +} + +/** + * @param {number} start + * @param {number} end + * @param {number} step + * @returns {number} + */ +export function range(start, end, step = 1) { + let output = [] + + if (typeof end === 'undefined') { + end = start + start = 0 + } + + for (let i = start; i < end; i += step) { + output.push(i) + } + + return output +} + +/** + * @param {number} value + * @param {number} places + * @returns {number} + */ +export function roundTo(value, places = 0) { + return Math.round(value * 10 ** places) / 10 ** places +} + +/** + * + * @param {{ + * controlPoint1: number, + * controlPoint2: number, + * endPoint: number, + * startPoint: number }} points + * @param {number} t + * @returns + */ +export function getValuesForBezierCurve( + { controlPoint1, controlPoint2, endPoint, startPoint }, + t +) { + let x, y + + if (controlPoint2) { + // Cubic Bezier curve + x = + (1 - t) ** 3 * startPoint[0] + + 3 * (1 - t) ** 2 * t * controlPoint1[0] + + 3 * (1 - t) * t ** 2 * controlPoint2[0] + + t ** 3 * endPoint[0] + + y = + (1 - t) ** 3 * startPoint[1] + + 3 * (1 - t) ** 2 * t * controlPoint1[1] + + 3 * (1 - t) * t ** 2 * controlPoint2[1] + + t ** 3 * endPoint[1] + } else { + // Quadratic Bezier curve + x = + (1 - t) * (1 - t) * startPoint[0] + + 2 * (1 - t) * t * controlPoint1[0] + + t * t * endPoint[0] + y = + (1 - t) * (1 - t) * startPoint[1] + + 2 * (1 - t) * t * controlPoint1[1] + + t * t * endPoint[1] + } + + return [x, y] +} diff --git a/test/demo/index.html b/test/demo/index.html index 0c316db..c1c320a 100644 --- a/test/demo/index.html +++ b/test/demo/index.html @@ -51,6 +51,34 @@ &.is-6 { box-shadow: --sharp-shadow(inset 0 2px 5px oklch(0 0 0 / 40%)); } + + &.is-lush-low { + width: 25rem; + box-shadow: --lush-shadow( + low -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%) + ); + } + + &.is-lush-medium { + width: 25rem; + box-shadow: --lush-shadow( + medium -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%) + ); + } + + &.is-lush-high { + width: 25rem; + box-shadow: --lush-shadow( + high -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%) + ); + } + + &.is-lush-high-inset { + width: 25rem; + box-shadow: --lush-shadow( + medium inset -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%) + ); + } } @@ -90,5 +118,29 @@ inset 0 1px 2px oklch(0 0 0 / 40%) ) + +
+
+--lush-shadow(
+  low -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)
+)
+
+--lush-shadow(
+  medium -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)
+)
+
+--lush-shadow(
+  high -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)
+)
+
+--lush-shadow(
+  medium inset -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)
+)
+
+ diff --git a/test/fixtures/replaces-lush-shadow-function.css b/test/fixtures/replaces-lush-shadow-function.css new file mode 100644 index 0000000..75d6c06 --- /dev/null +++ b/test/fixtures/replaces-lush-shadow-function.css @@ -0,0 +1,3 @@ +a { + box-shadow: --lush-shadow(low -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); +} diff --git a/test/fixtures/replaces-lush-shadow-function.out.css b/test/fixtures/replaces-lush-shadow-function.out.css new file mode 100644 index 0000000..66935ff --- /dev/null +++ b/test/fixtures/replaces-lush-shadow-function.out.css @@ -0,0 +1,6 @@ +a { + box-shadow: + 0.3px 0.5px 0.7px hsl(from oklch(0 0 0 / 15%) h s l / 0.1), + 0.4px 0.8px 1px -1.2px hsl(from oklch(0 0 0 / 15%) h s l / 0.1), + 1px 2px 2.5px -2.5px hsl(from oklch(0 0 0 / 15%) h s l / 0.1); +} From 75d7a76f577565d6aaacf5d0e0e82892d7cdc49e Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Wed, 21 Jan 2026 00:16:52 +0200 Subject: [PATCH 2/6] use `postcss-value-parser` to parse value --- package.json | 1 + pnpm-lock.yaml | 27 +++---- src/lush.js | 188 ++++++++++++++++++++++++------------------------- 3 files changed, 101 insertions(+), 115 deletions(-) diff --git a/package.json b/package.json index d5165de..13a3a64 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "eslint": "^9.37.0", "multiocular": "^0.8.1", "postcss": "^8.5.6", + "postcss-value-parser": "^4.2.0", "vite": "^7.1.10" }, "prettier": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4c31a2..8ef5af8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: postcss: specifier: ^8.5.6 version: 8.5.6 + postcss-value-parser: + specifier: ^4.2.0 + version: 4.2.0 vite: specifier: ^7.1.10 version: 7.1.10(yaml@2.8.1) @@ -341,67 +344,56 @@ packages: resolution: {integrity: sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.52.4': resolution: {integrity: sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.52.4': resolution: {integrity: sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.52.4': resolution: {integrity: sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.4': resolution: {integrity: sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.4': resolution: {integrity: sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.4': resolution: {integrity: sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.4': resolution: {integrity: sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.52.4': resolution: {integrity: sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.4': resolution: {integrity: sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.52.4': resolution: {integrity: sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.4': resolution: {integrity: sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==} @@ -538,49 +530,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1125,6 +1109,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2308,6 +2295,8 @@ snapshots: picomatch@4.0.3: {} + postcss-value-parser@4.2.0: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 diff --git a/src/lush.js b/src/lush.js index ae321d5..d310269 100644 --- a/src/lush.js +++ b/src/lush.js @@ -1,3 +1,5 @@ +import valueParser from 'postcss-value-parser' + import { clamp, getValuesForBezierCurve, @@ -10,16 +12,99 @@ import { * @param {{ value: string }} decl */ export function replaceLushFunctions(decl) { - let { color, crispy, inset, offsetX, offsetY, oomph, resolution, variant } = - parseLushShadow(decl.value) + let parsed = valueParser(decl.value) + + let args = [ + 'variant', + 'inset', + 'light-x', + 'light-y', + 'oomph', + 'crispy', + 'resolution', + 'color' + ] + + let node = parsed.nodes.find( + ({ type, value }) => type === 'function' && value === '--lush-shadow' + ) + + if (node === undefined) { + return + } + + let nodes = node.nodes.filter(fnNode => fnNode.type !== 'space') + + if (nodes.length !== 7 && nodes.length !== 8) { + throw decl.error( + `'--lush-shadow(variant inset? light-x light-y oomph crispy resolution color) requires 7-8 params got ${nodes.length}`, + { index: node.startIndex } + ) + } + + /** + * @type {[string, boolean, number, number, number, number, number, string]} + */ + let [variant, inset, x, y, oomph, crispy, resolution, color] = nodes.reduce( + (previous, valueNode, index) => { + if (index === 0 && valueNode.type === 'word') { + if (['high', 'low', 'medium'].includes(valueNode.value.toLowerCase())) { + return [...previous, valueNode.value] + } + + throw decl.error( + `'--lush-shadow(variant inset? light-x light-y oomph crispy resolution color) variant to be "low", "medium" or "high" got ${valueNode.value}`, + { index: valueNode.sourceIndex } + ) + } + + if (index === 1 && valueNode.type === 'word') { + if (valueNode.value === 'inset') { + return [...previous, true] + } else if (/^-?\d+(\.\d+)?$/.test(valueNode.value)) { + return [...previous, false, Number(valueNode.value)] + } else { + throw decl.error( + `'--lush-shadow(variant inset? light-x light-y oomph crispy resolution color) ${args[index + 1]} to be number got ${valueNode.value}`, + { index: valueNode.sourceIndex } + ) + } + } + + if (index < nodes.length - 1) { + if (/^-?\d+(\.\d+)?$/.test(valueNode.value)) { + return [...previous, Number(valueNode.value)] + } else { + throw decl.error( + `'--lush-shadow(variant inset? light-x light-y oomph crispy resolution color) ${args[index]} to be number got ${valueNode.value}`, + { index: node.sourceIndex + valueNode.sourceIndex } + ) + } + } + + if (valueNode.type === 'word' && index === nodes.length - 1) { + return [...previous, valueNode.value] + } + + if (valueNode.type === 'function' && index === nodes.length - 1) { + return [ + ...previous, + decl.value.substring(valueNode.sourceIndex, valueNode.sourceEndIndex) + ] + } + + return previous + }, + [] + ) let [low, medium, high] = generateShadows({ color, crispy, inset, lightSource: { - x: offsetX, - y: offsetY + x, + y }, oomph, resolution @@ -43,98 +128,6 @@ export function replaceLushFunctions(decl) { } } -/** - * Parses --lush-shadow(variant, light-x, light-y, oomph, crispy, resolution, color) - * @param {string} value - e.g. "--lush-shadow(high 0.24 -0.21 0.52 0.76 0.75 oklch(0 0 0 / 40%))" - * @returns {Object|null} - */ -function parseLushShadow(value) { - // Strip wrapper - let content = value - .replace(/^--lush-shadow\s*\(/, '') - .replace(/\)\s*$/, '') - .trim() - - if (!content) return null - - let parts = [] - let current = '' - let parenDepth = 0 - let inColor = false - - // Tokenize respecting nested color functions/var() - for (let char of content) { - if (char === '(') { - parenDepth++ - current += char - if (parenDepth === 1 && !/^[a-zA-Z-]+$/.test(current.trim())) { - inColor = true - } - } else if (char === ')') { - parenDepth-- - current += char - if (parenDepth === 0) inColor = false - } else if (char === ' ' && parenDepth === 0 && !inColor) { - if (current.trim()) parts.push(current.trim()) - current = '' - } else { - current += char - } - } - if (current.trim()) parts.push(current.trim()) - - // Result structure – numbers are now actual numbers - let result = { - color: null, // string - crispy: null, // number | null - inset: false, - offsetX: null, // number | null - offsetY: null, // number | null - oomph: null, // number | null - resolution: null, // number | null - variant: null - } - - let i = 0 - - // 1. Variant (required, first token) - if (parts.length === 0) return null - result.variant = parts[i++] - if (!result.variant || /^[0-9.-]/.test(result.variant)) { - return null // probably missing variant name - } - - // 2. Optional inset - if (i < parts.length && parts[i] === 'inset') { - result.inset = true - i++ - } - - // 3. Numeric parameters → convert to number - let numFields = ['offsetX', 'offsetY', 'oomph', 'crispy', 'resolution'] - let numIdx = 0 - - while (i < parts.length && numIdx < numFields.length) { - let token = parts[i] - // Very permissive number check (including scientific notation) - let num = Number(token) - if (!isNaN(num) && token.trim() !== '') { - result[numFields[numIdx]] = num - numIdx++ - i++ - } else { - break - } - } - - // 4. Remaining = color (can contain spaces inside functions) - if (i < parts.length) { - result.color = parts.slice(i).join(' ') - } - - return result -} - /** * We'll generate a set of 3 shadows: small, medium, large. * Each shadow will have multiple layers, depending on the size. @@ -162,6 +155,9 @@ function generateShadows({ oomph, resolution }) { + /** + * @type {[string[], string[], string[]]} + */ let output = [] let SHADOW_LAYER_LIMITS = { From b821f670efc614c39e6e7ad697cfdeaf91e405e3 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Wed, 21 Jan 2026 14:59:54 +0200 Subject: [PATCH 3/6] add tests --- index.js | 4 - src/lush.js | 157 +++++++++++++++++++------------------- src/utils.js | 2 +- test/index.test.js | 184 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+), 83 deletions(-) diff --git a/index.js b/index.js index 4a05468..c1a4bd9 100644 --- a/index.js +++ b/index.js @@ -123,10 +123,6 @@ function replaceFunctions(decl, func) { let result = decl.value let currentIndex = 0 - if (!result.includes(searchPattern)) { - return - } - let startIndex = result.indexOf(searchPattern, currentIndex) while (startIndex !== -1) { let openParenIndex = startIndex + searchPattern.length - 1 diff --git a/src/lush.js b/src/lush.js index d310269..36454b8 100644 --- a/src/lush.js +++ b/src/lush.js @@ -12,6 +12,46 @@ import { * @param {{ value: string }} decl */ export function replaceLushFunctions(decl) { + let [variant, inset, x, y, oomph, crispy, resolution, color] = + parseLushShadow(decl) + + let [low, medium, high] = generateShadows({ + color, + crispy, + inset, + lightSource: { + x, + y + }, + oomph, + resolution + }) + + let before = + decl.raws.between && decl.raws.before ? `${decl.raws.before} ` : '' + let between = + decl.raws.between && decl.raws.before ? `,${decl.raws.before} ` : ', ' + + switch (variant) { + case 'high': + decl.value = before + high.flat().join(between) + break + case 'low': + decl.value = before + low.flat().join(between) + break + case 'medium': + decl.value = before + medium.flat().join(between) + break + } + + return decl +} + +/** + * @param {{ value: string }} decl + * @returns {[string, boolean, number, number, number, number, number, string]} + */ +export function parseLushShadow(decl) { let parsed = valueParser(decl.value) let args = [ @@ -33,7 +73,12 @@ export function replaceLushFunctions(decl) { return } - let nodes = node.nodes.filter(fnNode => fnNode.type !== 'space') + let nodes = node.nodes.filter( + fnNode => + fnNode.type !== 'space' && + fnNode.type !== 'comment' && + fnNode.type !== 'div' + ) if (nodes.length !== 7 && nodes.length !== 8) { throw decl.error( @@ -42,90 +87,48 @@ export function replaceLushFunctions(decl) { ) } - /** - * @type {[string, boolean, number, number, number, number, number, string]} - */ - let [variant, inset, x, y, oomph, crispy, resolution, color] = nodes.reduce( - (previous, valueNode, index) => { - if (index === 0 && valueNode.type === 'word') { - if (['high', 'low', 'medium'].includes(valueNode.value.toLowerCase())) { - return [...previous, valueNode.value] - } - - throw decl.error( - `'--lush-shadow(variant inset? light-x light-y oomph crispy resolution color) variant to be "low", "medium" or "high" got ${valueNode.value}`, - { index: valueNode.sourceIndex } - ) + return nodes.reduce((previous, valueNode, index) => { + if (index === 0 && valueNode.type === 'word') { + if (['high', 'low', 'medium'].includes(valueNode.value.toLowerCase())) { + return [...previous, valueNode.value] } - if (index === 1 && valueNode.type === 'word') { - if (valueNode.value === 'inset') { - return [...previous, true] - } else if (/^-?\d+(\.\d+)?$/.test(valueNode.value)) { - return [...previous, false, Number(valueNode.value)] - } else { - throw decl.error( - `'--lush-shadow(variant inset? light-x light-y oomph crispy resolution color) ${args[index + 1]} to be number got ${valueNode.value}`, - { index: valueNode.sourceIndex } - ) - } - } + throw decl.error( + `'--lush-shadow(variant inset? light-x light-y oomph crispy resolution color) variant to be "low", "medium" or "high" got ${valueNode.value}`, + { index: valueNode.sourceIndex } + ) + } - if (index < nodes.length - 1) { - if (/^-?\d+(\.\d+)?$/.test(valueNode.value)) { - return [...previous, Number(valueNode.value)] - } else { - throw decl.error( - `'--lush-shadow(variant inset? light-x light-y oomph crispy resolution color) ${args[index]} to be number got ${valueNode.value}`, - { index: node.sourceIndex + valueNode.sourceIndex } - ) - } + if (index === 1 && valueNode.type === 'word') { + if (valueNode.value === 'inset') { + return [...previous, true] + } else if (/^-?\d+(\.\d+)?$/.test(valueNode.value)) { + previous.push(false) } + } - if (valueNode.type === 'word' && index === nodes.length - 1) { - return [...previous, valueNode.value] - } + if (index < nodes.length - 1) { + if (/^-?\d+(\.\d+)?$/.test(valueNode.value)) { + return [...previous, Number(valueNode.value)] + } else { + let argIndex = previous.length + Math.abs(args.length - nodes.length) - if (valueNode.type === 'function' && index === nodes.length - 1) { - return [ - ...previous, - decl.value.substring(valueNode.sourceIndex, valueNode.sourceEndIndex) - ] + throw decl.error( + `'--lush-shadow(variant inset? light-x light-y oomph crispy resolution color) ${args[argIndex]} to be number got ${valueNode.value}`, + { index: node.sourceIndex + valueNode.sourceIndex } + ) } + } - return previous - }, - [] - ) - - let [low, medium, high] = generateShadows({ - color, - crispy, - inset, - lightSource: { - x, - y - }, - oomph, - resolution - }) - - let before = - decl.raws.between && decl.raws.before ? `${decl.raws.before} ` : '' - let between = - decl.raws.between && decl.raws.before ? `,${decl.raws.before} ` : ', ' + if (valueNode.type === 'word' && index === nodes.length - 1) { + return [...previous, valueNode.value] + } - switch (variant) { - case 'high': - decl.value = before + high.flat().join(between) - break - case 'low': - decl.value = before + low.flat().join(between) - break - case 'medium': - decl.value = before + medium.flat().join(between) - break - } + return [ + ...previous, + decl.value.substring(valueNode.sourceIndex, valueNode.sourceEndIndex) + ] + }, []) } /** @@ -385,8 +388,6 @@ function calculateBlurRadius({ crispy, x, y }) { * @returns */ function calculateSpread({ crispy, layerIndex, numOfLayers }) { - // return 0; - if (layerIndex === 0) { return 0 } diff --git a/src/utils.js b/src/utils.js index 7b2a9e8..dae937f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -77,7 +77,7 @@ export function roundTo(value, places = 0) { * endPoint: number, * startPoint: number }} points * @param {number} t - * @returns + * @returns {[number, number]} */ export function getValuesForBezierCurve( { controlPoint1, controlPoint2, endPoint, startPoint }, diff --git a/test/index.test.js b/test/index.test.js index ce72241..ed74c1b 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -6,6 +6,14 @@ import { test } from 'node:test' import postcss from 'postcss' import plugin, { renderShadows } from '../index.js' +import { parseLushShadow } from '../src/lush.js' +import { + clamp, + getValuesForBezierCurve, + normalize, + range, + roundTo +} from '../src/utils.js' // adjust path as needed const FIXTURES = join(import.meta.dirname, 'fixtures') @@ -95,3 +103,179 @@ test('throws error when currentColor is used as first argument', () => { run('a { box-shadow: --linear-shadow(currentColor 2px 10px red); }') }, /first parameter must be a length not color currentColor/) }) + +test('normalize - basic linear mapping from one range to another', () => { + equal(normalize(50, 0, 100), 0.5) + equal(normalize(0, 0, 100), 0) + equal(normalize(100, 0, 100), 1) + equal(normalize(75, 50, 100), 0.5) +}) + +test('normalize - custom output range', () => { + equal(normalize(50, 0, 100, 0, 255), 127.5) + equal(normalize(0, 0, 100, -1, 1), -1) + equal(normalize(100, 0, 100, -50, 50), 50) + equal(normalize(30, 10, 90, 200, 800), 350) +}) + +test('normalize - same input and output range', () => { + equal(normalize(42, 0, 100, 0, 100), 42) +}) + +test('clamp - standard usage (min < max)', () => { + equal(clamp(5, 0, 10), 5) + equal(clamp(-3, 0, 10), 0) + equal(clamp(15, 0, 10), 10) +}) + +test('clamp - inverted min/max (min > max)', () => { + equal(clamp(7, 10, 5), 7) // inside → no change + equal(clamp(3, 10, 5), 5) // below lower bound → becomes actual max (5) + equal(clamp(12, 10, 5), 10) // above upper bound → becomes actual min (10) + equal(clamp(5, 10, 5), 5) + equal(clamp(10, 10, 5), 10) +}) + +test('clamp - default min=0, max=1', () => { + equal(clamp(0.6), 0.6) + equal(clamp(-0.1), 0) + equal(clamp(1.3), 1) +}) + +test('range - default step=1, start from 0', () => { + deepEqual(range(5), [0, 1, 2, 3, 4]) + deepEqual(range(3), [0, 1, 2]) +}) + +test('range - start and end specified', () => { + deepEqual(range(2, 7), [2, 3, 4, 5, 6]) + deepEqual(range(-2, 3), [-2, -1, 0, 1, 2]) +}) + +test('range - with custom step', () => { + deepEqual(range(0, 10, 2), [0, 2, 4, 6, 8]) + deepEqual(range(1, 2, 0.25), [1, 1.25, 1.5, 1.75]) + deepEqual(range(10, 0, -2), []) // note: loop doesn't run backwards +}) + +test('range - empty ranges', () => { + deepEqual(range(5, 5), []) + deepEqual(range(10, 5), []) // no backwards iteration + deepEqual(range(0, 0), []) +}) + +test('roundTo - default to integer', () => { + equal(roundTo(3.7), 4) + equal(roundTo(3.2), 3) + equal(roundTo(-1.6), -2) +}) + +test('roundTo - specific decimal places', () => { + equal(roundTo(3.14159, 2), 3.14) + equal(roundTo(3.146, 2), 3.15) + equal(roundTo(1.23456, 4), 1.2346) + equal(roundTo(9.87654321, 0), 10) + equal(roundTo(42.5, -1), 40) // tens place +}) + +test('getValuesForBezierCurve - quadratic (only controlPoint1)', () => { + let points = { + controlPoint1: [50, 100], + controlPoint2: null, // or undefined + endPoint: [100, 0], + startPoint: [0, 0] + } + + deepEqual(getValuesForBezierCurve(points, 0), [0, 0]) + deepEqual(getValuesForBezierCurve(points, 1), [100, 0]) + deepEqual( + getValuesForBezierCurve(points, 0.5).map(v => Math.round(v)), + [50, 50] + ) +}) + +test('getValuesForBezierCurve - cubic (with controlPoint2)', () => { + let points = { + controlPoint1: [20, 80], + controlPoint2: [80, 20], + endPoint: [100, 100], + startPoint: [0, 0] + } + + deepEqual(getValuesForBezierCurve(points, 0), [0, 0]) + deepEqual(getValuesForBezierCurve(points, 1), [100, 100]) + + let mid = getValuesForBezierCurve(points, 0.5).map(v => Math.round(v)) + deepEqual(mid, [50, 50]) // approximate for this symmetric-ish curve +}) + +//////////////////////////////////////// +//////////////////////////////////////// +//////////////////////////////////////// +//////////////////////////////////////// + +test('correctly parses lush shadow with low variant', () => { + run( + 'a { box-shadow: --lush-shadow(low -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); }' + ) +}) + +test('correctly parses lush shadow with medium variant', () => { + run( + 'a { box-shadow: --lush-shadow(medium -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); }' + ) +}) + +test('correctly parses lush shadow with high variant', () => { + run( + 'a { box-shadow: --lush-shadow(high -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); }' + ) +}) + +test('correctly parses lush shadow with inset', () => { + run( + 'a { box-shadow: --lush-shadow(low inset -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); }' + ) +}) + +test('correctly parses hash color', () => { + run( + 'a { box-shadow: --lush-shadow(low inset -0.25 -0.5 0.5 0.5 0.75 #000000); }' + ) +}) + +test('parseLushShadow - do nothing if no declaration', () => { + equal(parseLushShadow({ value: '' }), undefined) +}) + +test('throws error for wrong parameters count if lush shadow', () => { + throws(() => { + run( + 'a { box-shadow: --lush-shadow(low inset -0.25 -0.5 oklch(0 0 0 / 15%)); }' + ) + }, /requires 7-8 params/) +}) + +test('throws error for wrong variant in lush shadow', () => { + throws(() => { + run( + 'a { box-shadow: --lush-shadow(wrong -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); }' + ) + }, /variant to be "low", "medium" or "high"/) +}) + +test('throws error when wrong value for light-x', () => { + throws(() => { + run( + 'a { box-shadow: --lush-shadow(low -0.25px -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); }' + ) + }, /light-x to be number/) +}) + +test('throws error when wrong value for light-x', () => { + throws(() => { + run( + 'a { box-shadow: --lush-shadow(low inset -0.25px -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); }' + ) + }, /light-x to be number/) +}) From 49eccba6eabe2f17c920b390c09b151f7f190583 Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Wed, 21 Jan 2026 15:12:54 +0200 Subject: [PATCH 4/6] add public api --- index.js | 4 +++- src/lush.js | 52 +++++++++++++++++++++++++--------------------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/index.js b/index.js index c1a4bd9..ca4a65c 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ -import { replaceLushFunctions } from './src/lush.js' +import { renderLushShadows, replaceLushFunctions } from './src/lush.js' function easeInQuad(x) { return x * x @@ -213,3 +213,5 @@ let plugin = () => { plugin.postcss = true export default plugin + +export { renderLushShadows } diff --git a/src/lush.js b/src/lush.js index 36454b8..62c4379 100644 --- a/src/lush.js +++ b/src/lush.js @@ -15,17 +15,15 @@ export function replaceLushFunctions(decl) { let [variant, inset, x, y, oomph, crispy, resolution, color] = parseLushShadow(decl) - let [low, medium, high] = generateShadows({ - color, - crispy, + let [low, medium, high] = renderLushShadows( inset, - lightSource: { - x, - y - }, + x, + y, oomph, - resolution - }) + crispy, + resolution, + color + ) let before = decl.raws.between && decl.raws.before ? `${decl.raws.before} ` : '' @@ -137,27 +135,24 @@ export function parseLushShadow(decl) { * A small shadow might only have 2 shadows, a large might have 6. * Though, this is affected by the `layers` property * - * @param {{ - * inset: boolean; - * color: string; - * crispy: number; - * lightSource: { - * x: number; - * y: number; - * }; - * oomph: number; - * resolution: number; - * }} props + * @param {boolean} inset + * @param {number} lightX + * @param {number} lightY + * @param {number} oomph + * @param {number} crispy + * @param {number} resolution + * @param {color} color * @returns */ -function generateShadows({ - color, - crispy, +export function renderLushShadows( inset, - lightSource, + lightX, + lightY, oomph, - resolution -}) { + crispy, + resolution, + color +) { /** * @type {[string[], string[], string[]]} */ @@ -204,7 +199,10 @@ function generateShadows({ let { x, y } = calculateShadowOffsets({ crispy, layerIndex, - lightSource, + lightSource: { + x: lightX, + y: lightY + }, numOfLayers, oomph, size From 9b1d524eb733b597be21610818d95908b6c5778c Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Wed, 21 Jan 2026 15:31:09 +0200 Subject: [PATCH 5/6] update readme --- README.md | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4d4f48d..8a8d7f1 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,6 @@ It supports non-px units like `rem`, 3 shadow types, `inset` shadows, and any co --- - ## Usage **Step 1:** Install plugin: @@ -60,7 +59,7 @@ module.exports = { ### CSS API -The plugins supports 3 shadows types. You can try them on [`smoothshadows.com`](https://smoothshadows.com). +The plugins supports 3 shadows types from [`smoothshadows.com`](https://smoothshadows.com). ```css .soft { @@ -82,6 +81,25 @@ It also supports `inset` shadows: } ``` +It supports lush shadows generation from [`joshwcomeau.com/shadow-palette`](https://www.joshwcomeau.com/shadow-palette/) + +```css +.low { + box-shadow: --lush-shadow(low -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); +} +.medium { + box-shadow: --lush-shadow(medium -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); +} +.high { + box-shadow: --lush-shadow(high -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%)); +} +.inset { + box-shadow: --lush-shadow( + medium inset -0.25 -0.5 0.5 0.5 0.75 oklch(0 0 0 / 15%) + ); +} +``` + ### JS API There is low-level JS API: @@ -92,3 +110,43 @@ import { renderShadows } from 'postcss-smooth-shadow' renderShadows('soft', false, '0', '0.5rem', '1rem', 'oklch(0 0 0 / 10%)') // => ["calc(0.111 * 0.5rem) calc(0.111 * 1rem) …", …] ``` + +API for lush shadows + +```ts +import { renderLushShadows } from 'postcss-smooth-shadow' + +const [low, medium, high] = renderLushShadows( + false, // inset + -0.25, // light-x + -0.5, // light-y + 0.5, // oomph + 0.5, // crispy + 0.75, // resolution + 'oklch(0 0 0 / 15%)' // color +) + +low = [ + ['0.3px 0.5px 0.7px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'], + ['0.4px 0.8px 1px -1.2px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'], + ['1px 2px 2.5px -2.5px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'] +] + +medium = [ + ['0.3px 0.5px 0.7px hsl(from oklch(0 0 0 / 15%) h s l / 0.11)'], + ['0.8px 1.6px 2px -0.8px hsl(from oklch(0 0 0 / 15%) h s l / 0.11)'], + ['2.1px 4.1px 5.2px -1.7px hsl(from oklch(0 0 0 / 15%) h s l / 0.11)'], + ['5px 10px 12.6px -2.5px hsl(from oklch(0 0 0 / 15%) h s l / 0.11)'] +] + +high = [ + ['0.3px 0.5px 0.7px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'], + ['1.5px 2.9px 3.7px -0.4px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'], + ['2.7px 5.4px 6.8px -0.7px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'], + ['4.5px 8.9px 11.2px -1.1px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'], + ['7.1px 14.3px 18px -1.4px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'], + ['11.2px 22.3px 28.1px -1.8px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'], + ['17px 33.9px 42.7px -2.1px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'], + ['25px 50px 62.9px -2.5px hsl(from oklch(0 0 0 / 15%) h s l / 0.1)'] +] +``` From 5140eb9e3190ab20eea1e8d506cf07ba215b92be Mon Sep 17 00:00:00 2001 From: Oleg Baltag Date: Wed, 21 Jan 2026 15:34:55 +0200 Subject: [PATCH 6/6] update types --- index.d.ts | 12 +++++++++++- src/lush.js | 7 ++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/index.d.ts b/index.d.ts index 0595337..c0b398a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,7 +1,7 @@ import type { PluginCreator, AtRule } from 'postcss' export function renderShadows( - type: 'linear' | 'sharp' | 'soft' | 'lush', + type: 'linear' | 'sharp' | 'soft', inset: boolean, x: number, y: number, @@ -9,6 +9,16 @@ export function renderShadows( color: string ): string +export function renderLushShadows( + inset: boolean, + lightX: number, + lightY: number, + oomph: number, + crispy: number, + resolution: number, + color: string +): [string[], string[], string[]] + declare const plugin: PluginCreator<{}> export default plugin diff --git a/src/lush.js b/src/lush.js index 62c4379..1b59c84 100644 --- a/src/lush.js +++ b/src/lush.js @@ -141,8 +141,8 @@ export function parseLushShadow(decl) { * @param {number} oomph * @param {number} crispy * @param {number} resolution - * @param {color} color - * @returns + * @param {string} color + * @returns {[string[], string[], string[]]} */ export function renderLushShadows( inset, @@ -153,9 +153,6 @@ export function renderLushShadows( resolution, color ) { - /** - * @type {[string[], string[], string[]]} - */ let output = [] let SHADOW_LAYER_LIMITS = {