stylelint-plugin-rhythmguard 3.6.0 → 3.7.1

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,30 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.7.1] - 2026-09-17
10
+
11
+ ### Fixed
12
+
13
+ - Sass maps declared through a merge helper are read: `$spacers: defaults((...), $spacers)` (Bootstrap v6), `map.merge($base, (...))` and `map-merge((...), (...))` contribute the merged map, later keys winning. The reader also follows Sass assignment rules now: a later plain assignment replaces an earlier `!default`, a later `!default` does not. Before, the first assignment always won, so Bootstrap v6's `$spacers: () !default;` hid the map that followed it and `scale: "auto"` fell back. ([#143](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/143))
14
+
15
+ ### Changed
16
+
17
+ - The quiet benchmark has a `bootstrap-v6` row, the branch its maintainers asked to see (twbs/bootstrap#42907); the manifest accepts a `ref` so a row can pin a branch other than the default.
18
+ - The release verify matrix and the post-publish smoke are back on the self-hosted farm. The repository variable `RELEASE_ON_HOSTED=true` moves a release onto GitHub-hosted runners while the farm is down, without a workflow edit or a re-cut; the release checklist explains when to use it.
19
+
20
+ ## [3.7.0] - 2026-09-17
21
+
22
+ ### Added
23
+
24
+ - Every rule accepts `note`, a sentence of up to 200 characters appended to each finding after the built-in text: `... (nearest: 12px or 16px). See docs/spacing.md for approved exceptions.` The built-in text is unchanged, so tooling that matches on it keeps working. Stylelint and ESLint rules alike. ([#134](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/134))
25
+
26
+ ### Changed
27
+
28
+ - `prefer-token` names the token it would write and where it was read from: `Unexpected raw scale value "12px". Use var(--space-3) (src/tokens.css).` The origin is the linted stylesheet, the `tokenMapFile` or the Tailwind config; an inline `tokenMap` entry is named without one, and a negative literal as `calc(-1 * var(--space-3))`. When no mapping holds the value the message says `No known token holds this value; use the nearest token or add one.` It said `Use design tokens for scale decisions.` in every case. ([#133](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/133))
29
+ - `use-scale` and `no-offscale-transform` with `fixWith: "token"` append `var(--space-3) holds 12px.` when exactly one token qualifies, so the message names what the fix writes. ([#133](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/133))
30
+ - `tailwind-class-use-scale` names the utility classes for the two nearest steps, with their px values, and its fix writes the utility class instead of an on-scale arbitrary value: `p-[13px]` reads `Use "p-3" (12px) or "p-4" (16px)` and becomes `p-3`; `md:-m-[13px]!` becomes `md:-m-3!`; `p-[0.8125rem]` becomes `p-3`. The class number is the snapped value divided by the new `spacingUnit` option (4px, Tailwind's `--spacing`); when it is not a quarter step the fix keeps the arbitrary form, and `spacingUnit: false` keeps it always. The audit's Tailwind findings carry the same text and `fixedToken`. ([#132](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/132))
31
+ - The release workflow verifies and publishes on GitHub-hosted runners, and the post-publish smoke runs there too. The first 3.7.0 release waited 24 hours for a self-hosted runner that never registered and was cancelled; a release no longer depends on the farm. Pushes and pull requests still use it.
32
+
9
33
  ## [3.6.0] - 2026-09-09
10
34
 
11
35
  ### Added
package/CONTRIBUTING.md CHANGED
@@ -106,7 +106,7 @@ The maintainer aims to respond to issues and PRs within a week. Small PRs merge
106
106
 
107
107
  ## Releases
108
108
 
109
- Maintainer-run. Bump `package.json` and `CHANGELOG.md`, create a GitHub release, and `release.yml` verifies on the self-hosted matrix and publishes through npm trusted publishing (OIDC) from a GitHub-hosted job. Provenance is automatic. The checklist is in [`docs/RELEASE_CHECKLIST.md`](./docs/RELEASE_CHECKLIST.md).
109
+ Maintainer-run. Bump `package.json` and `CHANGELOG.md`, create a GitHub release, and `release.yml` verifies on the self-hosted matrix and publishes through npm trusted publishing (OIDC) from a GitHub-hosted job. The checklist covers cutting a release while the farm is down. Provenance is automatic. The checklist is in [`docs/RELEASE_CHECKLIST.md`](./docs/RELEASE_CHECKLIST.md).
110
110
 
111
111
  ## Benchmarking performance
112
112
 
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.svg?v=9" 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.svg?v=11" 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=360)](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=371)](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
 
@@ -11,7 +11,7 @@ This project enforces its spacing scale with stylelint-plugin-rhythmguard.
11
11
  - A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
12
12
  - Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
13
13
  - Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
14
- - Tailwind arbitrary spacing values such as `p-[13px]` are findings too; use the scale utility (`p-3`) or an on-scale arbitrary value.
14
+ - Tailwind arbitrary spacing values such as `p-[13px]` are findings too. The finding names the two utility classes to choose from (`p-3` or `p-4`); write one of those, not another arbitrary value.
15
15
  - When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
16
16
  - If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
17
17
 
@@ -6,7 +6,7 @@ This project enforces its spacing scale with stylelint-plugin-rhythmguard.
6
6
  - A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
7
7
  - Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
8
8
  - Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
9
- - Tailwind arbitrary spacing values such as `p-[13px]` are findings too; use the scale utility (`p-3`) or an on-scale arbitrary value.
9
+ - Tailwind arbitrary spacing values such as `p-[13px]` are findings too. The finding names the two utility classes to choose from (`p-3` or `p-4`); write one of those, not another arbitrary value.
10
10
  - When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
11
11
  - If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
12
12
 
@@ -12,7 +12,7 @@ This project enforces its spacing scale with stylelint-plugin-rhythmguard.
12
12
  - A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
13
13
  - Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
14
14
  - Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
15
- - Tailwind arbitrary spacing values such as `p-[13px]` are findings too; use the scale utility (`p-3`) or an on-scale arbitrary value.
15
+ - Tailwind arbitrary spacing values such as `p-[13px]` are findings too. The finding names the two utility classes to choose from (`p-3` or `p-4`); write one of those, not another arbitrary value.
16
16
  - When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
17
17
  - If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
18
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "3.6.0",
3
+ "version": "3.7.1",
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"
@@ -153,7 +153,7 @@
153
153
  "devDependencies": {
154
154
  "@eslint/js": "^9.39.5",
155
155
  "@types/node": "^22.20.1",
156
- "c8": "^10.1.3",
156
+ "c8": "^12.0.0",
157
157
  "eslint": "^9.39.5",
158
158
  "postcss-scss": "^4.0.9",
159
159
  "stylelint": "^16.15.0",
@@ -6,7 +6,7 @@
6
6
  */
7
7
  const fs = require('node:fs');
8
8
  const { formatLength } = require('../../core/length');
9
- const { createTailwindClassAnalyzer } = require('../../core/tailwind-class-analysis');
9
+ const { createTailwindClassAnalyzer, offScaleClassMessage } = require('../../core/tailwind-class-analysis');
10
10
  const { createTailwindMotionAnalyzer } = require('../../core/tailwind-motion-analysis');
11
11
  const { formatTime } = require('../../core/time');
12
12
  const {
@@ -43,9 +43,7 @@ function collectTailwindFindings(templateFiles, options) {
43
43
  : null,
44
44
  rawValue: analysis.rawValue,
45
45
  rule: 'rhythmguard-tailwind/tailwind-class-use-scale',
46
- text: analysis.reason === 'negative'
47
- ? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
48
- : `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values.`,
46
+ text: offScaleClassMessage(segment.token, analysis),
49
47
  token: segment.token,
50
48
  type: 'tailwind-arbitrary-spacing',
51
49
  utility: analysis.utility,
@@ -205,6 +205,7 @@ function fixedLengthValue(parsedLength, nearestPx, { baseFontSize, unitStrategy,
205
205
  module.exports = {
206
206
  fixedLengthValue,
207
207
  formatLength,
208
+ formatNumber,
208
209
  fromPx,
209
210
  isHairlineLength,
210
211
  nearestScaleValues,
@@ -54,6 +54,22 @@ function isNonEmptyString(value) {
54
54
  return typeof value === 'string' && value.trim().length > 0;
55
55
  }
56
56
 
57
+ const MAX_NOTE_LENGTH = 200;
58
+
59
+ /** A `note` is appended to every finding, so it stays a sentence: non-empty, at most 200 characters. */
60
+ function isNote(value) {
61
+ return isNonEmptyString(value) && value.trim().length <= MAX_NOTE_LENGTH;
62
+ }
63
+
64
+ function normalizeNote(value) {
65
+ return isNote(value) ? value.trim() : '';
66
+ }
67
+
68
+ /** The message with the configured note after it, or the message alone. */
69
+ function withNote(message, note) {
70
+ return note ? `${message} ${note}` : message;
71
+ }
72
+
57
73
  function isSupportedUnit(value) {
58
74
  if (!isNonEmptyString(value)) {
59
75
  return false;
@@ -399,6 +415,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
399
415
  entryValidator: isMathFunctionArgumentMap,
400
416
  expectsObject: true,
401
417
  }),
418
+ note: Object.freeze({
419
+ entryValidator: isNote,
420
+ }),
402
421
  preset: Object.freeze({
403
422
  entryValidator: isNonEmptyString,
404
423
  }),
@@ -483,6 +502,9 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
483
502
  entryValidator: isMathFunctionArgumentMap,
484
503
  expectsObject: true,
485
504
  }),
505
+ note: Object.freeze({
506
+ entryValidator: isNote,
507
+ }),
486
508
  preset: Object.freeze({
487
509
  entryValidator: isNonEmptyString,
488
510
  }),
@@ -573,6 +595,7 @@ function buildScaleOptions(rawOptions) {
573
595
  : DEFAULT_IGNORE_KEYWORDS,
574
596
  invalidPreset: scaleSelection.invalidPreset,
575
597
  mathFunctionArguments: normalizeMathFunctionArgumentMap(options.mathFunctionArguments),
598
+ note: normalizeNote(options.note),
576
599
  preset: scaleSelection.selectedPreset,
577
600
  presetNames: listScalePresetNames(),
578
601
  properties: resolvePropertyPatterns(options),
@@ -618,6 +641,7 @@ function buildTokenOptions(rawOptions) {
618
641
  : DEFAULT_IGNORE_KEYWORDS,
619
642
  invalidPreset: scaleSelection.invalidPreset,
620
643
  mathFunctionArguments: normalizeMathFunctionArgumentMap(options.mathFunctionArguments),
644
+ note: normalizeNote(options.note),
621
645
  preset: scaleSelection.selectedPreset,
622
646
  presetNames: listScalePresetNames(),
623
647
  properties: resolvePropertyPatterns(options),
@@ -701,6 +725,10 @@ function resolvePropertyScale(prop, options) {
701
725
  }
702
726
 
703
727
  module.exports = {
728
+ MAX_NOTE_LENGTH,
729
+ isNote,
730
+ normalizeNote,
731
+ withNote,
704
732
  NO_OFFSCALE_TRANSFORM_POSSIBLE_OPTIONS,
705
733
  NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA,
706
734
  PREFER_TOKEN_POSSIBLE_OPTIONS,
@@ -2,6 +2,7 @@
2
2
 
3
3
  const {
4
4
  formatLength,
5
+ formatNumber,
5
6
  nearestScaleValues,
6
7
  normalizeScale,
7
8
  numbersEqual,
@@ -11,6 +12,9 @@ const {
11
12
 
12
13
  const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
13
14
  const DEFAULT_UNITS = ['px', 'rem', 'em'];
15
+ // Tailwind's numeric spacing utilities are multiples of one unit: `--spacing`
16
+ // (0.25rem, 4px) in v4, and the same 4px step in the v3 default scale.
17
+ const DEFAULT_SPACING_UNIT_PX = 4;
14
18
 
15
19
  const ARBITRARY_SPACING_CLASS = /^(?<utility>-?(?:m(?:[trblxy])?|p(?:[trblxy])?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left|translate-[xy]|scroll-(?:m|p)(?:[trblxy])?))-\[(?<rawValue>[^\]]+)\]$/;
16
20
 
@@ -24,6 +28,11 @@ function normalizeTailwindClassOptions(option = {}) {
24
28
  ? option.baseFontSize
25
29
  : 16,
26
30
  scale: Array.isArray(option.scale) ? option.scale : DEFAULT_SCALE,
31
+ spacingUnit: option.spacingUnit === false
32
+ ? null
33
+ : typeof option.spacingUnit === 'number' && Number.isFinite(option.spacingUnit) && option.spacingUnit > 0
34
+ ? option.spacingUnit
35
+ : DEFAULT_SPACING_UNIT_PX,
27
36
  units: Array.isArray(option.units)
28
37
  ? option.units.map((unit) => String(unit).toLowerCase())
29
38
  : DEFAULT_UNITS,
@@ -121,6 +130,26 @@ function hasInvalidVariantPrefix(prefix) {
121
130
  return false;
122
131
  }
123
132
 
133
+ /**
134
+ * The utility class Tailwind generates for a px value, or null when the value
135
+ * is not a quarter step of the spacing unit (v4 generates any quarter step;
136
+ * v3 generates the quarter steps of its default scale). `p-3` for 12px on a
137
+ * 4px unit; `-m-3` when the class or the value is negative.
138
+ */
139
+ function utilityClassFor(utility, px, negative, spacingUnit) {
140
+ if (!spacingUnit) {
141
+ return null;
142
+ }
143
+ const steps = Math.abs(px) / spacingUnit;
144
+ const quarters = steps * 4;
145
+ if (Math.abs(quarters - Math.round(quarters)) > 1e-9) {
146
+ return null;
147
+ }
148
+ const name = utility.startsWith('-') ? utility.slice(1) : utility;
149
+ const sign = negative && steps !== 0 ? '-' : '';
150
+ return `${sign}${name}-${formatNumber(Math.round(quarters) / 4)}`;
151
+ }
152
+
124
153
  function analyzeClassToken(token, options, scalePx) {
125
154
  const parsedToken = parseClassToken(token);
126
155
  if (parsedToken.prefix && hasInvalidVariantPrefix(parsedToken.prefix)) {
@@ -175,13 +204,22 @@ function analyzeClassToken(token, options, scalePx) {
175
204
  ? signedNearest
176
205
  : signedNearest / options.baseFontSize;
177
206
 
207
+ const negative = match.groups.utility.startsWith('-') || parsedLength.number < 0;
208
+ const wrap = (candidate) =>
209
+ `${parsedToken.prefix}${parsedToken.leadingImportant}${candidate}${parsedToken.trailingImportant}`;
210
+ const utilityFix = utilityClassFor(match.groups.utility, nearest.nearest, negative, options.spacingUnit);
211
+ const lowerUtility = utilityClassFor(match.groups.utility, nearest.lower, negative, options.spacingUnit);
212
+ const upperUtility = utilityClassFor(match.groups.utility, nearest.upper, negative, options.spacingUnit);
213
+
178
214
  const replacementValue = formatLength(replacementNumber, parsedLength.unit);
179
- const fixedCandidate = parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
180
- const fixedToken = `${parsedToken.prefix}${parsedToken.leadingImportant}${fixedCandidate}${parsedToken.trailingImportant}`;
215
+ const fixedCandidate = utilityFix || parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
181
216
 
182
217
  return {
183
- fixedToken,
218
+ fixedToken: wrap(fixedCandidate),
184
219
  nearest,
220
+ // The utility classes for the two nearest steps, when both are quarter
221
+ // steps of the spacing unit; messages name these instead of px values.
222
+ nearestUtilities: lowerUtility && upperUtility ? { lower: lowerUtility, upper: upperUtility } : null,
185
223
  parsedLength,
186
224
  reason: 'off-scale',
187
225
  rawValue: match.groups.rawValue,
@@ -214,8 +252,26 @@ function createTailwindClassAnalyzer(option = {}) {
214
252
  };
215
253
  }
216
254
 
255
+ /**
256
+ * The message text for an off-scale class: the two nearest utility classes
257
+ * with their px values when the unit makes them, the px steps otherwise.
258
+ */
259
+ function offScaleClassMessage(token, analysis) {
260
+ if (analysis.reason === 'negative') {
261
+ return `Unexpected Tailwind arbitrary spacing value "${token}". Negative values are disabled for this rule.`;
262
+ }
263
+ const lowerPx = analysis.nearest ? formatLength(analysis.nearest.lower, 'px') : 'n/a';
264
+ const upperPx = analysis.nearest ? formatLength(analysis.nearest.upper, 'px') : 'n/a';
265
+ if (analysis.nearestUtilities) {
266
+ const { lower, upper } = analysis.nearestUtilities;
267
+ return `Unexpected Tailwind arbitrary spacing value "${token}". Use "${lower}" (${lowerPx}) or "${upper}" (${upperPx}).`;
268
+ }
269
+ return `Unexpected Tailwind arbitrary spacing value "${token}". Use scale values (nearest: ${lowerPx} or ${upperPx}).`;
270
+ }
271
+
217
272
  module.exports = {
218
273
  createTailwindClassAnalyzer,
219
274
  findClassSegments,
220
275
  normalizeTailwindClassOptions,
276
+ offScaleClassMessage,
221
277
  };
@@ -64,9 +64,19 @@ function replacementFor(parsedLength, nearestPx, options) {
64
64
  return fixedLengthValue(parsedLength, nearestPx, options);
65
65
  }
66
66
 
67
+ /**
68
+ * The sentence a length rule appends when its fix writes a token: which token
69
+ * holds the snapped value. Empty when the replacement is a literal.
70
+ */
71
+ function tokenHoldsNote(replacement, formattedNearest) {
72
+ const match = typeof replacement === 'string' ? replacement.match(/var\(--[^)]+\)/) : null;
73
+ return match ? `${match[0]} holds ${formattedNearest}.` : '';
74
+ }
75
+
67
76
  module.exports = {
68
77
  negateReplacement,
69
78
  replacementFor,
79
+ tokenHoldsNote,
70
80
  tokenForLength,
71
81
  tokenIndexFromDefinitions,
72
82
  };
@@ -40,7 +40,19 @@ function normalizeTokenReference(tokenReference) {
40
40
  return `var(--${trimmed})`;
41
41
  }
42
42
 
43
- function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize) {
43
+ /**
44
+ * Where a token came from, for messages: the path relative to the working
45
+ * directory when it is inside it, the absolute path otherwise.
46
+ */
47
+ function displayOrigin(file) {
48
+ if (!file) {
49
+ return null;
50
+ }
51
+ const relative = path.relative(process.cwd(), file);
52
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
53
+ }
54
+
55
+ function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize, origins = null, origin = null) {
44
56
  if (typeof rawLength !== 'string') {
45
57
  return;
46
58
  }
@@ -55,6 +67,10 @@ function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize) {
55
67
  return;
56
68
  }
57
69
 
70
+ if (origins && origin && !origins[token]) {
71
+ origins[token] = origin;
72
+ }
73
+
58
74
  const absolute = Math.abs(parsed.number);
59
75
  const normalizedRaw = formatLength(absolute, parsed.unit || 'px');
60
76
  map[normalizedRaw] = token;
@@ -81,7 +97,7 @@ function mergeExplicitTokenMap(target, source) {
81
97
  return target;
82
98
  }
83
99
 
84
- function walkTokenGroup(map, group, prefix, baseFontSize) {
100
+ function walkTokenGroup(map, group, prefix, baseFontSize, origins, origin) {
85
101
  for (const [key, value] of Object.entries(group)) {
86
102
  const tokenName = `${prefix}-${key}`;
87
103
 
@@ -91,24 +107,25 @@ function walkTokenGroup(map, group, prefix, baseFontSize) {
91
107
 
92
108
  // Leaf node with $value (DTCG)
93
109
  if (typeof value.$value === 'string') {
94
- addLengthValueMapping(map, value.$value, tokenName, baseFontSize);
110
+ addLengthValueMapping(map, value.$value, tokenName, baseFontSize, origins, origin);
95
111
  continue;
96
112
  }
97
113
 
98
114
  // Leaf node with value (Style Dictionary)
99
115
  if (typeof value.value === 'string') {
100
- addLengthValueMapping(map, value.value, tokenName, baseFontSize);
116
+ addLengthValueMapping(map, value.value, tokenName, baseFontSize, origins, origin);
101
117
  continue;
102
118
  }
103
119
 
104
- // Nested group — recurse deeper
105
- walkTokenGroup(map, value, tokenName, baseFontSize);
120
+ // Nested group: recurse deeper
121
+ walkTokenGroup(map, value, tokenName, baseFontSize, origins, origin);
106
122
  }
107
123
  }
108
124
 
109
125
  function mergeTokenMapFromFile({
110
126
  baseFontSize,
111
127
  currentMap,
128
+ origins = null,
112
129
  tokenMapFile,
113
130
  }) {
114
131
  if (!tokenMapFile) {
@@ -134,6 +151,7 @@ function mergeTokenMapFromFile({
134
151
  const nextMap = {
135
152
  ...currentMap,
136
153
  };
154
+ const origin = displayOrigin(resolvedPath);
137
155
 
138
156
  for (const [entryKey, entryValue] of Object.entries(parsed)) {
139
157
  if (typeof entryValue === 'string') {
@@ -142,36 +160,39 @@ function mergeTokenMapFromFile({
142
160
 
143
161
  if (keyAsLength) {
144
162
  nextMap[entryKey] = entryValue;
163
+ if (origins && !origins[entryValue]) {
164
+ origins[entryValue] = origin;
165
+ }
145
166
  continue;
146
167
  }
147
168
 
148
169
  if (valueAsLength) {
149
- addLengthValueMapping(nextMap, entryValue, entryKey, baseFontSize);
170
+ addLengthValueMapping(nextMap, entryValue, entryKey, baseFontSize, origins, origin);
150
171
  }
151
172
 
152
173
  continue;
153
174
  }
154
175
 
155
176
  if (typeof entryValue === 'number') {
156
- addLengthValueMapping(nextMap, `${entryValue}px`, entryKey, baseFontSize);
177
+ addLengthValueMapping(nextMap, `${entryValue}px`, entryKey, baseFontSize, origins, origin);
157
178
  continue;
158
179
  }
159
180
 
160
181
  if (isPlainObject(entryValue)) {
161
182
  // Style Dictionary format: { value: "16px" }
162
183
  if (typeof entryValue.value === 'string') {
163
- addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize);
184
+ addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize, origins, origin);
164
185
  continue;
165
186
  }
166
187
 
167
188
  // W3C DTCG format: { $value: "16px", $type: "dimension" }
168
189
  if (typeof entryValue.$value === 'string') {
169
- addLengthValueMapping(nextMap, entryValue.$value, entryKey, baseFontSize);
190
+ addLengthValueMapping(nextMap, entryValue.$value, entryKey, baseFontSize, origins, origin);
170
191
  continue;
171
192
  }
172
193
 
173
- // Nested group — recurse (e.g. { spacing: { 4: { $value: "16px" } } })
174
- walkTokenGroup(nextMap, entryValue, entryKey, baseFontSize);
194
+ // Nested group: recurse (e.g. { spacing: { 4: { $value: "16px" } } })
195
+ walkTokenGroup(nextMap, entryValue, entryKey, baseFontSize, origins, origin);
175
196
  }
176
197
  }
177
198
 
@@ -181,12 +202,14 @@ function mergeTokenMapFromFile({
181
202
  function mergeTokenMapFromCssCustomProperties({
182
203
  baseFontSize,
183
204
  currentMap,
205
+ origins = null,
184
206
  root,
185
207
  tokenRegex,
186
208
  }) {
187
209
  const nextMap = {
188
210
  ...currentMap,
189
211
  };
212
+ const origin = displayOrigin(root && root.source && root.source.input ? root.source.input.file : null);
190
213
 
191
214
  root.walkDecls((decl) => {
192
215
  const prop = decl.prop.toLowerCase();
@@ -203,7 +226,7 @@ function mergeTokenMapFromCssCustomProperties({
203
226
  return;
204
227
  }
205
228
 
206
- addLengthValueMapping(nextMap, decl.value, `var(${decl.prop})`, baseFontSize);
229
+ addLengthValueMapping(nextMap, decl.value, `var(${decl.prop})`, baseFontSize, origins, origin);
207
230
  });
208
231
 
209
232
  return nextMap;
@@ -321,6 +344,7 @@ function loadTailwindSpacing(resolvedPath) {
321
344
 
322
345
  function mergeTokenMapFromTailwindSpacing({
323
346
  currentMap,
347
+ origins = null,
324
348
  tailwindConfigPath,
325
349
  }) {
326
350
  if (!tailwindConfigPath) {
@@ -354,13 +378,23 @@ function mergeTokenMapFromTailwindSpacing({
354
378
  const absolute = Math.abs(parsed.number);
355
379
  const normalizedRaw = formatLength(absolute, parsed.unit || 'px');
356
380
  nextMap[normalizedRaw] = `theme(spacing.${key})`;
381
+ if (origins && !origins[nextMap[normalizedRaw]]) {
382
+ origins[nextMap[normalizedRaw]] = displayOrigin(resolvedPath);
383
+ }
357
384
  }
358
385
 
359
386
  return nextMap;
360
387
  }
361
388
 
389
+ /**
390
+ * The raw-length to token-reference map a rule fixes with. Pass `origins` to
391
+ * also learn where each token reference came from (the token map file, the
392
+ * stylesheet, the Tailwind config), keyed by the reference; an explicit
393
+ * `tokenMap` entry has no origin.
394
+ */
362
395
  function buildEffectiveTokenMap({
363
396
  options,
397
+ origins = null,
364
398
  root,
365
399
  tokenRegex,
366
400
  }) {
@@ -370,6 +404,7 @@ function buildEffectiveTokenMap({
370
404
  tokenMap = mergeTokenMapFromFile({
371
405
  baseFontSize: options.baseFontSize,
372
406
  currentMap: tokenMap,
407
+ origins,
373
408
  tokenMapFile: options.tokenMapFile,
374
409
  });
375
410
  }
@@ -378,6 +413,7 @@ function buildEffectiveTokenMap({
378
413
  tokenMap = mergeTokenMapFromCssCustomProperties({
379
414
  baseFontSize: options.baseFontSize,
380
415
  currentMap: tokenMap,
416
+ origins,
381
417
  root,
382
418
  tokenRegex,
383
419
  });
@@ -386,6 +422,7 @@ function buildEffectiveTokenMap({
386
422
  if (options.tokenMapFromTailwindSpacing && options.tailwindConfigPath) {
387
423
  tokenMap = mergeTokenMapFromTailwindSpacing({
388
424
  currentMap: tokenMap,
425
+ origins,
389
426
  tailwindConfigPath: options.tailwindConfigPath,
390
427
  });
391
428
  }
@@ -456,15 +456,67 @@ function parseScssDeclarations(source) {
456
456
  }
457
457
 
458
458
  startPattern.lastIndex = index;
459
+ const isDefault = /!default\b/.test(value);
459
460
  const cleaned = value.replace(/!(default|global)\b/g, '').trim();
460
- if (cleaned && !declarations.has(name)) {
461
+ // Sass semantics at the top level: `!default` assigns only when the variable is
462
+ // still unset, a plain assignment always wins. Bootstrap v6 declares
463
+ // `$spacers: () !default;` and then `$spacers: defaults((...), $spacers);`.
464
+ if (cleaned && (!declarations.has(name) || !isDefault)) {
461
465
  declarations.set(name, cleaned);
462
466
  }
463
467
  }
464
468
 
469
+ for (const [name, raw] of declarations) {
470
+ const unwrapped = unwrapScssMapCall(raw, declarations);
471
+ if (unwrapped) {
472
+ declarations.set(name, unwrapped);
473
+ }
474
+ }
475
+
465
476
  return declarations;
466
477
  }
467
478
 
479
+ /**
480
+ * A map declared through a merge helper, read as the map it produces:
481
+ * `defaults((...), $spacers)` (Bootstrap v6), `map.merge($a, (...))`,
482
+ * `map-merge((...), (...))`. Map-literal arguments and variables that hold a
483
+ * map are merged left to right with later keys winning, as Sass does; anything
484
+ * else (an unset `!default` placeholder, an expression) contributes nothing.
485
+ */
486
+ const SCSS_MAP_MERGE_CALL = /^(?:defaults|map\.merge|map-merge)\s*\(([\s\S]*)\)$/;
487
+
488
+ function unwrapScssMapCall(raw, declarations) {
489
+ const match = raw.match(SCSS_MAP_MERGE_CALL);
490
+ if (!match) {
491
+ return null;
492
+ }
493
+ const entries = new Map();
494
+ for (const argument of splitTopLevel(match[1], ',')) {
495
+ const text = argument.trim();
496
+ let mapLiteral = null;
497
+ if (isScssMap(text)) {
498
+ mapLiteral = text;
499
+ } else if (/^\$[\w-]+$/.test(text)) {
500
+ const referenced = declarations.get(text.slice(1));
501
+ if (referenced && isScssMap(referenced)) {
502
+ mapLiteral = referenced;
503
+ }
504
+ }
505
+ if (!mapLiteral) {
506
+ continue;
507
+ }
508
+ for (const entry of splitTopLevel(mapLiteral.slice(1, -1), ',')) {
509
+ const pair = splitTopLevel(entry, ':');
510
+ if (pair.length < 2) continue;
511
+ entries.set(pair[0].trim(), pair.slice(1).join(':').trim());
512
+ }
513
+ }
514
+ if (entries.size === 0) {
515
+ return null;
516
+ }
517
+ return `(${[...entries].map(([key, expression]) => `${key}: ${expression}`).join(', ')})`;
518
+ }
519
+
468
520
  function isScssMap(raw) {
469
521
  return raw.startsWith('(') && raw.endsWith(')') && /:/.test(raw);
470
522
  }
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const { MAX_NOTE_LENGTH, normalizeNote, withNote } = require('../../core/options');
3
4
  const { formatTime } = require('../../core/time');
4
5
  const { createTailwindMotionAnalyzer } = require('../../core/tailwind-motion-analysis');
5
6
 
@@ -51,7 +52,7 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
51
52
  ? formatTime(analysis.nearest.upper, 'ms')
52
53
  : 'n/a';
53
54
  context.report({
54
- message: buildMessage(analysis, segment, lower, upper),
55
+ message: withNote(buildMessage(analysis, segment, lower, upper), analyzer.note),
55
56
  node,
56
57
  fix:
57
58
  fixedText && analysis.reason === 'duration'
@@ -95,13 +96,16 @@ module.exports = {
95
96
  },
96
97
  type: 'array',
97
98
  },
99
+ note: { maxLength: MAX_NOTE_LENGTH, minLength: 1, type: 'string' },
98
100
  },
99
101
  type: 'object',
100
102
  },
101
103
  ],
102
104
  },
103
105
  create(context) {
104
- const analyzer = createTailwindMotionAnalyzer(context.options && context.options[0]);
106
+ const option = (context.options && context.options[0]) || {};
107
+ const analyzer = createTailwindMotionAnalyzer(option);
108
+ analyzer.note = normalizeNote(option.note);
105
109
  const sourceCode = context.sourceCode || context.getSourceCode();
106
110
 
107
111
  return {
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const { formatLength } = require('../../core/length');
4
- const { createTailwindClassAnalyzer } = require('../../core/tailwind-class-analysis');
3
+ const { MAX_NOTE_LENGTH, normalizeNote, withNote } = require('../../core/options');
4
+ const { createTailwindClassAnalyzer, offScaleClassMessage } = require('../../core/tailwind-class-analysis');
5
5
 
6
6
  const RULE_NAME = 'tailwind-class-use-scale';
7
7
 
@@ -44,17 +44,8 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
44
44
  : null;
45
45
 
46
46
  for (const { analysis, segment } of findings) {
47
- const lower = analysis.nearest
48
- ? formatLength(analysis.nearest.lower, 'px')
49
- : 'n/a';
50
- const upper = analysis.nearest
51
- ? formatLength(analysis.nearest.upper, 'px')
52
- : 'n/a';
53
47
  context.report({
54
- message:
55
- analysis.reason === 'negative'
56
- ? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
57
- : `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values (nearest: ${lower} or ${upper}).`,
48
+ message: withNote(offScaleClassMessage(segment.token, analysis), analyzer.note),
58
49
  node,
59
50
  fix:
60
51
  fixedText && analysis.reason !== 'negative'
@@ -77,6 +68,7 @@ module.exports = {
77
68
  properties: {
78
69
  allowNegative: { type: 'boolean' },
79
70
  baseFontSize: { type: 'number' },
71
+ note: { maxLength: MAX_NOTE_LENGTH, minLength: 1, type: 'string' },
80
72
  scale: {
81
73
  items: {
82
74
  anyOf: [
@@ -86,6 +78,12 @@ module.exports = {
86
78
  },
87
79
  type: 'array',
88
80
  },
81
+ spacingUnit: {
82
+ anyOf: [
83
+ { exclusiveMinimum: true, minimum: 0, type: 'number' },
84
+ { enum: [false] },
85
+ ],
86
+ },
89
87
  units: {
90
88
  items: { type: 'string' },
91
89
  type: 'array',
@@ -96,7 +94,9 @@ module.exports = {
96
94
  ],
97
95
  },
98
96
  create(context) {
99
- const analyzer = createTailwindClassAnalyzer(context.options && context.options[0]);
97
+ const option = (context.options && context.options[0]) || {};
98
+ const analyzer = createTailwindClassAnalyzer(option);
99
+ analyzer.note = normalizeNote(option.note);
100
100
  const sourceCode = context.sourceCode || context.getSourceCode();
101
101
 
102
102
  return {
@@ -30,14 +30,14 @@ const { validatePrimary, validateNoOffscaleTransformSecondaryOptions } = require
30
30
 
31
31
  const { reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
32
32
  const { decisionFor, loadRcDecisions } = require('../../core/decisions');
33
- const { replacementFor, tokenIndexFromDefinitions } = require('../../core/token-index');
33
+ const { replacementFor, tokenHoldsNote, tokenIndexFromDefinitions } = require('../../core/token-index');
34
34
 
35
35
  const ruleName = 'rhythmguard/no-offscale-transform';
36
36
  const messages = stylelint.utils.ruleMessages(ruleName, {
37
37
  invalidPreset: (presetName, presetNames) =>
38
38
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
39
- rejected: (value, lower, upper) =>
40
- `Unexpected transform translation value "${value}". Use scale values (nearest: ${lower} or ${upper}).`,
39
+ rejected: (value, lower, upper, note = '') =>
40
+ `Unexpected transform translation value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
41
41
  });
42
42
 
43
43
  const ruleFunction = (primary, secondaryOptions) => {
@@ -93,6 +93,7 @@ const ruleFunction = (primary, secondaryOptions) => {
93
93
  node.value,
94
94
  formatLength(nearest.lower, nearestUnit),
95
95
  formatLength(nearest.upper, nearestUnit),
96
+ [tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)), options.note].filter(Boolean).join(' '),
96
97
  ),
97
98
  node,
98
99
  replacement: fixedValue,
@@ -38,22 +38,27 @@ const ruleName = 'rhythmguard/prefer-token';
38
38
  const messages = stylelint.utils.ruleMessages(ruleName, {
39
39
  invalidPreset: (presetName, presetNames) =>
40
40
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
41
- rejected: (value) =>
42
- `Unexpected raw scale value "${value}". Use design tokens for scale decisions.`,
41
+ rejected: (value, replacement = null, origin = null, note = '') =>
42
+ `Unexpected raw scale value "${value}". ${replacement
43
+ ? `Use ${replacement}${origin ? ` (${origin})` : ''}.`
44
+ : 'No known token holds this value; use the nearest token or add one.'}${note ? ` ${note}` : ''}`,
43
45
  });
44
46
 
45
47
  function applyNegativeToken(replacement, parsedLength) {
46
48
  return !replacement || parsedLength.number >= 0 ? replacement : negateReplacement(replacement);
47
49
  }
48
50
 
51
+ /** The token for a raw length, and the text the fix writes (negated when the literal is). */
49
52
  function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
53
+ const found = (token) => ({ replacement: applyNegativeToken(token, parsedLength), token });
54
+
50
55
  if (Object.prototype.hasOwnProperty.call(tokenMap, raw)) {
51
- return applyNegativeToken(tokenMap[raw], parsedLength);
56
+ return found(tokenMap[raw]);
52
57
  }
53
58
 
54
59
  const absoluteRaw = formatLength(Math.abs(parsedLength.number), parsedLength.unit || 'px');
55
60
  if (Object.prototype.hasOwnProperty.call(tokenMap, absoluteRaw)) {
56
- return applyNegativeToken(tokenMap[absoluteRaw], parsedLength);
61
+ return found(tokenMap[absoluteRaw]);
57
62
  }
58
63
 
59
64
  if (options.unitStrategy === 'convert') {
@@ -61,7 +66,7 @@ function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
61
66
  if (absPx !== null) {
62
67
  const pxKey = `${absPx}px`;
63
68
  if (Object.prototype.hasOwnProperty.call(tokenMap, pxKey)) {
64
- return applyNegativeToken(tokenMap[pxKey], parsedLength);
69
+ return found(tokenMap[pxKey]);
65
70
  }
66
71
  }
67
72
  }
@@ -92,8 +97,10 @@ const ruleFunction = (primary, secondaryOptions) => {
92
97
  withResolvedScale(options, root);
93
98
 
94
99
  const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
100
+ const origins = {};
95
101
  const tokenMap = buildEffectiveTokenMap({
96
102
  options,
103
+ origins,
97
104
  root,
98
105
  tokenRegex,
99
106
  });
@@ -114,8 +121,10 @@ const ruleFunction = (primary, secondaryOptions) => {
114
121
  const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
115
122
  let changed = false;
116
123
 
117
- const reportNode = (node, replacement = null) => {
118
- reportValueNode({ decl, message: messages.rejected(node.value), node, replacement, result, ruleName });
124
+ const reportNode = (node, resolved = null) => {
125
+ const replacement = resolved ? resolved.replacement : null;
126
+ const origin = resolved ? origins[resolved.token] || null : null;
127
+ reportValueNode({ decl, message: messages.rejected(node.value, replacement, origin, options.note), node, replacement, result, ruleName });
119
128
  };
120
129
 
121
130
  const checkWordNode = (node, context) => {
@@ -181,9 +190,7 @@ const ruleFunction = (primary, secondaryOptions) => {
181
190
  }
182
191
  }
183
192
 
184
- const replacement = resolveTokenReplacement(tokenMap, node.value, parsedLength, options);
185
-
186
- reportNode(node, replacement);
193
+ reportNode(node, resolveTokenReplacement(tokenMap, node.value, parsedLength, options));
187
194
  return true;
188
195
  };
189
196
 
@@ -20,6 +20,7 @@ const {
20
20
 
21
21
  const { reportValueNode } = require('../report');
22
22
  const { validatePrimary } = require('../validate');
23
+ const { isNote, normalizeNote, withNote } = require('../../core/options');
23
24
 
24
25
  const ruleName = 'rhythmguard/use-motion-scale';
25
26
  const DURATION_PROPERTIES = new Set([
@@ -39,12 +40,12 @@ const EASING_PROPERTIES = new Set([
39
40
  const EASING_FUNCTIONS = new Set(['cubic-bezier', 'linear', 'steps']);
40
41
 
41
42
  const messages = stylelint.utils.ruleMessages(ruleName, {
42
- invalidDuration: (value) =>
43
- `Unexpected negative motion duration "${value}". Use non-negative duration values.`,
44
- rejectedDuration: (value, lower, upper) =>
45
- `Unexpected motion duration "${value}". Use duration scale values (nearest: ${lower} or ${upper}).`,
46
- rejectedEasing: (value) =>
47
- `Unexpected raw motion easing "${value}". Use motion tokens for easing decisions.`,
43
+ invalidDuration: (value, note = '') =>
44
+ withNote(`Unexpected negative motion duration "${value}". Use non-negative duration values.`, note),
45
+ rejectedDuration: (value, lower, upper, note = '') =>
46
+ withNote(`Unexpected motion duration "${value}". Use duration scale values (nearest: ${lower} or ${upper}).`, note),
47
+ rejectedEasing: (value, note = '') =>
48
+ withNote(`Unexpected raw motion easing "${value}". Use motion tokens for easing decisions.`, note),
48
49
  });
49
50
 
50
51
  function isPlainObject(value) {
@@ -59,6 +60,7 @@ function buildOptions(rawOptions) {
59
60
  durationUnits: normalizeDurationUnits(options.durationUnits),
60
61
  easingTokenMap: isPlainObject(options.easingTokenMap) ? options.easingTokenMap : {},
61
62
  fixToScale: options.fixToScale !== false,
63
+ note: normalizeNote(options.note),
62
64
  };
63
65
  }
64
66
 
@@ -78,6 +80,7 @@ function validateSecondaryOptions(result, secondaryOptions) {
78
80
  typeof entry === 'string' && entry.trim().length > 0,
79
81
  )],
80
82
  fixToScale: [true, false],
83
+ note: [isNote],
81
84
  },
82
85
  });
83
86
  }
@@ -124,8 +127,9 @@ const ruleFunction = (primary, secondaryOptions) => {
124
127
  node.value,
125
128
  formatTime(nearest.lower, 'ms'),
126
129
  formatTime(nearest.upper, 'ms'),
130
+ options.note,
127
131
  )
128
- : messages.invalidDuration(node.value),
132
+ : messages.invalidDuration(node.value, options.note),
129
133
  node,
130
134
  replacement: fixedValue,
131
135
  result,
@@ -146,7 +150,7 @@ const ruleFunction = (primary, secondaryOptions) => {
146
150
  }
147
151
  : null,
148
152
  length: source.length,
149
- message: messages.rejectedEasing(source),
153
+ message: messages.rejectedEasing(source, options.note),
150
154
  node,
151
155
  result,
152
156
  ruleName,
@@ -32,7 +32,7 @@ const {
32
32
 
33
33
  const { createTokenRegex, reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
34
34
  const { decisionFor, loadRcDecisions } = require('../../core/decisions');
35
- const { replacementFor, tokenIndexFromDefinitions } = require('../../core/token-index');
35
+ const { replacementFor, tokenHoldsNote, tokenIndexFromDefinitions } = require('../../core/token-index');
36
36
  const { validatePrimary, validateUseScaleSecondaryOptions } = require('../validate');
37
37
 
38
38
  const ruleName = 'rhythmguard/use-scale';
@@ -198,9 +198,10 @@ const ruleFunction = (primary, secondaryOptions) => {
198
198
  const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px') => {
199
199
  const lower = nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a';
200
200
  const upper = nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a';
201
+ const tokenNote = nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)) : '';
201
202
  reportValueNode({
202
203
  decl,
203
- message: messages.rejected(value, lower, upper, fallbackNote),
204
+ message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, options.note].filter(Boolean).join(' ')),
204
205
  node,
205
206
  replacement: fixedValue,
206
207
  result,
package/types/shared.d.ts CHANGED
@@ -21,6 +21,8 @@ export interface RhythmguardRuleOptions {
21
21
  fixWith?: "value" | "token";
22
22
  /** Read the `decisions` section of `.rhythmguardrc.json` (default true). The audit sets false and applies decisions itself. */
23
23
  decisions?: boolean;
24
+ /** A sentence of up to 200 characters appended to every finding, after the built-in text. */
25
+ note?: string;
24
26
  baseFontSize?: number;
25
27
  customScale?: ScaleValue[];
26
28
  includeMathFunctions?: boolean;