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 +22 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/audit/args.js +2 -0
- package/src/audit/config.js +23 -0
- package/src/audit/render-markdown.js +35 -0
- package/src/audit/render-text.js +5 -0
- package/src/audit/report.js +14 -5
- package/src/audit/scan/stylesheets.js +6 -2
- package/src/audit/shared.js +3 -0
- package/src/audit/token-ladders.js +246 -0
- package/src/core/css-vocabulary.js +26 -0
- package/src/core/options.js +78 -8
- package/src/core/scale-inference.js +47 -8
- package/src/core/token-index.js +2 -8
- package/src/core/value-nodes.js +51 -0
- package/src/rules/prefer-token/index.js +21 -9
- package/src/rules/use-scale/index.js +32 -5
- package/types/audit.d.ts +4 -0
- package/types/shared.d.ts +8 -0
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
|
[](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
|
|
10
|
-
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
11
11
|
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
12
12
|
[](./LICENSE)
|
|
13
13
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "3.
|
|
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)',
|
package/src/audit/config.js
CHANGED
|
@@ -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,
|
package/src/audit/render-text.js
CHANGED
|
@@ -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) {
|
package/src/audit/report.js
CHANGED
|
@@ -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
|
|
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) "([^"]+)"/,
|
package/src/audit/shared.js
CHANGED
|
@@ -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,
|
package/src/core/options.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
758
|
+
return resolvePropertyScaleEntry(prop, options).scale;
|
|
759
|
+
}
|
|
705
760
|
|
|
706
|
-
|
|
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
|
-
|
|
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 (
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
533
|
-
|
|
572
|
+
tokenPattern: pattern.source,
|
|
573
|
+
tokenRegex: pattern,
|
|
534
574
|
});
|
|
535
|
-
|
|
536
|
-
options.
|
|
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) {
|
package/src/core/token-index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
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 ?
|
|
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
|
}
|
package/src/core/value-nodes.js
CHANGED
|
@@ -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`. */
|