@thejaredwilcurt/csslop 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,500 @@
1
+ /**
2
+ * @file Evaluates CSS color-mix() expressions and normalizes them into compact color values.
3
+ */
4
+
5
+ import {
6
+ oklabToRgb,
7
+ parseColor,
8
+ rgbToOklab,
9
+ rgbToOklch,
10
+ rgbaToHex
11
+ } from './colors.js';
12
+ import { roundCompactNumber } from './shared.js';
13
+
14
+ /**
15
+ * Interpolates between two hue angles along the shorter arc, per the CSS Color specification.
16
+ *
17
+ * @param {number} h1 The first hue angle in degrees.
18
+ * @param {number} h2 The second hue angle in degrees.
19
+ * @param {number} t The interpolation factor from 0 to 1.
20
+ * @return {number} The interpolated hue angle in degrees, normalized to 0–360.
21
+ */
22
+ function interpolateHueShorter (h1, h2, t) {
23
+ let diff = h2 - h1;
24
+ if (diff > 180) {
25
+ diff -= 360;
26
+ }
27
+ if (diff < -180) {
28
+ diff += 360;
29
+ }
30
+ let result = h1 + diff * t;
31
+ return ((result % 360) + 360) % 360;
32
+ }
33
+
34
+ /**
35
+ * Format an OKLCH result as a minified string.
36
+ *
37
+ * @param {number} L The lightness component.
38
+ * @param {number} C The chroma component.
39
+ * @param {number} H The hue angle in degrees.
40
+ * @param {number|undefined} alpha The alpha value from 0 to 1, or undefined for fully opaque.
41
+ * @return {string} A minified oklch() function string.
42
+ */
43
+ function formatOklch (L, C, H, alpha) {
44
+ const fmtL = roundCompactNumber(L, 3);
45
+ const fmtC = roundCompactNumber(C, 3);
46
+ const fmtH = roundCompactNumber(H, 1);
47
+ if (alpha !== undefined && alpha < 1) {
48
+ return 'oklch(' + fmtL + ' ' + fmtC + ' ' + fmtH + '/' + roundCompactNumber(alpha, 3) + ')';
49
+ }
50
+ return 'oklch(' + fmtL + ' ' + fmtC + ' ' + fmtH + ')';
51
+ }
52
+
53
+ /**
54
+ * Detect which channel indices have 'none' in a raw color function string.
55
+ *
56
+ * @param {string} rawColorStr The raw CSS color function string (e.g. "rgb(none 0 0)").
57
+ * @return {Array} An array of zero-based channel indices where 'none' was found.
58
+ */
59
+ function findNoneChannels (rawColorStr) {
60
+ const indices = [];
61
+ // Match rgb/rgba/hsl/hsla/hwb function calls and extract their arguments
62
+ const functionMatch = rawColorStr.match(/\b(?:rgba?|hsla?|hwb)\(([^)]*)\)/i);
63
+ if (functionMatch) {
64
+ // Split arguments on whitespace, commas, or slash separators
65
+ const parts = functionMatch[1].trim().split(/[\s,/]+/).map((part) => {
66
+ return part.trim();
67
+ }).filter((part) => {
68
+ return part.length > 0;
69
+ });
70
+ parts.forEach((part, index) => {
71
+ if (part.toLowerCase() === 'none') {
72
+ indices.push(index);
73
+ }
74
+ });
75
+ }
76
+ return indices;
77
+ }
78
+
79
+ /**
80
+ * Evaluate an N-color (3+) color-mix() expression. Returns a minified CSS color string or null.
81
+ *
82
+ * @param {string} colorSpace The interpolation color space ("srgb", "oklab", or "oklch").
83
+ * @param {Array} args The raw argument strings for each color.
84
+ * @return {string|null} A minified CSS color string, or null if the expression cannot be evaluated.
85
+ */
86
+ function evaluateNColorMix (colorSpace, args) {
87
+ const parsedArgs = [];
88
+ for (const arg of args) {
89
+ const parsed = parseColorMixArg(arg.trim());
90
+ if (!parsed) {
91
+ return null;
92
+ }
93
+ parsedArgs.push(parsed);
94
+ }
95
+
96
+ // If any color is unresolvable (var(), currentcolor), whitespace-strip only
97
+ if (parsedArgs.some((parsedArg) => {
98
+ return !parsedArg.color;
99
+ })) {
100
+ return normalizeUnresolvableNColorMix(colorSpace, parsedArgs);
101
+ }
102
+
103
+ const percentages = normalizeNColorPercentages(parsedArgs);
104
+ const percentageSum = percentages.reduce((sum, value) => {
105
+ return sum + value;
106
+ }, 0);
107
+
108
+ // All-zero percentages → transparent black
109
+ if (percentageSum === 0) {
110
+ return rgbaToHex(0, 0, 0, 0);
111
+ }
112
+
113
+ let alphaMultiplier = 1;
114
+ if (percentageSum < 100) {
115
+ alphaMultiplier = percentageSum / 100;
116
+ } else if (percentageSum > 100) {
117
+ for (let i = 0; i < percentages.length; i++) {
118
+ percentages[i] = percentages[i] / percentageSum * 100;
119
+ }
120
+ }
121
+
122
+ // Compute weights
123
+ const totalPercentage = percentages.reduce((sum, value) => {
124
+ return sum + value;
125
+ }, 0);
126
+ const weights = percentages.map((value) => {
127
+ return value / totalPercentage;
128
+ });
129
+ const colors = parsedArgs.map((parsedArg) => {
130
+ return parsedArg.color;
131
+ });
132
+
133
+ if (colorSpace === 'srgb') {
134
+ return mixNColorsSrgb(colors, weights, alphaMultiplier);
135
+ }
136
+
137
+ if (colorSpace === 'oklab') {
138
+ return mixNColorsOklab(colors, weights, alphaMultiplier);
139
+ }
140
+
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * Normalize percentages for an N-color color-mix() expression.
146
+ * When no percentages are specified, each color gets an equal share of 100%.
147
+ * When some are unspecified, the remaining percentage is split equally among them.
148
+ *
149
+ * @param {Array} parsedArgs The parsed color-mix arguments.
150
+ * @return {Array} An array of normalized percentage values.
151
+ */
152
+ function normalizeNColorPercentages (parsedArgs) {
153
+ const percentages = parsedArgs.map((parsedArg) => {
154
+ return parsedArg.percentage;
155
+ });
156
+ if (percentages.every((value) => {
157
+ return value === null;
158
+ })) {
159
+ const equalWeight = 100 / parsedArgs.length;
160
+ return parsedArgs.map(() => {
161
+ return equalWeight;
162
+ });
163
+ }
164
+ const specifiedSum = percentages.reduce((sum, value) => {
165
+ return sum + (value !== null ? value : 0);
166
+ }, 0);
167
+ const unspecifiedCount = percentages.filter((value) => {
168
+ return value === null;
169
+ }).length;
170
+ if (unspecifiedCount > 0) {
171
+ const remaining = Math.max(0, 100 - specifiedSum);
172
+ const percentagePerUnspecified = remaining / unspecifiedCount;
173
+ return percentages.map((value) => {
174
+ return value !== null ? value : percentagePerUnspecified;
175
+ });
176
+ }
177
+ return percentages;
178
+ }
179
+
180
+ /**
181
+ * Build a whitespace-stripped color-mix() string for an unresolvable N-color expression.
182
+ *
183
+ * @param {string} colorSpace The interpolation color space.
184
+ * @param {Array} parsedArgs The parsed color-mix arguments.
185
+ * @return {string} A whitespace-stripped color-mix() expression.
186
+ */
187
+ function normalizeUnresolvableNColorMix (colorSpace, parsedArgs) {
188
+ const parts = parsedArgs.map((parsedArg) => {
189
+ const rawColor = parsedArg.raw.trim();
190
+ const percentageString = parsedArg.percentage !== null ? ' ' + parsedArg.percentage + '%' : '';
191
+ return rawColor + percentageString;
192
+ });
193
+ return 'color-mix(in ' + colorSpace + ',' + parts.join(',') + ')';
194
+ }
195
+
196
+ /**
197
+ * Mix N colors in the sRGB color space using weighted averages.
198
+ *
199
+ * @param {Array} colors Array of [r, g, b, a] color arrays.
200
+ * @param {Array} weights Array of weight values for each color.
201
+ * @param {number} alphaMultiplier Multiplier for the final alpha channel.
202
+ * @return {string} A hex color string.
203
+ */
204
+ function mixNColorsSrgb (colors, weights, alphaMultiplier) {
205
+ let r = 0;
206
+ let g = 0;
207
+ let b = 0;
208
+ let alpha = 0;
209
+ for (let i = 0; i < colors.length; i++) {
210
+ r += colors[i][0] * weights[i];
211
+ g += colors[i][1] * weights[i];
212
+ b += colors[i][2] * weights[i];
213
+ alpha += colors[i][3] * weights[i];
214
+ }
215
+ return rgbaToHex(Math.round(r), Math.round(g), Math.round(b), alpha * alphaMultiplier);
216
+ }
217
+
218
+ /**
219
+ * Mix N colors in the OKLab color space using weighted averages.
220
+ *
221
+ * @param {Array} colors Array of [r, g, b, a] color arrays.
222
+ * @param {Array} weights Array of weight values for each color.
223
+ * @param {number} alphaMultiplier Multiplier for the final alpha channel.
224
+ * @return {string} A hex color string.
225
+ */
226
+ function mixNColorsOklab (colors, weights, alphaMultiplier) {
227
+ const oklabValues = colors.map((color) => {
228
+ return rgbToOklab(color[0], color[1], color[2]);
229
+ });
230
+ let L = 0;
231
+ let a = 0;
232
+ let b = 0;
233
+ let alpha = 0;
234
+ for (let i = 0; i < oklabValues.length; i++) {
235
+ L += oklabValues[i].L * weights[i];
236
+ a += oklabValues[i].a * weights[i];
237
+ b += oklabValues[i].b * weights[i];
238
+ alpha += colors[i][3] * weights[i];
239
+ }
240
+ alpha *= alphaMultiplier;
241
+ const rgb = oklabToRgb(L, a, b);
242
+ return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha >= 1 ? 1 : alpha);
243
+ }
244
+
245
+ /**
246
+ * Evaluate a color-mix() expression. Returns a minified CSS color string or null.
247
+ *
248
+ * @param {string} expr The full color-mix() expression string.
249
+ * @return {string|null} A minified CSS color string, or null if the expression cannot be evaluated.
250
+ */
251
+ function evaluateColorMix (expr) {
252
+ // Parse: color-mix(in <space> [<hue-method>], <color> [<p>%], <color> [<p>%])
253
+ // We need to handle nested parentheses for inner color functions
254
+ const inner = extractBalancedArgs(expr, 'color-mix');
255
+ if (!inner) {
256
+ return null;
257
+ }
258
+
259
+ // Parse the interpolation method
260
+ const inMatch = inner.match(/^in\s+(srgb|oklch|oklab)(?:\s+shorter\s+hue)?\s*,\s*/i);
261
+ if (!inMatch) {
262
+ return null;
263
+ }
264
+
265
+ const colorSpace = inMatch[1].toLowerCase();
266
+ const rest = inner.slice(inMatch[0].length);
267
+
268
+ // Split color arguments (handling nested parens)
269
+ const args = splitColorMixArgs(rest);
270
+ if (args.length < 2) {
271
+ return null;
272
+ }
273
+
274
+ // N-color path (3+ colors)
275
+ if (args.length > 2) {
276
+ return evaluateNColorMix(colorSpace, args);
277
+ }
278
+
279
+ // Parse each argument: "<color> [<percentage>]"
280
+ const parsed1 = parseColorMixArg(args[0].trim());
281
+ const parsed2 = parseColorMixArg(args[1].trim());
282
+ if (!parsed1 || !parsed2) {
283
+ return null;
284
+ }
285
+
286
+ // Check for unresolvable colors (var(), currentcolor, etc.)
287
+ if (!parsed1.color || !parsed2.color) {
288
+ // Can still do normalization but not computation
289
+ return normalizeColorMix(colorSpace, parsed1, parsed2);
290
+ }
291
+
292
+ // Normalize percentages per CSS spec
293
+ let p1 = parsed1.percentage;
294
+ let p2 = parsed2.percentage;
295
+
296
+ if (p1 === null && p2 === null) {
297
+ p1 = 50;
298
+ p2 = 50;
299
+ } else if (p1 === null) {
300
+ p1 = 100 - p2;
301
+ } else if (p2 === null) {
302
+ p2 = 100 - p1;
303
+ }
304
+
305
+ let alphaMultiplier = 1;
306
+ const pSum = p1 + p2;
307
+ if (pSum === 0) {
308
+ return null;
309
+ }
310
+
311
+ if (pSum < 100) {
312
+ alphaMultiplier = pSum / 100;
313
+ } else if (pSum > 100) {
314
+ p1 = p1 / pSum * 100;
315
+ p2 = p2 / pSum * 100;
316
+ }
317
+
318
+ // Trivial cases
319
+ if (p1 === 0) {
320
+ return rgbaToHex(parsed2.color[0], parsed2.color[1], parsed2.color[2], parsed2.color[3]);
321
+ }
322
+ if (p2 === 0) {
323
+ return rgbaToHex(parsed1.color[0], parsed1.color[1], parsed1.color[2], parsed1.color[3]);
324
+ }
325
+
326
+ // CSS spec: 'none' channels are missing — fill from the other color before mixing
327
+ const nones1 = findNoneChannels(parsed1.raw);
328
+ const nones2 = findNoneChannels(parsed2.raw);
329
+ for (const idx of nones1) {
330
+ if (idx < parsed1.color.length) {
331
+ parsed1.color[idx] = parsed2.color[idx];
332
+ }
333
+ }
334
+ for (const idx of nones2) {
335
+ if (idx < parsed2.color.length) {
336
+ parsed2.color[idx] = parsed1.color[idx];
337
+ }
338
+ }
339
+
340
+ const t1 = p1 / (p1 + p2);
341
+ const t2 = p2 / (p1 + p2);
342
+ const [r1, g1, b1, a1] = parsed1.color;
343
+ const [r2, g2, b2, a2] = parsed2.color;
344
+
345
+ if (colorSpace === 'srgb') {
346
+ const r = Math.round(r1 * t1 + r2 * t2);
347
+ const g = Math.round(g1 * t1 + g2 * t2);
348
+ const b = Math.round(b1 * t1 + b2 * t2);
349
+ const a = (a1 * t1 + a2 * t2) * alphaMultiplier;
350
+ return rgbaToHex(r, g, b, a);
351
+ }
352
+
353
+ if (colorSpace === 'oklab') {
354
+ const lab1 = rgbToOklab(r1, g1, b1);
355
+ const lab2 = rgbToOklab(r2, g2, b2);
356
+ const L = lab1.L * t1 + lab2.L * t2;
357
+ const a = lab1.a * t1 + lab2.a * t2;
358
+ const b = lab1.b * t1 + lab2.b * t2;
359
+ const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
360
+ // Check if result fits in sRGB gamut
361
+ const rgb = oklabToRgb(L, a, b);
362
+ if (alpha >= 1) {
363
+ return rgbaToHex(rgb[0], rgb[1], rgb[2], 1);
364
+ }
365
+ return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha);
366
+ }
367
+
368
+ if (colorSpace === 'oklch') {
369
+ const lch1 = rgbToOklch(r1, g1, b1);
370
+ const lch2 = rgbToOklch(r2, g2, b2);
371
+ const L = lch1.L * t1 + lch2.L * t2;
372
+ const C = lch1.C * t1 + lch2.C * t2;
373
+ const H = interpolateHueShorter(lch1.H, lch2.H, t2);
374
+ const alpha = (a1 * t1 + a2 * t2) * alphaMultiplier;
375
+ return formatOklch(L, C, H, alpha);
376
+ }
377
+
378
+ return null;
379
+ }
380
+
381
+ /**
382
+ * Extract the balanced content inside a function call.
383
+ *
384
+ * @param {string} expr The expression string containing the function call.
385
+ * @param {string} funcName The function name to locate (e.g. "color-mix").
386
+ * @return {string|null} The content between the matching parentheses, or null if not found.
387
+ */
388
+ function extractBalancedArgs (expr, funcName) {
389
+ const prefix = funcName + '(';
390
+ const start = expr.indexOf(prefix);
391
+ if (start === -1) {
392
+ return null;
393
+ }
394
+ let depth = 1;
395
+ let position = start + prefix.length;
396
+ while (position < expr.length && depth > 0) {
397
+ if (expr[position] === '(') {
398
+ depth++;
399
+ } else if (expr[position] === ')') {
400
+ depth--;
401
+ }
402
+ position++;
403
+ }
404
+ return expr.slice(start + prefix.length, position - 1);
405
+ }
406
+
407
+ /**
408
+ * Split color-mix arguments at top-level commas (handling nested parens).
409
+ *
410
+ * @param {string} str The color arguments string, with arguments separated by commas.
411
+ * @return {Array} An array of argument strings split at each top-level comma.
412
+ */
413
+ function splitColorMixArgs (str) {
414
+ const args = [];
415
+ let depth = 0;
416
+ let start = 0;
417
+ for (let position = 0; position < str.length; position++) {
418
+ if (str[position] === '(') {
419
+ depth++;
420
+ } else if (str[position] === ')') {
421
+ depth--;
422
+ } else if (str[position] === ',' && depth === 0) {
423
+ args.push(str.slice(start, position));
424
+ start = position + 1;
425
+ }
426
+ }
427
+ args.push(str.slice(start));
428
+ return args;
429
+ }
430
+
431
+ /**
432
+ * Parse a single color-mix argument: "<color> [<percentage>]" or "<percentage> <color>".
433
+ *
434
+ * @param {string} arg The color-mix argument string to parse.
435
+ * @return {object|null} An object with color (Array or null), percentage (number or null), raw (string), and hasVar (boolean), or null if unparseable.
436
+ */
437
+ function parseColorMixArg (arg) {
438
+ arg = arg.trim();
439
+
440
+ // Try: percentage at end, e.g. "red 50%" or "rgb(0 0 0)50%"
441
+ let match = arg.match(/^(.+?)\s*(\d+(?:\.\d+)?)%\s*$/);
442
+ if (match) {
443
+ const colorStr = match[1].trim();
444
+ const percentage = parseFloat(match[2]);
445
+ const color = parseColor(colorStr);
446
+ // Check if color contains var() or currentcolor (cannot be evaluated statically)
447
+ return { color, percentage, raw: colorStr, hasVar: /var\(|currentcolor/i.test(colorStr) };
448
+ }
449
+
450
+ // Try: percentage at start, e.g. "50% red"
451
+ match = arg.match(/^(\d+(?:\.\d+)?)%\s+(.+)$/);
452
+ if (match) {
453
+ const colorStr = match[2].trim();
454
+ const percentage = parseFloat(match[1]);
455
+ const color = parseColor(colorStr);
456
+ // Check if color contains var() or currentcolor (cannot be evaluated statically)
457
+ return { color, percentage, raw: colorStr, hasVar: /var\(|currentcolor/i.test(colorStr) };
458
+ }
459
+
460
+ // No percentage
461
+ const color = parseColor(arg);
462
+ // Check if color contains var() or currentcolor (cannot be evaluated statically)
463
+ return { color, percentage: null, raw: arg, hasVar: /var\(|currentcolor/i.test(arg) };
464
+ }
465
+
466
+ /**
467
+ * Normalize a color-mix expression when we can't fully compute it.
468
+ *
469
+ * @param {string} colorSpace The interpolation color space ("srgb", "oklab", or "oklch").
470
+ * @param {object} parsed1 The parsed first color argument with color, percentage, and raw fields.
471
+ * @param {object} parsed2 The parsed second color argument with color, percentage, and raw fields.
472
+ * @return {string} A normalized color-mix() expression with default percentages and color space elided.
473
+ */
474
+ function normalizeColorMix (colorSpace, parsed1, parsed2) {
475
+ // Normalize percentages: strip explicit 50%/50% (the defaults)
476
+ let p1Str = '';
477
+ let p2Str = '';
478
+ if (parsed1.percentage !== null && parsed1.percentage !== 50) {
479
+ p1Str = ' ' + parsed1.percentage + '%';
480
+ }
481
+ if (parsed2.percentage !== null && parsed2.percentage !== 50) {
482
+ p2Str = ' ' + parsed2.percentage + '%';
483
+ }
484
+
485
+ // Use the raw color strings (but try to minify known colors)
486
+ let c1 = parsed1.raw;
487
+ let c2 = parsed2.raw;
488
+ if (parsed1.color) {
489
+ c1 = rgbaToHex(parsed1.color[0], parsed1.color[1], parsed1.color[2], parsed1.color[3]);
490
+ }
491
+ if (parsed2.color) {
492
+ c2 = rgbaToHex(parsed2.color[0], parsed2.color[1], parsed2.color[2], parsed2.color[3]);
493
+ }
494
+
495
+ // oklab is the default interpolation method per CSS Color 5 — elide it
496
+ const spacePrefix = colorSpace === 'oklab' ? '' : 'in ' + colorSpace + ',';
497
+ return 'color-mix(' + spacePrefix + c1 + p1Str + ',' + c2 + p2Str + ')';
498
+ }
499
+
500
+ export { evaluateColorMix };