stylelint-plugin-rhythmguard 3.10.0 → 3.10.2

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/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.10.2] - 2026-10-15
10
+
11
+ ### Messages
12
+
13
+ - A value below the smallest step or above the largest named that step twice: `Use scale values (nearest: 14px or 14px).` It is now named once: `(nearest: 14px)`, and for a Tailwind class `Use "p-8" (32px).` Applies to `use-scale`, `no-offscale-transform`, `use-motion-scale` and both ESLint rules. Messages for values between two steps are unchanged.
14
+
15
+ ### Changed
16
+
17
+ - New README banner without a version number, so it no longer needs replacing each release. GitHub shows the transparent SVG; npmjs.com gets a non-transparent PNG rendered from it on the same background as before.
18
+
19
+ ## [3.10.1] - 2026-10-15
20
+
21
+ ### Fixed
22
+
23
+ - Token ladders: a ladder whose every step is a `var()` alias of a step on one other ladder, such as Tailwind `@theme` names over a token file, is folded into that ladder instead of being reported again with the same problems. The ladder it aliases lists it under `aliases`, and the Markdown report names it next to that ladder.
24
+
9
25
  ## [3.10.0] - 2026-10-15
10
26
 
11
27
  ### Added
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.png?v=13" width="100%" alt="Rhythmguard: stable local evidence for design system drift" />
2
+ <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.png?v=14" width="100%" alt="Rhythmguard: stable local evidence for design system drift" />
3
3
  </p>
4
4
 
5
5
  # stylelint-plugin-rhythmguard
@@ -7,7 +7,7 @@
7
7
  Nobody chose 13px. Rhythmguard catches off-scale spacing in CSS and Tailwind class strings, tells you the nearest steps on your scale, and snaps to them or to your tokens when you ask.
8
8
 
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/petrilahdelma/stylelint-plugin-rhythmguard/ci.yml?branch=main&label=ci)](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
10
- [![npm version](https://img.shields.io/npm/v/stylelint-plugin-rhythmguard?label=npm&color=1f6feb&r=3100)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
10
+ [![npm version](https://img.shields.io/npm/v/stylelint-plugin-rhythmguard?label=npm&color=1f6feb&r=3102)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
11
11
  [![npm downloads](https://img.shields.io/npm/dm/stylelint-plugin-rhythmguard.svg)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
12
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-white.svg)](./LICENSE)
13
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "3.10.0",
3
+ "version": "3.10.2",
4
4
  "description": "Nobody chose 13px. Catches off-scale spacing in CSS and Tailwind class strings and snaps it to your scale or tokens. Stylelint rules, an ESLint companion, and an audit CLI.",
5
5
  "bin": {
6
6
  "rhythmguard": "src/cli/index.js"
@@ -141,7 +141,8 @@ function appendTokenLaddersMarkdown(lines, ladders) {
141
141
  const note = `${LADDER_PROBLEM_TEXT[problem.type]}${where} with \`${problem.tokens[0]}\``;
142
142
  flagged.set(problem.tokens[1], [...(flagged.get(problem.tokens[1]) || []), note]);
143
143
  }
144
- lines.push(`**\`${escapeMarkdown(entry.prefix)}\`**`);
144
+ const aliasNote = entry.aliases ? ` (also aliased as ${entry.aliases.map((alias) => `\`${escapeMarkdown(alias)}\``).join(', ')})` : '';
145
+ lines.push(`**\`${escapeMarkdown(entry.prefix)}\`**${aliasNote}`);
145
146
  lines.push('');
146
147
  lines.push('| Token | Min | Max | Problem |');
147
148
  lines.push('| --- | --- | --- | --- |');
@@ -19,7 +19,8 @@ const { formatLength, numbersEqual, parseLengthToken, toPx } = require('../core/
19
19
  * Each problem names the end it occurs at: `min`, `max`, or `both`.
20
20
  *
21
21
  * A step that cannot be evaluated (calc(), a viewport length, a token with
22
- * several different definitions) is left out of the comparison.
22
+ * several different definitions) is left out of the comparison. A ladder
23
+ * that only aliases another one step for step is folded into it.
23
24
  */
24
25
  const DEFAULT_MIN_STEP_PX = 2;
25
26
  const MIN_LADDER_STEPS = 3;
@@ -158,9 +159,37 @@ function resolveTokenLadders({ baseFontSize = 16, declarations, minStepPx = DEFA
158
159
  ladder.steps.push({ max: range.max, min: range.min, rank: step.rank, token });
159
160
  }
160
161
 
162
+ const eligible = [...ladders.values()].filter((ladder) => ladder.steps.length >= MIN_LADDER_STEPS);
163
+ const ladderOfToken = new Map();
164
+ for (const ladder of eligible) {
165
+ for (const step of ladder.steps) ladderOfToken.set(step.token, ladder);
166
+ }
167
+ // A ladder whose every step is a var() alias of a step on one other ladder
168
+ // (Tailwind @theme names over a token file) has that ladder's problems, not
169
+ // its own. It is listed under the other ladder's `aliases` and not counted.
170
+ const aliasTarget = (ladder) => {
171
+ let target = null;
172
+ for (const step of ladder.steps) {
173
+ const values = declarations.get(step.token);
174
+ const reference = values && values.size === 1 ? [...values][0].trim().match(VAR_REFERENCE) : null;
175
+ const owner = reference ? ladderOfToken.get(reference[1]) : null;
176
+ if (!owner || owner === ladder || (target && owner !== target)) return null;
177
+ target = owner;
178
+ }
179
+ return target;
180
+ };
181
+ const aliases = new Map();
182
+ for (const ladder of eligible) {
183
+ const target = aliasTarget(ladder);
184
+ if (target && !aliasTarget(target)) {
185
+ aliases.set(ladder, target);
186
+ target.aliases = [...(target.aliases || []), ladder.prefix].sort();
187
+ }
188
+ }
189
+
161
190
  const entries = [];
162
- for (const ladder of ladders.values()) {
163
- if (ladder.steps.length < MIN_LADDER_STEPS) continue;
191
+ for (const ladder of eligible) {
192
+ if (aliases.has(ladder)) continue;
164
193
  const steps = ladder.steps.sort((a, b) => a.rank - b.rank);
165
194
  const problems = [];
166
195
  for (let index = 0; index < steps.length - 1; index += 1) {
@@ -176,6 +205,7 @@ function resolveTokenLadders({ baseFontSize = 16, declarations, minStepPx = DEFA
176
205
  if (atMax) problems.push({ end: 'max', tokens, ...atMax });
177
206
  }
178
207
  entries.push({
208
+ ...(ladder.aliases ? { aliases: ladder.aliases } : {}),
179
209
  prefix: ladder.prefix,
180
210
  problems,
181
211
  steps: steps.map(({ max, min, token }) => ({
@@ -182,6 +182,14 @@ function nearestScaleValues(target, scale) {
182
182
  };
183
183
  }
184
184
 
185
+ /**
186
+ * "nearest: 12px or 16px" for a value between two steps, "nearest: 14px" for a
187
+ * value below the smallest step or above the largest, where both are the same.
188
+ */
189
+ function nearestText(lower, upper) {
190
+ return lower === upper ? `nearest: ${lower}` : `nearest: ${lower} or ${upper}`;
191
+ }
192
+
185
193
  /**
186
194
  * The replacement text for an off-scale length, or null when the value's unit
187
195
  * is not one the rule may rewrite. Keeps the sign, and with the `exact` unit
@@ -203,6 +211,7 @@ function fixedLengthValue(parsedLength, nearestPx, { baseFontSize, unitStrategy,
203
211
  }
204
212
 
205
213
  module.exports = {
214
+ nearestText,
206
215
  fixedLengthValue,
207
216
  formatLength,
208
217
  formatNumber,
@@ -4,6 +4,7 @@ const {
4
4
  formatLength,
5
5
  formatNumber,
6
6
  nearestScaleValues,
7
+ nearestText,
7
8
  normalizeScale,
8
9
  numbersEqual,
9
10
  parseLengthToken,
@@ -264,9 +265,11 @@ function offScaleClassMessage(token, analysis) {
264
265
  const upperPx = analysis.nearest ? formatLength(analysis.nearest.upper, 'px') : 'n/a';
265
266
  if (analysis.nearestUtilities) {
266
267
  const { lower, upper } = analysis.nearestUtilities;
267
- return `Unexpected Tailwind arbitrary spacing value "${token}". Use "${lower}" (${lowerPx}) or "${upper}" (${upperPx}).`;
268
+ return lower === upper
269
+ ? `Unexpected Tailwind arbitrary spacing value "${token}". Use "${lower}" (${lowerPx}).`
270
+ : `Unexpected Tailwind arbitrary spacing value "${token}". Use "${lower}" (${lowerPx}) or "${upper}" (${upperPx}).`;
268
271
  }
269
- return `Unexpected Tailwind arbitrary spacing value "${token}". Use scale values (nearest: ${lowerPx} or ${upperPx}).`;
272
+ return `Unexpected Tailwind arbitrary spacing value "${token}". Use scale values (${nearestText(lowerPx, upperPx)}).`;
270
273
  }
271
274
 
272
275
  module.exports = {
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { MAX_NOTE_LENGTH, normalizeNote, withNote } = require('../../core/options');
4
+ const { nearestText } = require('../../core/length');
4
5
  const { formatTime } = require('../../core/time');
5
6
  const { createTailwindMotionAnalyzer } = require('../../core/tailwind-motion-analysis');
6
7
 
@@ -71,7 +72,7 @@ function buildMessage(analysis, segment, lower, upper) {
71
72
  return `Unexpected Tailwind arbitrary motion duration "${segment.token}". Use non-negative duration values.`;
72
73
  }
73
74
 
74
- return `Unexpected Tailwind arbitrary motion duration "${segment.token}". Use duration scale values (nearest: ${lower} or ${upper}).`;
75
+ return `Unexpected Tailwind arbitrary motion duration "${segment.token}". Use duration scale values (${nearestText(lower, upper)}).`;
75
76
  }
76
77
 
77
78
  module.exports = {
@@ -6,6 +6,7 @@ const {
6
6
  formatLength,
7
7
  isHairlineLength,
8
8
  nearestScaleValues,
9
+ nearestText,
9
10
  numbersEqual,
10
11
  parseLengthToken,
11
12
  toPx,
@@ -37,7 +38,7 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
37
38
  invalidPreset: (presetName, presetNames) =>
38
39
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
39
40
  rejected: (value, lower, upper, note = '') =>
40
- `Unexpected transform translation value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
41
+ `Unexpected transform translation value "${value}". Use scale values (${nearestText(lower, upper)}).${note ? ` ${note}` : ''}`,
41
42
  });
42
43
 
43
44
  const ruleFunction = (primary, secondaryOptions) => {
@@ -4,6 +4,7 @@ const stylelint = require('stylelint');
4
4
  const valueParser = require('postcss-value-parser');
5
5
  const {
6
6
  nearestScaleValues,
7
+ nearestText,
7
8
  numbersEqual,
8
9
  } = require('../../core/length');
9
10
  const {
@@ -43,7 +44,7 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
43
44
  invalidDuration: (value, note = '') =>
44
45
  withNote(`Unexpected negative motion duration "${value}". Use non-negative duration values.`, note),
45
46
  rejectedDuration: (value, lower, upper, note = '') =>
46
- withNote(`Unexpected motion duration "${value}". Use duration scale values (nearest: ${lower} or ${upper}).`, note),
47
+ withNote(`Unexpected motion duration "${value}". Use duration scale values (${nearestText(lower, upper)}).`, note),
47
48
  rejectedEasing: (value, note = '') =>
48
49
  withNote(`Unexpected raw motion easing "${value}". Use motion tokens for easing decisions.`, note),
49
50
  });
@@ -6,6 +6,7 @@ const {
6
6
  formatLength,
7
7
  isHairlineLength,
8
8
  nearestScaleValues,
9
+ nearestText,
9
10
  numbersEqual,
10
11
  parseLengthToken,
11
12
  toPx,
@@ -44,7 +45,7 @@ const messages = stylelint.utils.ruleMessages(ruleName, {
44
45
  invalidPreset: (presetName, presetNames) =>
45
46
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
46
47
  rejected: (value, lower, upper, note = '', position = null, aside = '', group = null) =>
47
- `Unexpected off-scale ${position ? `${position} "${value}"${aside}` : `value "${value}"`}. Use ${group ? `${group} ` : ''}scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
48
+ `Unexpected off-scale ${position ? `${position} "${value}"${aside}` : `value "${value}"`}. Use ${group ? `${group} ` : ''}scale values (${nearestText(lower, upper)}).${note ? ` ${note}` : ''}`,
48
49
  });
49
50
 
50
51
  /** " (16.8px)" after a bound written in rem or em, so the reader sees what was compared. */