stylelint-plugin-rhythmguard 3.9.0 → 3.10.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,28 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [3.10.1] - 2026-10-15
10
+
11
+ ### Fixed
12
+
13
+ - 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.
14
+
15
+ ## [3.10.0] - 2026-10-15
16
+
17
+ ### Added
18
+
19
+ - The audit reports token ladders as `contracts.tokens.ladders`: custom properties that share a prefix and a size suffix (`xxs` to `xxl`, `sm`/`md`/`lg`, `1` to `n`) are evaluated at their smallest and largest value (a static length, `clamp()` bounds, the static arguments of `min()` and `max()`, through `var()`) and neighbours are compared at both ends. Collapsed steps (the same size), steps under `minStepPx` apart (default 2px, `audit.ladders.minStepPx`) and inverted steps are reported, and the Markdown report shows each such ladder as a min and max table. A fluid body ladder whose three smallest steps all start at 14px now says so. The section adds no findings and does not change the exit code. ([#205](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/205))
20
+ - `scales` on `use-scale` and `prefer-token`: a scale per property group (`spacing`, `radius`, `size`, `typography`), so a type ladder such as 14, 16, 18, 20, 24, 32, 40 can sit next to a 4px spacing scale in one rule block. A group takes values, a preset name or `"auto"`, which reads the group's own tokens (`--font-size-*` and `--text-*`, `--radius-*`). `propertyScales` still wins for its property. Findings checked against a group scale say so: `Use typography scale values (nearest: 16px or 18px).`; every other message is unchanged. The audit takes the same map as `audit.scales` and checks each group it names. ([#204](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/204))
21
+ - `checkMathBounds` on `use-scale` and `prefer-token`, and `--check-math-bounds` (or `audit.checkMathBounds`) on the audit: the minimum and maximum of `clamp()` and each length argument of `min()` and `max()` are checked like literals, with the position in the message (`Unexpected off-scale clamp() maximum "1.05rem" (16.8px). ...`). Before, wrapping a value in `clamp()` was a way to leave the scale without a finding. The preferred value of `clamp()` is fluid by design and never checked; neither is an expression, a `var()` or a viewport length. `--fix` and `fixWith: "token"` apply per bound. Off by default in 3.x because it adds findings to existing configs; on by default is a candidate for 4.0. ([#203](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/203))
22
+
23
+ ### Fixed
24
+
25
+ - `outline-offset` is no longer offered radius tokens. It is checked with the `radius` property group, so 3.9.0's token-kind matching pointed it at `--radius-*`: on one consumer, all 89 `prefer-token` warnings that named a radius token were on `outline-offset` (`outline-offset: -2px` read `Use calc(-1 * var(--radius-sm))`). It is the gap between the border edge and the outline, a spacing length, so it now takes spacing tokens, and under `scales` the spacing scale. Which properties each group checks is unchanged. A contract test asserts that no property but the corner radii resolves to a radius token.
26
+
27
+ ### Messages
28
+
29
+ - `prefer-token` on `outline-offset`, when a radius token held the value: was `Use var(--radius-*)`, now names the spacing token that holds it, or `No known token holds this value; ...` when none does.
30
+
9
31
  ## [3.9.0] - 2026-10-15
10
32
 
11
33
  ### Added
package/README.md CHANGED
@@ -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=390)](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=3101)](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.9.0",
3
+ "version": "3.10.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"
package/src/audit/args.js CHANGED
@@ -54,6 +54,8 @@ const OPTIONS = [
54
54
  { flag: '--since', kind: 'value', value: '<git-ref>', help: 'Scan only changed files since a git ref',
55
55
  apply: (p, raw) => { p.since = parsePathOption(raw, '--since'); setFrom(p, 'since'); } },
56
56
  { flag: '--staged', kind: 'flag', help: 'Scan only staged files', apply: (p) => { p.staged = true; setFrom(p, 'staged'); } },
57
+ { flag: '--check-math-bounds', kind: 'flag', help: 'Check clamp() minimum and maximum and min()/max() arguments against the scale',
58
+ apply: (p) => { p.checkMathBounds = true; setFrom(p, 'checkMathBounds'); } },
57
59
  { flag: '--include-motion', kind: 'flag', help: 'Include opt-in motion duration/easing drift',
58
60
  apply: (p) => { p.includeMotion = true; setFrom(p, 'includeMotion'); } },
59
61
  { flag: '--token-source', kind: 'value', value: '<file>', help: 'External token source (repeatable, comma-separated)',
@@ -3,6 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { normalizeDecisions } = require('../core/decisions');
6
+ const { SCALED_GROUPS, isGroupScalesMap } = require('../core/options');
6
7
  const {
7
8
  normalizeTokenKind,
8
9
  normalizeTokenSourceFormat,
@@ -109,6 +110,11 @@ function applyAuditConfig(parsed, configResult) {
109
110
  applyConfigScalar(next, audit, cliOptions, 'tokenKind', 'tokenKind', normalizeTokenKind);
110
111
  applyConfigScalar(next, audit, cliOptions, 'baseFontSize', 'baseFontSize', parseBaseFontSize);
111
112
  applyConfigScalar(next, audit, cliOptions, 'includeMotion', 'includeMotion', parseBooleanOption);
113
+ applyConfigScalar(next, audit, cliOptions, 'checkMathBounds', 'checkMathBounds', parseBooleanOption);
114
+ applyConfigScalar(next, audit, cliOptions, 'scales', 'scales', parseGroupScales);
115
+ if (audit.ladders !== undefined) {
116
+ next.ladderMinStepPx = parseLadderOptions(audit.ladders);
117
+ }
112
118
  applyConfigScalar(next, audit, cliOptions, 'scale', 'scale', (value) => {
113
119
  if (Array.isArray(value)) {
114
120
  return value;
@@ -130,6 +136,23 @@ function applyConfigScalar(target, audit, cliOptions, targetKey, configKey, pars
130
136
  target[targetKey] = parser(audit[configKey], configKey);
131
137
  }
132
138
 
139
+ function parseLadderOptions(value) {
140
+ const minStepPx = value && typeof value === 'object' && !Array.isArray(value) ? value.minStepPx : undefined;
141
+ if (typeof minStepPx !== 'number' || !Number.isFinite(minStepPx) || minStepPx < 0) {
142
+ throw new Error('ladders.minStepPx must be a non-negative number of px.');
143
+ }
144
+
145
+ return minStepPx;
146
+ }
147
+
148
+ function parseGroupScales(value, optionName) {
149
+ if (!isGroupScalesMap(value)) {
150
+ throw new Error(`${optionName} must map ${SCALED_GROUPS.join(', ')} to a scale, a preset name or "auto".`);
151
+ }
152
+
153
+ return value;
154
+ }
155
+
133
156
  function parseBooleanOption(value, optionName) {
134
157
  if (typeof value !== 'boolean') {
135
158
  throw new Error(`${optionName} must be a boolean.`);
@@ -55,6 +55,7 @@ function renderMarkdown(report) {
55
55
  appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
56
56
  appendTokenContractMarkdown(lines, report.tokenContract);
57
57
  appendTokenChainsMarkdown(lines, report.tokenContract.chains);
58
+ appendTokenLaddersMarkdown(lines, report.tokenContract.ladders);
58
59
  appendBaselineMarkdown(lines, report);
59
60
 
60
61
  if (report.topAffectedFiles.length > 0) {
@@ -118,6 +119,40 @@ function appendTokenChainsMarkdown(lines, chains) {
118
119
  }
119
120
  }
120
121
 
122
+ const LADDER_PROBLEM_TEXT = Object.freeze({
123
+ collapsed: 'collapsed',
124
+ inverted: 'inverted',
125
+ 'too-close': 'too close',
126
+ });
127
+
128
+ function appendTokenLaddersMarkdown(lines, ladders) {
129
+ if (!ladders || ladders.entries.length === 0) {
130
+ return;
131
+ }
132
+ const { summary } = ladders;
133
+ lines.push('## Token Ladders');
134
+ lines.push('');
135
+ lines.push(`${ladders.entries.length} of ${summary.ladders} token ladders have steps that do not read as steps: ${summary.collapsed} collapsed, ${summary.tooClose} under ${summary.minStepPx}px apart, ${summary.inverted} inverted. Each step is shown at its smallest and largest value.`);
136
+ lines.push('');
137
+ for (const entry of ladders.entries.slice(0, 20)) {
138
+ const flagged = new Map();
139
+ for (const problem of entry.problems) {
140
+ const where = problem.end === 'both' ? '' : ` at ${problem.end}`;
141
+ const note = `${LADDER_PROBLEM_TEXT[problem.type]}${where} with \`${problem.tokens[0]}\``;
142
+ flagged.set(problem.tokens[1], [...(flagged.get(problem.tokens[1]) || []), note]);
143
+ }
144
+ const aliasNote = entry.aliases ? ` (also aliased as ${entry.aliases.map((alias) => `\`${escapeMarkdown(alias)}\``).join(', ')})` : '';
145
+ lines.push(`**\`${escapeMarkdown(entry.prefix)}\`**${aliasNote}`);
146
+ lines.push('');
147
+ lines.push('| Token | Min | Max | Problem |');
148
+ lines.push('| --- | --- | --- | --- |');
149
+ for (const step of entry.steps) {
150
+ lines.push(`| \`${escapeMarkdown(step.token)}\` | ${step.min || 'fluid'} | ${step.max || 'fluid'} | ${(flagged.get(step.token) || []).join('; ')} |`);
151
+ }
152
+ lines.push('');
153
+ }
154
+ }
155
+
121
156
  function appendTokenContractMarkdown(lines, tokenContract) {
122
157
  const {
123
158
  conflictingTokens,
@@ -51,6 +51,11 @@ function renderText(report) {
51
51
  lines.push(` Token chains ${summary['on-scale']} of ${summary.total} resolve to the scale (${summary['off-scale']} off-scale, ${summary.unresolved} unresolved, ${summary.ambiguous} ambiguous)`);
52
52
  lines.push('');
53
53
  }
54
+ if (report.tokenContract.ladders && report.tokenContract.ladders.summary.ladders > 0) {
55
+ const { summary } = report.tokenContract.ladders;
56
+ lines.push(` Token ladders ${summary.ladders} checked (${summary.collapsed} collapsed, ${summary.tooClose} under ${summary.minStepPx}px apart, ${summary.inverted} inverted steps)`);
57
+ lines.push('');
58
+ }
54
59
  appendBaselineText(lines, report);
55
60
 
56
61
  if (report.topAffectedFiles.length > 0) {
@@ -26,6 +26,7 @@ const {
26
26
  } = require('../core/scale-inference');
27
27
  const { applyDecisions, buildDecisionPlan } = require('./decisions');
28
28
  const { collectDeclarations, resolveTokenChains, toChainsContract } = require('./token-chains');
29
+ const { resolveTokenLadders, toLaddersContract } = require('./token-ladders');
29
30
  const { assertDirectory, getScanFiles } = require('./scan/files');
30
31
  const { collectCssFindings, runStylelintAudit } = require('./scan/stylesheets');
31
32
  const { collectTailwindFindings, collectTailwindMotionFindings } = require('./scan/templates');
@@ -58,8 +59,10 @@ async function createAuditReport(options) {
58
59
 
59
60
  const lintOptions = {
60
61
  baseFontSize: parsed.baseFontSize,
62
+ checkMathBounds: parsed.checkMathBounds,
61
63
  includeMotion: parsed.includeMotion,
62
64
  scale: scale.values,
65
+ scales: parsed.scales,
63
66
  };
64
67
 
65
68
  const cssResults = await runStylelintAudit(cssFiles, lintOptions);
@@ -98,15 +101,21 @@ async function createAuditReport(options) {
98
101
  tokenSourceWarnings: tokenSourceResult.warnings,
99
102
  });
100
103
 
104
+ const declarations = collectDeclarations({
105
+ cssFiles,
106
+ externalDefinitions: tokenSourceResult.definitions,
107
+ skipFile: (file) => NON_AUTHORED_SEGMENT.test(file),
108
+ });
101
109
  report.tokenContract.chains = toChainsContract(resolveTokenChains({
102
110
  baseFontSize: parsed.baseFontSize,
103
- declarations: collectDeclarations({
104
- cssFiles,
105
- externalDefinitions: tokenSourceResult.definitions,
106
- skipFile: (file) => NON_AUTHORED_SEGMENT.test(file),
107
- }),
111
+ declarations,
108
112
  scale: scale.values,
109
113
  }));
114
+ report.tokenContract.ladders = toLaddersContract(resolveTokenLadders({
115
+ baseFontSize: parsed.baseFontSize,
116
+ declarations,
117
+ minStepPx: parsed.ladderMinStepPx,
118
+ }));
110
119
  report.decisions = decided.summary;
111
120
  report.decisionPlan = buildDecisionPlan({
112
121
  baseFontSize: parsed.baseFontSize,
@@ -47,7 +47,10 @@ async function runStylelintAudit(cssFiles, options) {
47
47
  {
48
48
  decisions: false,
49
49
  baseFontSize: options.baseFontSize,
50
+ checkMathBounds: options.checkMathBounds === true,
50
51
  scale: options.scale,
52
+ // audit.scales adds the groups it names, each checked against its own scale.
53
+ ...(options.scales ? { propertyGroups: ['spacing', ...Object.keys(options.scales)], scales: options.scales } : {}),
51
54
  severity: 'warning',
52
55
  },
53
56
  ],
@@ -55,6 +58,7 @@ async function runStylelintAudit(cssFiles, options) {
55
58
  true,
56
59
  {
57
60
  baseFontSize: options.baseFontSize,
61
+ checkMathBounds: options.checkMathBounds === true,
58
62
  scale: options.scale,
59
63
  severity: 'warning',
60
64
  tokenMapFromCssCustomProperties: true,
@@ -127,10 +131,10 @@ function collectCssFindings(fileResults) {
127
131
  const text = warning.text || '';
128
132
  const source = readSourceOnce(sources, fileResult.source);
129
133
  const offScaleMatch = text.match(
130
- /Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
134
+ /Unexpected (?:off-scale (?:value|(?:clamp|min|max)\(\) (?:minimum|maximum|argument))|transform translation value) "([^"]+)"/,
131
135
  );
132
136
  const tokenMatch = text.match(
133
- /Unexpected raw scale value "([^"]+)"/,
137
+ /Unexpected raw (?:scale value|(?:clamp|min|max)\(\) (?:minimum|maximum|argument)) "([^"]+)"/,
134
138
  );
135
139
  const motionDurationMatch = text.match(
136
140
  /Unexpected (?:motion duration|negative motion duration) "([^"]+)"/,
@@ -75,12 +75,15 @@ function createDefaultAuditOptions() {
75
75
  format: 'text',
76
76
  ignorePath: DEFAULT_IGNORE_PATH,
77
77
  ignorePatterns: [],
78
+ checkMathBounds: false,
78
79
  includeMotion: false,
80
+ ladderMinStepPx: 2,
79
81
  maxFindings: null,
80
82
  minCleanliness: null,
81
83
  noConfig: false,
82
84
  outputPath: null,
83
85
  scale: DEFAULT_SCALE,
86
+ scales: null,
84
87
  schema: false,
85
88
  since: null,
86
89
  sinceBaseline: false,
@@ -0,0 +1,246 @@
1
+ 'use strict';
2
+
3
+ const valueParser = require('postcss-value-parser');
4
+ const { formatLength, numbersEqual, parseLengthToken, toPx } = require('../core/length');
5
+
6
+ /**
7
+ * Token ladders (issue #205). A ladder is a set of custom properties that
8
+ * share a prefix and differ by a size step: `--font-size-text-xxs` to
9
+ * `-xxl`, `--space-1` to `--space-9`, `--radius-sm/md/lg`. Every step can be
10
+ * on the scale and the ladder still be broken, so each step is evaluated at
11
+ * its smallest and largest value (a static length, a clamp() minimum and
12
+ * maximum, the static arguments of min() and max(), followed through var())
13
+ * and adjacent steps are compared at both ends:
14
+ *
15
+ * collapsed two steps render the same size at one end
16
+ * too-close two steps differ by less than `minStepPx` (default 2px)
17
+ * inverted a step is larger than the next one at one end
18
+ *
19
+ * Each problem names the end it occurs at: `min`, `max`, or `both`.
20
+ *
21
+ * A step that cannot be evaluated (calc(), a viewport length, a token with
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.
24
+ */
25
+ const DEFAULT_MIN_STEP_PX = 2;
26
+ const MIN_LADDER_STEPS = 3;
27
+ const VAR_REFERENCE = /^var\(\s*(--[\w-]+)\s*(?:,\s*([\s\S]+))?\)$/i;
28
+
29
+ // T-shirt steps in order. Aliases share a rank (sm and s, 2xl and xxl).
30
+ const SIZE_RANKS = new Map([
31
+ ['xxxs', -4], ['3xs', -4], ['xxs', -3], ['2xs', -3], ['xs', -2],
32
+ ['s', -1], ['sm', -1], ['small', -1],
33
+ ['m', 0], ['md', 0], ['medium', 0], ['base', 0],
34
+ ['l', 1], ['lg', 1], ['large', 1],
35
+ ['xl', 2], ['xxl', 3], ['2xl', 3], ['xxxl', 4], ['3xl', 4],
36
+ ['4xl', 5], ['5xl', 6], ['6xl', 7], ['7xl', 8], ['8xl', 9], ['9xl', 10],
37
+ ]);
38
+ const STEP_SUFFIX = /^(--[\w-]+?)-([a-z0-9]+)$/i;
39
+
40
+ /** The ladder a token belongs to and its rank on it, or null. */
41
+ function ladderStep(token) {
42
+ const match = token.match(STEP_SUFFIX);
43
+ if (!match) {
44
+ return null;
45
+ }
46
+ const [, prefix, suffix] = match;
47
+ const lower = suffix.toLowerCase();
48
+ if (SIZE_RANKS.has(lower)) {
49
+ return { key: `${prefix} (sizes)`, prefix, rank: SIZE_RANKS.get(lower) };
50
+ }
51
+ if (/^\d+$/.test(lower)) {
52
+ return { key: `${prefix} (numbers)`, prefix, rank: Number(lower) };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function splitArguments(fnNode) {
58
+ const args = [[]];
59
+ for (const node of fnNode.nodes) {
60
+ if (node.type === 'div' && node.value === ',') {
61
+ args.push([]);
62
+ } else {
63
+ args[args.length - 1].push(node);
64
+ }
65
+ }
66
+ return args.map((nodes) => valueParser.stringify(nodes).trim());
67
+ }
68
+
69
+ function createEvaluator(declarations, baseFontSize) {
70
+ const memo = new Map();
71
+
72
+ const evaluateToken = (token, seen) => {
73
+ if (memo.has(token)) return memo.get(token);
74
+ const values = declarations.get(token);
75
+ if (!values || seen.has(token)) return null;
76
+ const ranges = [...values].map((value) => evaluate(value, new Set([...seen, token])));
77
+ const [first] = ranges;
78
+ const agree = first && ranges.every((range) => range && sameEnd(range.min, first.min) && sameEnd(range.max, first.max));
79
+ const result = agree ? first : null;
80
+ memo.set(token, result);
81
+ return result;
82
+ };
83
+
84
+ // { min, max } in px; an end is null when the value has no static bound there.
85
+ const evaluate = (raw, seen = new Set()) => {
86
+ const value = String(raw).trim();
87
+ const reference = value.match(VAR_REFERENCE);
88
+ if (reference) {
89
+ const [, target, fallback] = reference;
90
+ if (declarations.has(target)) return evaluateToken(target, seen);
91
+ return fallback === undefined ? null : evaluate(fallback, seen);
92
+ }
93
+ const parsed = valueParser(value).nodes.filter((node) => node.type !== 'space');
94
+ if (parsed.length !== 1) return null;
95
+ const [node] = parsed;
96
+ if (node.type === 'word') {
97
+ const length = parseLengthToken(node.value);
98
+ if (!length || !length.unit || length.unit === '%') return null;
99
+ const px = toPx(Math.abs(length.number), length.unit, baseFontSize);
100
+ if (px === null) return null;
101
+ const signed = length.number < 0 ? -px : px;
102
+ return { max: signed, min: signed };
103
+ }
104
+ if (node.type !== 'function') return null;
105
+ const name = node.value.toLowerCase();
106
+ const args = splitArguments(node).map((arg) => evaluate(arg, seen));
107
+ if (name === 'clamp') {
108
+ if (args.length !== 3 || !args[0] || !args[2] || args[0].min === null || args[2].max === null) return null;
109
+ return { max: args[2].max, min: args[0].min };
110
+ }
111
+ if (name === 'min' || name === 'max') {
112
+ const statics = args.filter((range) => range && range.min !== null && range.max !== null);
113
+ if (statics.length === 0) return null;
114
+ const pick = name === 'min' ? Math.min : Math.max;
115
+ const all = statics.length === args.length;
116
+ // min(5vw, 2rem) is at most 2rem and has no static minimum; max() mirrors it.
117
+ return {
118
+ max: name === 'min' || all ? pick(...statics.map((range) => range.max)) : null,
119
+ min: name === 'max' || all ? pick(...statics.map((range) => range.min)) : null,
120
+ };
121
+ }
122
+ return null;
123
+ };
124
+
125
+ return { evaluate, evaluateToken };
126
+ }
127
+
128
+ function sameEnd(a, b) {
129
+ return (a === null && b === null) || (a !== null && b !== null && numbersEqual(a, b));
130
+ }
131
+
132
+ function compareEnds(lower, upper, end, minStepPx) {
133
+ const a = lower[end];
134
+ const b = upper[end];
135
+ if (a === null || b === null) return null;
136
+ if (numbersEqual(a, b)) return { type: 'collapsed' };
137
+ if (a > b) return { type: 'inverted' };
138
+ if (b - a < minStepPx) return { difference: Math.round((b - a) * 1000) / 1000, type: 'too-close' };
139
+ return null;
140
+ }
141
+
142
+ /**
143
+ * Every ladder of at least three evaluable steps, with the problems between
144
+ * adjacent steps. `declarations` is the token-chain map: token to the set of
145
+ * values it is declared with.
146
+ */
147
+ function resolveTokenLadders({ baseFontSize = 16, declarations, minStepPx = DEFAULT_MIN_STEP_PX }) {
148
+ const { evaluateToken } = createEvaluator(declarations, baseFontSize);
149
+ const ladders = new Map();
150
+ for (const token of declarations.keys()) {
151
+ const step = ladderStep(token);
152
+ if (!step) continue;
153
+ const range = evaluateToken(token, new Set());
154
+ if (!range) continue;
155
+ if (!ladders.has(step.key)) ladders.set(step.key, { prefix: step.prefix, steps: [] });
156
+ const ladder = ladders.get(step.key);
157
+ // An alias for a rank already on the ladder (s next to sm) is not a step.
158
+ if (ladder.steps.some((existing) => existing.rank === step.rank)) continue;
159
+ ladder.steps.push({ max: range.max, min: range.min, rank: step.rank, token });
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
+
190
+ const entries = [];
191
+ for (const ladder of eligible) {
192
+ if (aliases.has(ladder)) continue;
193
+ const steps = ladder.steps.sort((a, b) => a.rank - b.rank);
194
+ const problems = [];
195
+ for (let index = 0; index < steps.length - 1; index += 1) {
196
+ const tokens = [steps[index].token, steps[index + 1].token];
197
+ const atMin = compareEnds(steps[index], steps[index + 1], 'min', minStepPx);
198
+ const atMax = compareEnds(steps[index], steps[index + 1], 'max', minStepPx);
199
+ // The same problem at both ends (always, for static steps) is one problem.
200
+ if (atMin && atMax && atMin.type === atMax.type && atMin.difference === atMax.difference) {
201
+ problems.push({ end: 'both', tokens, ...atMin });
202
+ continue;
203
+ }
204
+ if (atMin) problems.push({ end: 'min', tokens, ...atMin });
205
+ if (atMax) problems.push({ end: 'max', tokens, ...atMax });
206
+ }
207
+ entries.push({
208
+ ...(ladder.aliases ? { aliases: ladder.aliases } : {}),
209
+ prefix: ladder.prefix,
210
+ problems,
211
+ steps: steps.map(({ max, min, token }) => ({
212
+ max: max === null ? null : formatLength(max, 'px'),
213
+ min: min === null ? null : formatLength(min, 'px'),
214
+ token,
215
+ })),
216
+ });
217
+ }
218
+ entries.sort((a, b) => a.prefix.localeCompare(b.prefix));
219
+
220
+ const count = (type) => entries.reduce((sum, entry) => sum + entry.problems.filter((problem) => problem.type === type).length, 0);
221
+ return {
222
+ entries,
223
+ summary: {
224
+ collapsed: count('collapsed'),
225
+ inverted: count('inverted'),
226
+ ladders: entries.length,
227
+ minStepPx,
228
+ tooClose: count('too-close'),
229
+ },
230
+ };
231
+ }
232
+
233
+ /** The contract shape: counts for every ladder, entries only for ladders with a problem. */
234
+ function toLaddersContract(ladders) {
235
+ return {
236
+ entries: ladders.entries.filter((entry) => entry.problems.length > 0),
237
+ summary: ladders.summary,
238
+ };
239
+ }
240
+
241
+ module.exports = {
242
+ DEFAULT_MIN_STEP_PX,
243
+ ladderStep,
244
+ resolveTokenLadders,
245
+ toLaddersContract,
246
+ };
@@ -41,6 +41,30 @@ const PROPERTY_GROUP_PATTERNS = Object.freeze({
41
41
  ]),
42
42
  });
43
43
 
44
+ /** The property group a CSS property belongs to, or null. */
45
+ function propertyGroupOf(prop) {
46
+ const lower = String(prop || '').toLowerCase();
47
+ const found = Object.entries(PROPERTY_GROUP_PATTERNS).find(([, patterns]) => patterns.some((pattern) => pattern.test(lower)));
48
+ return found ? found[0] : null;
49
+ }
50
+
51
+ // Properties whose length is a different kind from the group that checks them.
52
+ // outline-offset is checked with the radius group (it always has been), but it
53
+ // is the gap between the border edge and the outline: a spacing length. A
54
+ // radius token there would tie every focus ring to the corner radius.
55
+ const PROPERTY_KIND_OVERRIDES = Object.freeze({
56
+ 'outline-offset': 'spacing',
57
+ });
58
+
59
+ /**
60
+ * The kind of length a property takes, for choosing tokens and a group's
61
+ * scale. The property group, except where PROPERTY_KIND_OVERRIDES says otherwise.
62
+ */
63
+ function propertyKindOf(prop) {
64
+ const lower = String(prop || '').toLowerCase();
65
+ return PROPERTY_KIND_OVERRIDES[lower] || propertyGroupOf(lower);
66
+ }
67
+
44
68
  const DEFAULT_PROPERTY_GROUPS = Object.freeze(['spacing']);
45
69
  const PROPERTY_GROUP_NAMES = Object.freeze(Object.keys(PROPERTY_GROUP_PATTERNS));
46
70
 
@@ -101,6 +125,8 @@ const SUPPORTED_SCALE_UNITS = new Set([
101
125
  const DEFAULT_MATH_FUNCTION_ARGUMENT_FILTER = Object.freeze({});
102
126
 
103
127
  module.exports = {
128
+ propertyGroupOf,
129
+ propertyKindOf,
104
130
  DEFAULT_IGNORE_KEYWORDS,
105
131
  DEFAULT_MATH_FUNCTION_ARGUMENT_FILTER,
106
132
  DEFAULT_PROPERTY_GROUPS,
@@ -11,6 +11,7 @@ const {
11
11
  PROPERTY_GROUP_PATTERNS,
12
12
  SPACING_PROPERTY_PATTERNS,
13
13
  SUPPORTED_SCALE_UNITS,
14
+ propertyKindOf,
14
15
  } = require('./css-vocabulary');
15
16
  const { parseLengthToken } = require('./length');
16
17
  const {
@@ -257,6 +258,42 @@ function isPropertyScaleMap(value) {
257
258
  });
258
259
  }
259
260
 
261
+ // Groups that take a length scale of their own; motion has use-motion-scale.
262
+ const SCALED_GROUPS = Object.freeze(['spacing', 'radius', 'size', 'typography']);
263
+
264
+ /** One group's scale: values, a preset name, "auto", or a { preset | scale | customScale } object. */
265
+ function isGroupScaleEntry(value) {
266
+ if (isAutoScaleLiteral(value)) {
267
+ return true;
268
+ }
269
+ if (typeof value === 'string') {
270
+ return Boolean(getScalePreset(value));
271
+ }
272
+ return isScaleOverrideEntry(value);
273
+ }
274
+
275
+ function isGroupScalesMap(value) {
276
+ return isPlainObject(value)
277
+ && Object.entries(value).every(([group, scale]) => SCALED_GROUPS.includes(group) && isGroupScaleEntry(scale));
278
+ }
279
+
280
+ /**
281
+ * `scales` normalised to { group: { auto, scale } }. A group set to "auto"
282
+ * has no scale until the rule infers one from that group's tokens.
283
+ */
284
+ function buildGroupScales(scales) {
285
+ if (!isGroupScalesMap(scales)) {
286
+ return {};
287
+ }
288
+ return Object.fromEntries(Object.entries(scales).map(([group, entry]) => {
289
+ if (isAutoScaleLiteral(entry)) {
290
+ return [group, { auto: true, scale: null }];
291
+ }
292
+ const scale = typeof entry === 'string' ? getScalePreset(entry) : resolveScaleOverride(entry);
293
+ return [group, { auto: false, scale: Array.isArray(scale) ? [...scale] : null }];
294
+ }));
295
+ }
296
+
260
297
  function buildPossibleOptionMap(schema) {
261
298
  return Object.fromEntries(
262
299
  Object.entries(schema).map(([optionName, descriptor]) => [
@@ -398,6 +435,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
398
435
  entryValidator: isScaleEntry,
399
436
  expectsArray: true,
400
437
  }),
438
+ checkMathBounds: Object.freeze({
439
+ entryValidator: isBoolean,
440
+ }),
401
441
  enforceInsideMathFunctions: Object.freeze({
402
442
  entryValidator: isBoolean,
403
443
  }),
@@ -460,6 +500,10 @@ const USE_SCALE_VALIDATION_SCHEMA = Object.freeze({
460
500
  entryValidator: isPropertyScaleMap,
461
501
  expectsObject: true,
462
502
  }),
503
+ scales: Object.freeze({
504
+ entryValidator: isGroupScalesMap,
505
+ expectsObject: true,
506
+ }),
463
507
  tokenFunctions: Object.freeze({
464
508
  entryValidator: isNonEmptyString,
465
509
  expectsArray: true,
@@ -487,6 +531,9 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
487
531
  entryValidator: isScaleEntry,
488
532
  expectsArray: true,
489
533
  }),
534
+ checkMathBounds: Object.freeze({
535
+ entryValidator: isBoolean,
536
+ }),
490
537
  enforceInsideMathFunctions: Object.freeze({
491
538
  entryValidator: isBoolean,
492
539
  }),
@@ -520,6 +567,10 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
520
567
  entryValidator: isPropertyScaleMap,
521
568
  expectsObject: true,
522
569
  }),
570
+ scales: Object.freeze({
571
+ entryValidator: isGroupScalesMap,
572
+ expectsObject: true,
573
+ }),
523
574
  scale: Object.freeze({
524
575
  allowLiterals: ['auto'],
525
576
  entryValidator: isScaleEntryOrAuto,
@@ -586,6 +637,7 @@ function buildScaleOptions(rawOptions) {
586
637
  options.baseFontSize > 0
587
638
  ? options.baseFontSize
588
639
  : 16,
640
+ checkMathBounds: options.checkMathBounds === true,
589
641
  enforceInsideMathFunctions: options.enforceInsideMathFunctions === true,
590
642
  fixToScale: options.fixToScale !== false,
591
643
  fixWith: options.fixWith === 'token' ? 'token' : 'value',
@@ -601,6 +653,7 @@ function buildScaleOptions(rawOptions) {
601
653
  properties: resolvePropertyPatterns(options),
602
654
  propertyGroups: normalizePropertyGroups(options.propertyGroups),
603
655
  propertyScaleOverrides: buildPropertyScaleOverrides(options.propertyScales),
656
+ groupScales: buildGroupScales(options.scales),
604
657
  scale: scaleSelection.scale,
605
658
  scaleAuto: isAutoScaleLiteral(options.scale),
606
659
  scaleSources: Array.isArray(options.scaleSources) ? options.scaleSources : [],
@@ -634,6 +687,7 @@ function buildTokenOptions(rawOptions) {
634
687
  options.baseFontSize > 0
635
688
  ? options.baseFontSize
636
689
  : 16,
690
+ checkMathBounds: options.checkMathBounds === true,
637
691
  enforceInsideMathFunctions: options.enforceInsideMathFunctions === true,
638
692
  ignoreMathFunctionArguments: normalizeMathFunctionArgumentMap(options.ignoreMathFunctionArguments),
639
693
  ignoreValues: Array.isArray(options.ignoreValues)
@@ -647,6 +701,7 @@ function buildTokenOptions(rawOptions) {
647
701
  properties: resolvePropertyPatterns(options),
648
702
  propertyGroups: normalizePropertyGroups(options.propertyGroups),
649
703
  propertyScaleOverrides: buildPropertyScaleOverrides(options.propertyScales),
704
+ groupScales: buildGroupScales(options.scales),
650
705
  scale: scaleSelection.scale,
651
706
  scaleAuto: isAutoScaleLiteral(options.scale),
652
707
  scaleSources: Array.isArray(options.scaleSources) ? options.scaleSources : [],
@@ -688,8 +743,9 @@ function createPropertyScaleResolver(options) {
688
743
  if (cached) {
689
744
  return cached;
690
745
  }
691
- const selectedScale = resolvePropertyScale(prop, options);
746
+ const { group, scale: selectedScale } = resolvePropertyScaleEntry(prop, options);
692
747
  const state = {
748
+ group,
693
749
  scaleByUnit: normalizeScaleByUnit(selectedScale),
694
750
  scalePx: normalizeScale(selectedScale, options.baseFontSize),
695
751
  };
@@ -699,29 +755,41 @@ function createPropertyScaleResolver(options) {
699
755
  }
700
756
 
701
757
  function resolvePropertyScale(prop, options) {
702
- if (!Array.isArray(options.propertyScaleOverrides)) {
703
- return options.scale;
704
- }
758
+ return resolvePropertyScaleEntry(prop, options).scale;
759
+ }
705
760
 
706
- for (const override of options.propertyScaleOverrides) {
761
+ /**
762
+ * The scale a property is checked against, most specific first: a
763
+ * `propertyScales` entry for the property, then the `scales` entry for its
764
+ * group, then `scale`. `group` is set only when `scales` supplied it, so a
765
+ * message can say which scale it means.
766
+ */
767
+ function resolvePropertyScaleEntry(prop, options) {
768
+ for (const override of options.propertyScaleOverrides || []) {
707
769
  if (override.matcher instanceof RegExp) {
708
770
  if (override.matcher.global || override.matcher.sticky) {
709
771
  override.matcher.lastIndex = 0;
710
772
  }
711
773
 
712
774
  if (override.matcher.test(prop)) {
713
- return override.scale;
775
+ return { group: null, scale: override.scale };
714
776
  }
715
777
 
716
778
  continue;
717
779
  }
718
780
 
719
781
  if (String(override.matcher).toLowerCase() === prop.toLowerCase()) {
720
- return override.scale;
782
+ return { group: null, scale: override.scale };
721
783
  }
722
784
  }
723
785
 
724
- return options.scale;
786
+ const group = propertyKindOf(prop);
787
+ const groupScale = group && options.groupScales ? options.groupScales[group] : null;
788
+ if (groupScale && Array.isArray(groupScale.scale)) {
789
+ return { group, scale: groupScale.scale };
790
+ }
791
+
792
+ return { group: null, scale: options.scale };
725
793
  }
726
794
 
727
795
  module.exports = {
@@ -737,7 +805,9 @@ module.exports = {
737
805
  USE_SCALE_VALIDATION_SCHEMA,
738
806
  buildScaleOptions,
739
807
  buildTokenOptions,
808
+ SCALED_GROUPS,
740
809
  createPropertyScaleResolver,
810
+ isGroupScalesMap,
741
811
  isPlainObject,
742
812
  resolvePropertyScale,
743
813
  };
@@ -522,19 +522,58 @@ function fallbackInference(rejected = null) {
522
522
  * an explicit scale pass through untouched.
523
523
  */
524
524
  function withResolvedScale(options, root) {
525
- if (!options.scaleAuto) {
526
- return options;
525
+ if (options.scaleAuto) {
526
+ const inference = resolveAutoScale({
527
+ baseFontSize: options.baseFontSize,
528
+ root,
529
+ scaleSources: options.scaleSources,
530
+ tailwindConfigPath: options.tailwindConfigPath,
531
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
532
+ });
533
+ options.scale = inference.scale;
534
+ options.scaleInference = inference;
535
+ }
536
+ for (const [group, entry] of Object.entries(options.groupScales || {})) {
537
+ if (entry.auto) {
538
+ entry.scale = inferGroupScale(group, options, root);
539
+ }
527
540
  }
528
- const inference = resolveAutoScale({
541
+ return options;
542
+ }
543
+
544
+ // Token names each group's "auto" reads. Spacing is the project scale itself.
545
+ const GROUP_TOKEN_PATTERNS = Object.freeze({
546
+ radius: /^--radius-/,
547
+ size: /^--(?:size|width|height|container)-/,
548
+ typography: /^--(?:font-size|text|fs)-/,
549
+ });
550
+
551
+ /**
552
+ * A group's scale from its own tokens (`--font-size-*` and Tailwind's
553
+ * `--text-*` for typography, `--radius-*` for radius). Null when fewer than
554
+ * four values are found, and the group then uses `scale`. Fluid tokens
555
+ * (`clamp()`) have no single value and are not part of an inferred scale.
556
+ */
557
+ function inferGroupScale(group, options, root) {
558
+ if (group === 'spacing') {
559
+ return options.scaleAuto ? options.scale : resolveAutoScale({
560
+ baseFontSize: options.baseFontSize,
561
+ root,
562
+ scaleSources: options.scaleSources,
563
+ tailwindConfigPath: options.tailwindConfigPath,
564
+ tokenPattern: DEFAULT_AUTO_TOKEN_PATTERN,
565
+ }).scale;
566
+ }
567
+ const pattern = GROUP_TOKEN_PATTERNS[group];
568
+ const definitions = collectTokenDefinitions({
529
569
  baseFontSize: options.baseFontSize,
530
570
  root,
531
571
  scaleSources: options.scaleSources,
532
- tailwindConfigPath: options.tailwindConfigPath,
533
- tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
572
+ tokenPattern: pattern.source,
573
+ tokenRegex: pattern,
534
574
  });
535
- options.scale = inference.scale;
536
- options.scaleInference = inference;
537
- return options;
575
+ const own = new Map([...definitions].filter(([token]) => pattern.test(token)));
576
+ return own.size > 0 ? scaleFromDefinitions(own, options.baseFontSize) : null;
538
577
  }
539
578
 
540
579
  function autoScaleFallbackNote(inference) {
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { PROPERTY_GROUP_PATTERNS } = require('./css-vocabulary');
3
+ const { propertyKindOf } = require('./css-vocabulary');
4
4
  const { fixedLengthValue, numbersEqual, toPx } = require('./length');
5
5
  const { createTokenKindMatcher, parseTokenValueLength } = require('./token-sources');
6
6
 
@@ -24,19 +24,13 @@ function tokenKindOf(token) {
24
24
  return found ? found[0] : null;
25
25
  }
26
26
 
27
- function propertyGroupOf(prop) {
28
- const lower = String(prop || '').toLowerCase();
29
- const found = Object.entries(PROPERTY_GROUP_PATTERNS).find(([, patterns]) => patterns.some((pattern) => pattern.test(lower)));
30
- return found ? found[0] : null;
31
- }
32
-
33
27
  /**
34
28
  * Whether a token may be offered for a property: a padding never gets a radius
35
29
  * token, even when both hold 16px and the token pattern admits both. A token
36
30
  * whose name declares no kind, or a property outside every group, is allowed.
37
31
  */
38
32
  function tokenFitsProperty(token, prop) {
39
- const group = prop ? propertyGroupOf(prop) : null;
33
+ const group = prop ? propertyKindOf(prop) : null;
40
34
  const kind = group ? tokenKindOf(String(token).replace(/^var\(\s*|\s*\)$/g, '')) : null;
41
35
  return !group || !kind || ACCEPTED_KINDS[group].includes(kind);
42
36
  }
@@ -191,6 +191,55 @@ function shouldLintMathArgument(context, options) {
191
191
  return true;
192
192
  }
193
193
 
194
+ const BOUND_FUNCTIONS = new Set(['clamp', 'min', 'max']);
195
+
196
+ function isBoundFunction(node) {
197
+ return node.type === 'function' && BOUND_FUNCTIONS.has(node.value.toLowerCase());
198
+ }
199
+
200
+ function splitArguments(nodes) {
201
+ const args = [[]];
202
+ for (const node of nodes) {
203
+ if (node.type === 'div' && node.value === ',') {
204
+ args.push([]);
205
+ continue;
206
+ }
207
+ if (node.type !== 'space' && node.type !== 'comment') {
208
+ args[args.length - 1].push(node);
209
+ }
210
+ }
211
+ return args;
212
+ }
213
+
214
+ /**
215
+ * The static bounds of a fluid value (issue #203): the minimum and maximum of
216
+ * `clamp(MIN, VAL, MAX)` and every argument of `min()` and `max()`. A bound
217
+ * counts only when it is one word, a length literal; the preferred value of
218
+ * clamp(), an expression, or a var() is fluid by design and left alone. A
219
+ * bound that is itself min(), max() or clamp() is followed.
220
+ */
221
+ function collectMathBounds(fnNode) {
222
+ const name = fnNode.value.toLowerCase();
223
+ const args = splitArguments(fnNode.nodes);
224
+ const positioned = name === 'clamp'
225
+ ? (args.length === 3 ? [[args[0], 'clamp() minimum'], [args[2], 'clamp() maximum']] : [])
226
+ : args.map((arg) => [arg, `${name}() argument`]);
227
+
228
+ const bounds = [];
229
+ for (const [arg, position] of positioned) {
230
+ if (arg.length !== 1) {
231
+ continue;
232
+ }
233
+ const [node] = arg;
234
+ if (node.type === 'word') {
235
+ bounds.push({ node, position });
236
+ } else if (isBoundFunction(node)) {
237
+ bounds.push(...collectMathBounds(node));
238
+ }
239
+ }
240
+ return bounds;
241
+ }
242
+
194
243
  function declarationValueIndex(decl) {
195
244
  const declarationText = decl.toString();
196
245
  const idx = declarationText.indexOf(decl.value);
@@ -198,7 +247,9 @@ function declarationValueIndex(decl) {
198
247
  }
199
248
 
200
249
  module.exports = {
250
+ collectMathBounds,
201
251
  declarationValueIndex,
252
+ isBoundFunction,
202
253
  isKeyword,
203
254
  isMathFunction,
204
255
  isTokenFunction,
@@ -14,6 +14,8 @@ const {
14
14
  createPropertyScaleResolver,
15
15
  } = require('../../core/options');
16
16
  const {
17
+ collectMathBounds,
18
+ isBoundFunction,
17
19
  isKeyword,
18
20
  isMathFunction,
19
21
  isTokenFunction,
@@ -44,8 +46,8 @@ const ruleName = 'rhythmguard/prefer-token';
44
46
  const messages = stylelint.utils.ruleMessages(ruleName, {
45
47
  invalidPreset: (presetName, presetNames) =>
46
48
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
47
- rejected: (value, replacement = null, origin = null, note = '', candidates = []) =>
48
- `Unexpected raw scale value "${value}". ${replacement
49
+ rejected: (value, replacement = null, origin = null, note = '', candidates = [], position = null) =>
50
+ `Unexpected raw ${position || 'scale value'} "${value}". ${replacement
49
51
  ? `Use ${replacement}${origin ? ` (${origin})` : ''}.`
50
52
  : candidates.length > 1
51
53
  ? `Use one of the tokens that hold it: ${listTokens(candidates)}.`
@@ -177,18 +179,18 @@ const ruleFunction = (primary, secondaryOptions) => {
177
179
  const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
178
180
  let changed = false;
179
181
 
180
- const reportNode = (node, parsedLength) => {
182
+ const reportNode = (node, parsedLength, position = null) => {
181
183
  const mapped = resolveTokenReplacement(tokenMap, node.value, parsedLength, options);
182
184
  if (mapped && tokenFitsProperty(mapped.token, prop)) {
183
185
  const origin = origins[mapped.token] || null;
184
- reportValueNode({ decl, message: messages.rejected(node.value, mapped.replacement, origin, options.note), node, replacement: mapped.replacement, result, ruleName });
186
+ reportValueNode({ decl, message: messages.rejected(node.value, mapped.replacement, origin, options.note, [], position), node, replacement: mapped.replacement, result, ruleName });
185
187
  return;
186
188
  }
187
189
  const indexed = resolveFromIndex(tokenIndex, parsedLength, prop, options);
188
190
  const named = indexed.replacement || null;
189
191
  reportValueNode({
190
192
  decl,
191
- message: messages.rejected(node.value, named, indexed.origin || null, options.note, indexed.candidates),
193
+ message: messages.rejected(node.value, named, indexed.origin || null, options.note, indexed.candidates, position),
192
194
  node,
193
195
  replacement: indexed.fixable ? named : null,
194
196
  result,
@@ -196,12 +198,12 @@ const ruleFunction = (primary, secondaryOptions) => {
196
198
  });
197
199
  };
198
200
 
199
- const checkWordNode = (node, context) => {
201
+ const checkWordNode = (node, context, position = null) => {
200
202
  if (isKeyword(node.value, options.ignoreValues)) {
201
203
  return false;
202
204
  }
203
205
 
204
- if (!shouldLintMathArgument(context, options)) {
206
+ if (!position && !shouldLintMathArgument(context, options)) {
205
207
  return false;
206
208
  }
207
209
 
@@ -259,7 +261,7 @@ const ruleFunction = (primary, secondaryOptions) => {
259
261
  }
260
262
  }
261
263
 
262
- reportNode(node, parsedLength);
264
+ reportNode(node, parsedLength, position);
263
265
  return true;
264
266
  };
265
267
 
@@ -285,12 +287,22 @@ const ruleFunction = (primary, secondaryOptions) => {
285
287
  return false;
286
288
  });
287
289
  } else {
290
+ const checkedBounds = new Set();
288
291
  walkRootValueNodes(parsed, (node, context) => {
289
292
  if (node.type === 'function') {
290
293
  if (isTokenFunction(node, options.tokenFunctions, tokenRegex)) {
291
294
  return true;
292
295
  }
293
296
 
297
+ if (options.checkMathBounds && isBoundFunction(node)) {
298
+ for (const bound of collectMathBounds(node)) {
299
+ if (!checkedBounds.has(bound.node)) {
300
+ checkedBounds.add(bound.node);
301
+ changed = checkWordNode(bound.node, null, bound.position) || changed;
302
+ }
303
+ }
304
+ }
305
+
294
306
  if (isMathFunction(node.value) && !options.enforceInsideMathFunctions) {
295
307
  return true;
296
308
  }
@@ -298,7 +310,7 @@ const ruleFunction = (primary, secondaryOptions) => {
298
310
  return false;
299
311
  }
300
312
 
301
- if (node.type !== 'word') {
313
+ if (node.type !== 'word' || checkedBounds.has(node)) {
302
314
  return false;
303
315
  }
304
316
 
@@ -15,6 +15,8 @@ const {
15
15
  createPropertyScaleResolver,
16
16
  } = require('../../core/options');
17
17
  const {
18
+ collectMathBounds,
19
+ isBoundFunction,
18
20
  isKeyword,
19
21
  isMathFunction,
20
22
  isTokenFunction,
@@ -41,10 +43,20 @@ const ruleName = 'rhythmguard/use-scale';
41
43
  const messages = stylelint.utils.ruleMessages(ruleName, {
42
44
  invalidPreset: (presetName, presetNames) =>
43
45
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
44
- rejected: (value, lower, upper, note = '') =>
45
- `Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
46
+ 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}` : ''}`,
46
48
  });
47
49
 
50
+ /** " (16.8px)" after a bound written in rem or em, so the reader sees what was compared. */
51
+ function pxAside(value, baseFontSize) {
52
+ const parsed = parseLengthToken(value);
53
+ if (!parsed || !parsed.unit || parsed.unit === 'px' || parsed.unit === '%') {
54
+ return '';
55
+ }
56
+ const px = toPx(Math.abs(parsed.number), parsed.unit, baseFontSize);
57
+ return px === null ? '' : ` (${formatLength(parsed.number < 0 ? -px : px, 'px')})`;
58
+ }
59
+
48
60
  /** Decisions from .rhythmguardrc.json; an invalid section is reported once and ignored. */
49
61
  function readDecisions(options, { result, root }) {
50
62
  if (!options.readDecisions) {
@@ -253,13 +265,13 @@ const ruleFunction = (primary, secondaryOptions) => {
253
265
  let fallbackNote = autoScaleFallbackNote(options.scaleInference);
254
266
  const getScaleStateForProperty = createPropertyScaleResolver(options);
255
267
 
256
- const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px', extraNote = '') => {
268
+ const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px', extraNote = '', position = null) => {
257
269
  const lower = nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a';
258
270
  const upper = nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a';
259
271
  const tokenNote = nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)) : '';
260
272
  reportValueNode({
261
273
  decl,
262
- message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, extraNote, options.note].filter(Boolean).join(' ')),
274
+ message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, extraNote, options.note].filter(Boolean).join(' '), position, position ? pxAside(value, options.baseFontSize) : '', getScaleStateForProperty(decl.prop.toLowerCase()).group),
263
275
  node,
264
276
  replacement: fixedValue,
265
277
  result,
@@ -342,6 +354,7 @@ const ruleFunction = (primary, secondaryOptions) => {
342
354
  return false;
343
355
  });
344
356
  } else {
357
+ const checkedBounds = new Set();
345
358
  walkRootValueNodes(parsed, (node, context) => {
346
359
 
347
360
  if (node.type === 'function') {
@@ -349,6 +362,20 @@ const ruleFunction = (primary, secondaryOptions) => {
349
362
  return true;
350
363
  }
351
364
 
365
+ if (options.checkMathBounds && isBoundFunction(node)) {
366
+ for (const bound of collectMathBounds(node)) {
367
+ if (checkedBounds.has(bound.node)) {
368
+ continue;
369
+ }
370
+ checkedBounds.add(bound.node);
371
+ if (isKeyword(bound.node.value, options.ignoreValues)) {
372
+ continue;
373
+ }
374
+ const boundReport = (value, d, n, nearest, fixed, unit, extra) => report(value, d, n, nearest, fixed, unit, extra, bound.position);
375
+ changed = checkLengthValue({ decl, node: bound.node, options, report: boundReport, scaleByUnit, scalePx }) || changed;
376
+ }
377
+ }
378
+
352
379
  if (
353
380
  isMathFunction(node.value) &&
354
381
  !options.enforceInsideMathFunctions
@@ -359,7 +386,7 @@ const ruleFunction = (primary, secondaryOptions) => {
359
386
  return false;
360
387
  }
361
388
 
362
- if (node.type !== 'word') {
389
+ if (node.type !== 'word' || checkedBounds.has(node)) {
363
390
  return false;
364
391
  }
365
392
 
package/types/audit.d.ts CHANGED
@@ -32,7 +32,11 @@ export interface AuditOptions {
32
32
  format?: "json" | "json-v1" | "markdown" | "text" | "html" | "github" | "badge";
33
33
  ignorePath?: string;
34
34
  ignorePatterns?: string[];
35
+ /** Check clamp() bounds and min()/max() arguments against the scale (equivalent to `--check-math-bounds`). */
36
+ checkMathBounds?: boolean;
35
37
  includeMotion?: boolean;
38
+ /** A scale per property group (`spacing`, `radius`, `size`, `typography`): values, a preset name or "auto". Each group named is also checked. */
39
+ scales?: Partial<Record<"spacing" | "radius" | "size" | "typography", Array<number | string> | string | { preset?: string; scale?: Array<number | string>; customScale?: Array<number | string> }>>;
36
40
  maxFindings?: number;
37
41
  minCleanliness?: number;
38
42
  /** Skip config discovery entirely (equivalent to `--no-config`). */
package/types/shared.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface ScaleSource {
17
17
  export interface RhythmguardRuleOptions {
18
18
  /** Exempt non-zero lengths of one CSS pixel or less (1px, -1px, 0.5px, 0.0625rem). Default true. */
19
19
  allowHairlines?: boolean;
20
+ /** Check the static bounds of fluid values: the clamp() minimum and maximum and each length argument of min() and max(). Default false; the preferred value of clamp() is never checked. */
21
+ checkMathBounds?: boolean;
20
22
  /** What autofix writes: the nearest literal (default) or, with "token", `var(--name)` when exactly one custom property holds that value in the same unit. */
21
23
  fixWith?: "value" | "token";
22
24
  /** Read the `decisions` section of `.rhythmguardrc.json` (default true). The audit sets false and applies decisions itself. */
@@ -34,6 +36,12 @@ export interface RhythmguardRuleOptions {
34
36
  * properties, then `tailwindConfigPath`, falling back to the `rhythmic-4` preset.
35
37
  */
36
38
  scale?: ScaleValue[] | "auto";
39
+ /**
40
+ * A scale per property group, used before `scale` and after `propertyScales`: values, a
41
+ * preset name, or `"auto"` to infer the group's scale from its tokens (`--font-size-*` and
42
+ * `--text-*` for typography, `--radius-*` for radius). Findings then name the group's scale.
43
+ */
44
+ scales?: Partial<Record<"spacing" | "radius" | "size" | "typography", ScaleValue[] | string | { preset?: string; scale?: ScaleValue[]; customScale?: ScaleValue[] }>>;
37
45
  /** Token files consulted first when `scale` is `"auto"`. */
38
46
  scaleSources?: Array<string | ScaleSource>;
39
47
  /** Tailwind v3 config whose `theme.spacing` feeds `scale: "auto"` and `tokenMapFromTailwindSpacing`. */