@thejaredwilcurt/csslop 0.0.1 → 0.0.3

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.
package/README.md CHANGED
@@ -1,12 +1,17 @@
1
1
  # CSSLOP
2
2
 
3
- Experimental vibe-coded CSS minification library
3
+
4
+ ## NOT FOR PRODUCTION USE
5
+
6
+
7
+ ### Experimental, vibe-coded, CSS minification library
8
+
4
9
 
5
10
  **The experiment:**
6
11
 
7
12
  * Use the tests from the open source, 3rd-party, CSS minification auditing library `css-minify-tests`.
8
13
  * Have AI completely generate the library logic to pass all tests.
9
- * Have me (an experience library author) validate the outcomes and make upstream corrections to the tests library.
14
+ * Have me (an experienced library author) validate the outcomes and make upstream corrections to the tests library.
10
15
 
11
16
  **The Results:**
12
17
 
@@ -22,18 +27,22 @@ Experimental vibe-coded CSS minification library
22
27
  * Gemini 3.1 Pro (High Thinking)
23
28
  * GPT-5.4 High (Thinking)
24
29
 
25
- These tools were prompted to pass the tests based in the `/copiedTests` folder that come from `keithamus/css-minify-tests`.
30
+ These tools were prompted to pass the tests in the `/copiedTests` folder that came from `keithamus/css-minify-tests`.
26
31
 
27
32
  **Summary of project phases:**
28
33
 
29
34
  1. **Human setup:** I set up the repo like I would any of my libraries. with entry points and folder structure. Created a script to pull in all the tests to the `copiedTests` folder. Wrote a simple `/tests` file to loop over and run all the copied tests, then output which sections were failing so I could track as the AI's tried to get more tests to pass.
30
35
  1. **Human code:** I tried out several Node.js-based CSS parsers until I found the most up-to-date one I could. I then used it to solve one very simple test to lay the groundwork for the broad direction of input/parse/transform/output. Ran the tests, and 12% were passing. Commit.
31
- 1. **AI Broad strokes:** I had several AI's attempt to write the entire library in one-go, telling it to make all the tests pass. Both Claudes and Gemini ran for over an hour before giving up with no file changes. GPT-5.4 gave up after a while, but did manage to make some progress, doubling the passing tests to 24%.
36
+ 1. **AI Broad strokes:** I had each of the 4 AI's attempt to write the entire library in one-go, telling it to make all the tests pass. Both Claudes and Gemini ran for over an hour before giving up with no file changes. GPT-5.4 gave up after a while, but did manage to make some progress, doubling the passing tests to 24%.
32
37
  1. **AI Test groups:** At this point, it was clear, that they couldn't handle this big of a task. To be fair, as a human, it would probably have taken me 4-6 months to do the work I was asking it to do in one prompt, and I wouldn't have done all that work in a single commit. So at this point, I began the process of asking the AI's to fix all the tests in a specific folder (escaping, comments, keyframes, merging, etc.). The tests were already organized by category, so having them focus on just one similarly related concept was much easier for the AIs. Because there were 29 folders, I cycled through the AI's, letting them each fix a test group. Some of the folders, like "values", with 70 tests, and 55 of them failing, were still too big for Claude to handle, and I had to go back to Gemini or GPT for these. And even then, sometimes they couldn't solve all of them, but would make progress before I had to turn it over to a different AI to continue onward with the remaining test fixes in that folder.
33
38
  1. **Human test corrections:** While the AI's were trying to write code to pass the tests, I was investigating the tests themselves and fixing issues with them upstream. Some tests had incorrect assumptions and needed re-written or improved. Some were missing edge cases that a human developer would likely cover during implementation, but these AI's were absolutely skipping doing any work they didn't have to in order to get the tests to pass. Fortunately, the maintainer of the tests repo was always very quick to respond and merge these PRs. Cool dude.
34
39
  1. **AI organization:** After all tests were finally passing (took several days of babysitting). I had GPT-5.4 re-organize the code. It did fine, I'd give it a C-. But huge improvement over having the entire library in one ~2000 line file. I went with GPT because of the 4 I tested, it seems to be the only one capable of handling large, complex tasks in one-go. Even if the output is mediocre, at least it doesn't give up halfway through.
35
- 1. **AI Bug fixes:** I gave each AI the same prompt to clean up bugs/hacks/TODOs/hardcoded values. Details are below. GPT and Claude sonnet were fine. Claude Opus was a mixed bag. Gemini was a complete disaster.
36
- 1. **Human Linting:** At this point, all the tests are passing, the code is somewhat organized, and mostly bug free. But it all looks like it was written by a toddler, time to apply my extremely strict linting rules.
40
+ 1. **AI Bug fixes:** I gave each AI the same prompt to clean up bugs/hacks/TODOs/hardcoded values. Details are below.
41
+ * GPT got the low hanging fruit, a lot of stuff, but all the easy things.
42
+ * Then Claude Sonnet got a few of the medium difficulty ones
43
+ * Claude Opus was a mixed bag, doing the hardest ones, but also making poor assumptions and bad mistakes.
44
+ * Gemini was a complete disaster, 100% of it's changes were discarded.
45
+ 1. **Human Linting:** At this point, all the tests were passing, the code is somewhat organized, and mostly bug free. But it all looks like it was written by a toddler, time to apply my extremely strict linting rules.
37
46
  1. **AI Readability improvements:** I had Claude Opus try to make the code easier to read (JSDoc comments, no single character variable names, no abbreviations in variables, grouping logic into related functions, breaking up lines of code, explain complex regex, etc.).
38
47
  1. **AI color completeness:** Had Claude Opus handle all named colors, and always convert to the shorter character representation, removing a hard-coded solution.
39
48
  1. **Test improvements:** Throughout this process, as upstream tests were improved or created, they were pulled in, and the AI was instructed to pass those new tests with prompts like, "Run `npm t` and fix all failing tests by modifying files in `src`."
@@ -202,7 +211,7 @@ Two different cases:
202
211
  1. `git add -A && git commit -m "Updated tests"`
203
212
  1. Then run `npm t` to see if any tests fail
204
213
  1. If they fail, give an AI this prompt:
205
- * **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`.
214
+ * **PROMPT:** Run `npm t` and fix all failing tests by modifying files in `src`. Do not use naive solutions, hacks, or hard coded values. Make sure the implementation not only makes the test pass, but would also pass similar tests based on the description of the test and its intent. Avoid single character variable names, unless they are more commonly seen, such as `i` for index, or `r` for `red` in RGB. Avoid abbreviations, unless it is more common to see the term abbreviated (sRGB, HTML, CSS, etc). Group related logic into well named functions. Ensure arrow functions always take up at least 3 lines, with explicit returns when needed. Always comment regex if used. Run `npm run lint` and ensure the linter passes when done.
206
215
  1. Verify only code in the `src` folder was modified
207
216
  1. Verify `npm t` passes with a 100% score
208
217
  1. Run `npm run lint`, if anything fails, have the AI fix it.
package/package.json CHANGED
@@ -2,12 +2,13 @@
2
2
  "name": "@thejaredwilcurt/csslop",
3
3
  "main": "index.js",
4
4
  "type": "module",
5
- "version": "0.0.1",
5
+ "version": "0.0.3",
6
6
  "description": "Experimental CSS minification",
7
7
  "scripts": {
8
8
  "copy": "node ./scripts/copyTests.js",
9
9
  "test": "node ./tests/css/index.test.js",
10
10
  "lint": "eslint *.js scripts src tests --fix",
11
+ "bump": "npx --yes -- @jsdevtools/version-bump-prompt && npm i",
11
12
  "publish": "npm publish --access=public"
12
13
  },
13
14
  "dependencies": {
@@ -18,7 +19,7 @@
18
19
  "@eslint/js": "^10.0.1",
19
20
  "@stylistic/eslint-plugin": "^5.10.0",
20
21
  "css-minify-tests": "github:keithamus/css-minify-tests",
21
- "eslint": "^10.4.0",
22
+ "eslint": "^10.4.1",
22
23
  "eslint-config-tjw-base": "^5.0.0",
23
24
  "eslint-config-tjw-import-x": "^1.0.1",
24
25
  "eslint-config-tjw-jsdoc": "^2.0.1",
@@ -472,6 +472,172 @@ function findNoneChannels (rawColorStr) {
472
472
  return indices;
473
473
  }
474
474
 
475
+ /**
476
+ * Evaluate an N-color (3+) color-mix() expression. Returns a minified CSS color string or null.
477
+ *
478
+ * @param {string} colorSpace The interpolation color space ("srgb", "oklab", or "oklch").
479
+ * @param {Array} args The raw argument strings for each color.
480
+ * @return {string|null} A minified CSS color string, or null if the expression cannot be evaluated.
481
+ */
482
+ function evaluateNColorMix (colorSpace, args) {
483
+ const parsedArgs = [];
484
+ for (const arg of args) {
485
+ const parsed = parseColorMixArg(arg.trim());
486
+ if (!parsed) {
487
+ return null;
488
+ }
489
+ parsedArgs.push(parsed);
490
+ }
491
+
492
+ // If any color is unresolvable (var(), currentcolor), whitespace-strip only
493
+ if (parsedArgs.some((parsedArg) => {
494
+ return !parsedArg.color;
495
+ })) {
496
+ return normalizeUnresolvableNColorMix(colorSpace, parsedArgs);
497
+ }
498
+
499
+ const percentages = normalizeNColorPercentages(parsedArgs);
500
+ const percentageSum = percentages.reduce((sum, value) => {
501
+ return sum + value;
502
+ }, 0);
503
+
504
+ // All-zero percentages → transparent black
505
+ if (percentageSum === 0) {
506
+ return rgbaToHex(0, 0, 0, 0);
507
+ }
508
+
509
+ let alphaMultiplier = 1;
510
+ if (percentageSum < 100) {
511
+ alphaMultiplier = percentageSum / 100;
512
+ } else if (percentageSum > 100) {
513
+ for (let i = 0; i < percentages.length; i++) {
514
+ percentages[i] = percentages[i] / percentageSum * 100;
515
+ }
516
+ }
517
+
518
+ // Compute weights
519
+ const totalPercentage = percentages.reduce((sum, value) => {
520
+ return sum + value;
521
+ }, 0);
522
+ const weights = percentages.map((value) => {
523
+ return value / totalPercentage;
524
+ });
525
+ const colors = parsedArgs.map((parsedArg) => {
526
+ return parsedArg.color;
527
+ });
528
+
529
+ if (colorSpace === 'srgb') {
530
+ return mixNColorsSrgb(colors, weights, alphaMultiplier);
531
+ }
532
+
533
+ if (colorSpace === 'oklab') {
534
+ return mixNColorsOklab(colors, weights, alphaMultiplier);
535
+ }
536
+
537
+ return null;
538
+ }
539
+
540
+ /**
541
+ * Normalize percentages for an N-color color-mix() expression.
542
+ * When no percentages are specified, each color gets an equal share of 100%.
543
+ * When some are unspecified, the remaining percentage is split equally among them.
544
+ *
545
+ * @param {Array} parsedArgs The parsed color-mix arguments.
546
+ * @return {Array} An array of normalized percentage values.
547
+ */
548
+ function normalizeNColorPercentages (parsedArgs) {
549
+ const percentages = parsedArgs.map((parsedArg) => {
550
+ return parsedArg.percentage;
551
+ });
552
+ if (percentages.every((value) => {
553
+ return value === null;
554
+ })) {
555
+ const equalWeight = 100 / parsedArgs.length;
556
+ return parsedArgs.map(() => {
557
+ return equalWeight;
558
+ });
559
+ }
560
+ const specifiedSum = percentages.reduce((sum, value) => {
561
+ return sum + (value !== null ? value : 0);
562
+ }, 0);
563
+ const unspecifiedCount = percentages.filter((value) => {
564
+ return value === null;
565
+ }).length;
566
+ if (unspecifiedCount > 0) {
567
+ const remaining = Math.max(0, 100 - specifiedSum);
568
+ const percentagePerUnspecified = remaining / unspecifiedCount;
569
+ return percentages.map((value) => {
570
+ return value !== null ? value : percentagePerUnspecified;
571
+ });
572
+ }
573
+ return percentages;
574
+ }
575
+
576
+ /**
577
+ * Build a whitespace-stripped color-mix() string for an unresolvable N-color expression.
578
+ *
579
+ * @param {string} colorSpace The interpolation color space.
580
+ * @param {Array} parsedArgs The parsed color-mix arguments.
581
+ * @return {string} A whitespace-stripped color-mix() expression.
582
+ */
583
+ function normalizeUnresolvableNColorMix (colorSpace, parsedArgs) {
584
+ const parts = parsedArgs.map((parsedArg) => {
585
+ const rawColor = parsedArg.raw.trim();
586
+ const percentageString = parsedArg.percentage !== null ? ' ' + parsedArg.percentage + '%' : '';
587
+ return rawColor + percentageString;
588
+ });
589
+ return 'color-mix(in ' + colorSpace + ',' + parts.join(',') + ')';
590
+ }
591
+
592
+ /**
593
+ * Mix N colors in the sRGB color space using weighted averages.
594
+ *
595
+ * @param {Array} colors Array of [r, g, b, a] color arrays.
596
+ * @param {Array} weights Array of weight values for each color.
597
+ * @param {number} alphaMultiplier Multiplier for the final alpha channel.
598
+ * @return {string} A hex color string.
599
+ */
600
+ function mixNColorsSrgb (colors, weights, alphaMultiplier) {
601
+ let r = 0;
602
+ let g = 0;
603
+ let b = 0;
604
+ let alpha = 0;
605
+ for (let i = 0; i < colors.length; i++) {
606
+ r += colors[i][0] * weights[i];
607
+ g += colors[i][1] * weights[i];
608
+ b += colors[i][2] * weights[i];
609
+ alpha += colors[i][3] * weights[i];
610
+ }
611
+ return rgbaToHex(Math.round(r), Math.round(g), Math.round(b), alpha * alphaMultiplier);
612
+ }
613
+
614
+ /**
615
+ * Mix N colors in the OKLab color space using weighted averages.
616
+ *
617
+ * @param {Array} colors Array of [r, g, b, a] color arrays.
618
+ * @param {Array} weights Array of weight values for each color.
619
+ * @param {number} alphaMultiplier Multiplier for the final alpha channel.
620
+ * @return {string} A hex color string.
621
+ */
622
+ function mixNColorsOklab (colors, weights, alphaMultiplier) {
623
+ const oklabValues = colors.map((color) => {
624
+ return rgbToOklab(color[0], color[1], color[2]);
625
+ });
626
+ let L = 0;
627
+ let a = 0;
628
+ let b = 0;
629
+ let alpha = 0;
630
+ for (let i = 0; i < oklabValues.length; i++) {
631
+ L += oklabValues[i].L * weights[i];
632
+ a += oklabValues[i].a * weights[i];
633
+ b += oklabValues[i].b * weights[i];
634
+ alpha += colors[i][3] * weights[i];
635
+ }
636
+ alpha *= alphaMultiplier;
637
+ const rgb = oklabToRgb(L, a, b);
638
+ return rgbaToHex(rgb[0], rgb[1], rgb[2], alpha >= 1 ? 1 : alpha);
639
+ }
640
+
475
641
  /**
476
642
  * Evaluate a color-mix() expression. Returns a minified CSS color string or null.
477
643
  *
@@ -495,15 +661,20 @@ function evaluateColorMix (expr) {
495
661
  const colorSpace = inMatch[1].toLowerCase();
496
662
  const rest = inner.slice(inMatch[0].length);
497
663
 
498
- // Split the two color arguments (handling nested parens)
499
- const [arg1, arg2] = splitColorMixArgs(rest);
500
- if (!arg1 || !arg2) {
664
+ // Split color arguments (handling nested parens)
665
+ const args = splitColorMixArgs(rest);
666
+ if (args.length < 2) {
501
667
  return null;
502
668
  }
503
669
 
670
+ // N-color path (3+ colors)
671
+ if (args.length > 2) {
672
+ return evaluateNColorMix(colorSpace, args);
673
+ }
674
+
504
675
  // Parse each argument: "<color> [<percentage>]"
505
- const parsed1 = parseColorMixArg(arg1.trim());
506
- const parsed2 = parseColorMixArg(arg2.trim());
676
+ const parsed1 = parseColorMixArg(args[0].trim());
677
+ const parsed2 = parseColorMixArg(args[1].trim());
507
678
  if (!parsed1 || !parsed2) {
508
679
  return null;
509
680
  }
@@ -630,23 +801,27 @@ function extractBalancedArgs (expr, funcName) {
630
801
  }
631
802
 
632
803
  /**
633
- * Split color-mix arguments at the top-level comma (handling nested parens).
804
+ * Split color-mix arguments at top-level commas (handling nested parens).
634
805
  *
635
- * @param {string} str The two color arguments string, separated by a comma.
636
- * @return {Array} A two-element array of [firstArg, secondArg], or [null, null] if no split point.
806
+ * @param {string} str The color arguments string, with arguments separated by commas.
807
+ * @return {Array} An array of argument strings split at each top-level comma.
637
808
  */
638
809
  function splitColorMixArgs (str) {
810
+ const args = [];
639
811
  let depth = 0;
812
+ let start = 0;
640
813
  for (let position = 0; position < str.length; position++) {
641
814
  if (str[position] === '(') {
642
815
  depth++;
643
816
  } else if (str[position] === ')') {
644
817
  depth--;
645
818
  } else if (str[position] === ',' && depth === 0) {
646
- return [str.slice(0, position), str.slice(position + 1)];
819
+ args.push(str.slice(start, position));
820
+ start = position + 1;
647
821
  }
648
822
  }
649
- return [null, null];
823
+ args.push(str.slice(start));
824
+ return args;
650
825
  }
651
826
 
652
827
  /**
@@ -74,11 +74,16 @@ const COLOR_TOKEN_PATTERN = new RegExp(
74
74
  * @return {string} The segment with all colors shortened to their minimal form.
75
75
  */
76
76
  function shortenColorValues (segment) {
77
+ // Match "color-mix(" as a whole word, case-insensitive
78
+ const hasColorMix = /\bcolor-mix\(/i.test(segment);
77
79
  return segment.replace(COLOR_TOKEN_PATTERN, (match) => {
78
80
  let channels;
79
81
  if (match.startsWith('#')) {
80
82
  channels = parseHex(match);
81
83
  } else {
84
+ if (hasColorMix) {
85
+ return match;
86
+ }
82
87
  const rgb = namedColors[match.toLowerCase()];
83
88
  if (rgb) {
84
89
  channels = [rgb[0], rgb[1], rgb[2], 1];