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 +16 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/audit/render-markdown.js +2 -1
- package/src/audit/token-ladders.js +33 -3
- package/src/core/length.js +9 -0
- package/src/core/tailwind-class-analysis.js +5 -2
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +2 -1
- package/src/rules/no-offscale-transform/index.js +2 -1
- package/src/rules/use-motion-scale/index.js +2 -1
- package/src/rules/use-scale/index.js +2 -1
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=
|
|
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
|
[](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
|
|
10
|
-
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
11
11
|
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
12
12
|
[](./LICENSE)
|
|
13
13
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "3.10.
|
|
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
|
-
|
|
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
|
|
163
|
-
if (ladder
|
|
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 }) => ({
|
package/src/core/length.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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. */
|