postcss-ruler 1.3.0 → 2.0.0

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.
Files changed (3) hide show
  1. package/README.md +164 -11
  2. package/index.js +63 -4
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -33,6 +33,16 @@ module.exports = {
33
33
  minWidth: 320, // Default minimum viewport width
34
34
  maxWidth: 1760, // Default maximum viewport width
35
35
  generateAllCrossPairs: false,
36
+ // Optional: Pre-define scales for cross-file usage (Astro, Vite, etc.)
37
+ scales: {
38
+ space: {
39
+ pairs: {
40
+ xs: [8, 16],
41
+ sm: [16, 24],
42
+ md: [24, 32],
43
+ },
44
+ },
45
+ },
36
46
  },
37
47
  },
38
48
  };
@@ -230,15 +240,157 @@ Convert individual values directly to `clamp()` functions:
230
240
  }
231
241
  ```
232
242
 
243
+ ### 4. Low Specificity Mode: Zero-Specificity Utilities
244
+
245
+ Wrap generated selectors in `:where()` to reduce their specificity to 0, making them easier to override:
246
+
247
+ ```css
248
+ @ruler scale({
249
+ prefix: 'space',
250
+ pairs: {
251
+ "xs": [8, 16],
252
+ "sm": [16, 24]
253
+ }
254
+ });
255
+
256
+ /* Enable lowSpecificity per utility */
257
+ @ruler utility({
258
+ selector: '.gap',
259
+ property: 'gap',
260
+ scale: 'space',
261
+ lowSpecificity: true
262
+ });
263
+ ```
264
+
265
+ **Generates:**
266
+
267
+ ```css
268
+ --space-xs: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem);
269
+ --space-sm: clamp(1rem, 0.5556vw + 0.8889rem, 1.5rem);
270
+
271
+ :where(.gap-xs) {
272
+ gap: clamp(0.5rem, 0.5556vw + 0.3889rem, 1rem);
273
+ }
274
+ :where(.gap-sm) {
275
+ gap: clamp(1rem, 0.5556vw + 0.8889rem, 1.5rem);
276
+ }
277
+ ```
278
+
279
+ **Specificity comparison:**
280
+
281
+ - `.gap-xs` → specificity (0,1,0)
282
+ - `:where(.gap-xs)` → specificity (0,0,0)
283
+
284
+ **Use case:** Design system utilities that should be easily overridable without `!important`:
285
+
286
+ ```css
287
+ /* Utility with zero specificity */
288
+ :where(.gap-md) {
289
+ gap: clamp(1.5rem, 0.5556vw + 1.3889rem, 2rem);
290
+ }
291
+
292
+ /* Easy to override with a simple class */
293
+ .custom-layout {
294
+ gap: 2rem; /* This wins without !important */
295
+ }
296
+ ```
297
+
298
+ **Works with attribute mode:**
299
+
300
+ ```css
301
+ @ruler utility({
302
+ attribute: 'data-size',
303
+ property: 'font-size',
304
+ scale: 'size',
305
+ lowSpecificity: true
306
+ });
307
+ ```
308
+
309
+ **Generates:**
310
+
311
+ ```css
312
+ :where([data-size="xs"]) {
313
+ font-size: var(--size-xs);
314
+ }
315
+ :where([data-size="sm"]) {
316
+ font-size: var(--size-sm);
317
+ }
318
+ ```
319
+
320
+ **Global configuration:**
321
+
322
+ ```javascript
323
+ // postcss.config.js
324
+ module.exports = {
325
+ plugins: {
326
+ "postcss-ruler": {
327
+ lowSpecificity: true, // All utilities use :where() by default
328
+ },
329
+ },
330
+ };
331
+ ```
332
+
333
+ You can override the global setting per utility by explicitly setting `lowSpecificity: false`.
334
+
233
335
  ## Configuration Options
234
336
 
235
337
  ### Plugin Options
236
338
 
237
- | Option | Type | Default | Description |
238
- | ----------------------- | ------- | ------- | ----------------------------------------- |
239
- | `minWidth` | number | `320` | Default minimum viewport width in pixels |
240
- | `maxWidth` | number | `1760` | Default maximum viewport width in pixels |
241
- | `generateAllCrossPairs` | boolean | `false` | Generate cross-combinations in scale mode |
339
+ | Option | Type | Default | Description |
340
+ | ----------------------- | ------- | ------- | -------------------------------------------------------------- |
341
+ | `minWidth` | number | `320` | Default minimum viewport width in pixels |
342
+ | `maxWidth` | number | `1760` | Default maximum viewport width in pixels |
343
+ | `generateAllCrossPairs` | boolean | `false` | Generate cross-combinations in scale mode |
344
+ | `lowSpecificity` | boolean | `false` | Wrap utility selectors in `:where()` to lower specificity to 0 |
345
+ | `scales` | object | `{}` | Pre-defined scales for cross-file usage (see below) |
346
+
347
+ ### Pre-defined Scales (for Astro, Vite, etc.)
348
+
349
+ When using bundlers like Astro or Vite that process CSS files in unpredictable order, you may encounter issues where `@ruler utility()` in one file can't reference a scale defined with `@ruler scale()` in another file.
350
+
351
+ To solve this, define your scales in the plugin config:
352
+
353
+ ```javascript
354
+ // postcss.config.js
355
+ module.exports = {
356
+ plugins: {
357
+ "postcss-ruler": {
358
+ scales: {
359
+ space: {
360
+ pairs: {
361
+ xs: [8, 16],
362
+ sm: [16, 24],
363
+ md: [24, 32],
364
+ lg: [32, 48],
365
+ },
366
+ },
367
+ size: {
368
+ minWidth: 400,
369
+ maxWidth: 1200,
370
+ generateAllCrossPairs: true,
371
+ pairs: {
372
+ sm: [100, 200],
373
+ md: [200, 400],
374
+ },
375
+ },
376
+ },
377
+ },
378
+ },
379
+ };
380
+ ```
381
+
382
+ Now you can use `@ruler utility()` in any file without needing `@ruler scale()` first:
383
+
384
+ ```css
385
+ /* Component.astro or any CSS file */
386
+ @ruler utility({
387
+ selector: '.gap',
388
+ property: 'gap',
389
+ scale: 'space'
390
+ });
391
+ ```
392
+
393
+ **Note:** If you use `@ruler scale()` with the same prefix as a config-defined scale, the inline scale will override the config scale for that file.
242
394
 
243
395
  ### At-Rule Options
244
396
 
@@ -308,12 +460,13 @@ When min and max values are equal, postcss-ruler outputs a simple rem value inst
308
460
 
309
461
  ### Utility Options
310
462
 
311
- | Option | Type | Default | Description |
312
- | ----------------------- | --------------- | -------- | --------------------------------------------------------------------- |
313
- | `selector` | string | required | Any valid CSS selector pattern (e.g., `.gap`, `&.active`, `#section`) |
314
- | `property` | string or array | required | CSS property name(s) to apply the scale values to |
315
- | `scale` | string | required | Name of a previously defined scale (the `prefix` value) |
316
- | `generateAllCrossPairs` | boolean | No | Include/exclude cross-pairs (overrides scale default) |
463
+ | Option | Type | Default | Description |
464
+ | ----------------------- | --------------- | -------- | --------------------------------------------------------------------------------- |
465
+ | `selector` | string | required | Any valid CSS selector pattern (e.g., `.gap`, `&.active`, `#section`) |
466
+ | `property` | string or array | required | CSS property name(s) to apply the scale values to |
467
+ | `scale` | string | required | Name of a previously defined scale (the `prefix` value) |
468
+ | `generateAllCrossPairs` | boolean | No | Include/exclude cross-pairs (overrides scale default) |
469
+ | `lowSpecificity` | boolean | No | Wrap selectors in `:where()` to reduce specificity to 0 (overrides global config) |
317
470
 
318
471
  ## How It Works
319
472
 
package/index.js CHANGED
@@ -8,6 +8,8 @@ module.exports = (opts) => {
8
8
  minWidth: 320,
9
9
  maxWidth: 1760,
10
10
  generateAllCrossPairs: false,
11
+ lowSpecificity: false,
12
+ scales: {},
11
13
  };
12
14
  const config = Object.assign(DEFAULTS, opts);
13
15
 
@@ -111,6 +113,35 @@ module.exports = (opts) => {
111
113
  return clampScales;
112
114
  };
113
115
 
116
+ /**
117
+ * Pre-processes scales defined in plugin config
118
+ * @param {Object} configScales - Scales object from plugin options
119
+ */
120
+ const initializeConfigScales = (configScales) => {
121
+ Object.entries(configScales).forEach(([prefix, scaleConfig]) => {
122
+ const scaleOpts = {
123
+ minWidth: scaleConfig.minWidth || config.minWidth,
124
+ maxWidth: scaleConfig.maxWidth || config.maxWidth,
125
+ generateAllCrossPairs:
126
+ scaleConfig.generateAllCrossPairs ?? config.generateAllCrossPairs,
127
+ };
128
+
129
+ const clampPairs = Object.entries(scaleConfig.pairs).map(
130
+ ([name, values]) => ({ name, values }),
131
+ );
132
+
133
+ scales[prefix] = generateClamps({
134
+ pairs: clampPairs,
135
+ ...scaleOpts,
136
+ });
137
+ });
138
+ };
139
+
140
+ // Initialize scales from config (if any)
141
+ if (Object.keys(config.scales).length > 0) {
142
+ initializeConfigScales(config.scales);
143
+ }
144
+
114
145
  /**
115
146
  * Parses parameters from @fluid at-rule
116
147
  * @param {Array} params - Parsed parameter nodes
@@ -202,6 +233,7 @@ module.exports = (opts) => {
202
233
  scale: null,
203
234
  generateAllCrossPairs: null,
204
235
  attribute: null,
236
+ lowSpecificity: null,
205
237
  };
206
238
 
207
239
  for (let i = 0; i < params.length; i++) {
@@ -241,6 +273,10 @@ module.exports = (opts) => {
241
273
  utilityParams.generateAllCrossPairs = value === "true";
242
274
  i++;
243
275
  break;
276
+ case "lowSpecificity":
277
+ utilityParams.lowSpecificity = value === "true";
278
+ i++;
279
+ break;
244
280
  }
245
281
  }
246
282
 
@@ -309,16 +345,21 @@ module.exports = (opts) => {
309
345
 
310
346
  const utilityParams = parseUtilityParams(params);
311
347
 
348
+ // Resolve lowSpecificity from config if not explicitly set
349
+ if (utilityParams.lowSpecificity === null) {
350
+ utilityParams.lowSpecificity = config.lowSpecificity;
351
+ }
352
+
312
353
  // Validate attribute-specific constraints first
313
354
  if (utilityParams.attribute !== null) {
314
355
  if (utilityParams.attribute === "") {
315
356
  throw new Error(
316
- '[postcss-ruler] @ruler utility() attribute parameter cannot be empty',
357
+ "[postcss-ruler] @ruler utility() attribute parameter cannot be empty",
317
358
  );
318
359
  }
319
360
  if (!/^[a-zA-Z0-9_-]+$/.test(utilityParams.attribute)) {
320
361
  throw new Error(
321
- '[postcss-ruler] @ruler utility() attribute parameter must contain only letters, numbers, hyphens, and underscores',
362
+ "[postcss-ruler] @ruler utility() attribute parameter must contain only letters, numbers, hyphens, and underscores",
322
363
  );
323
364
  }
324
365
  }
@@ -368,13 +409,31 @@ module.exports = (opts) => {
368
409
  if (utilityParams.attribute) {
369
410
  // Attribute mode: [data-attr="value"] or .class[data-attr="value"]
370
411
  const attrSelector = `[${utilityParams.attribute}="${item.label}"]`;
371
- ruleSelector = utilityParams.selector
412
+ const baseSelector = utilityParams.selector
372
413
  ? `${utilityParams.selector}${attrSelector}`
373
414
  : attrSelector;
415
+ ruleSelector = utilityParams.lowSpecificity
416
+ ? `:where(${baseSelector})`
417
+ : baseSelector;
374
418
  ruleValue = `var(--${utilityParams.scale}-${item.label})`;
375
419
  } else {
376
420
  // Class mode (existing behavior)
377
- ruleSelector = `${utilityParams.selector}-${item.label}`;
421
+ const baseSelector = `${utilityParams.selector}-${item.label}`;
422
+
423
+ // Handle parent context selectors (e.g., ".container &")
424
+ if (utilityParams.lowSpecificity) {
425
+ if (utilityParams.selector.endsWith(" &")) {
426
+ // Parent context: ".container &" -> ".container :where(&-xs)"
427
+ const parentPart = utilityParams.selector.slice(0, -1); // Remove trailing "&"
428
+ ruleSelector = `${parentPart}:where(&-${item.label})`;
429
+ } else {
430
+ // Regular selector: wrap entire selector
431
+ ruleSelector = `:where(${baseSelector})`;
432
+ }
433
+ } else {
434
+ ruleSelector = baseSelector;
435
+ }
436
+
378
437
  ruleValue = item.clamp;
379
438
  }
380
439
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postcss-ruler",
3
- "version": "1.3.0",
3
+ "version": "2.0.0",
4
4
  "description": "PostCSS plugin to generate fluid scales and values.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "eslintConfig": {
39
39
  "parserOptions": {
40
- "ecmaVersion": 2018
40
+ "ecmaVersion": 2020
41
41
  },
42
42
  "env": {
43
43
  "node": true,