stylelint-plugin-rhythmguard 2.0.1 → 2.2.0

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/src/cli/index.js CHANGED
@@ -3,9 +3,13 @@
3
3
 
4
4
  const command = process.argv[2];
5
5
 
6
- const HELP = `Usage: rhythmguard <command>
6
+ const HELP = `Usage: rhythmguard [command]
7
+
8
+ With no command: zero-config quickstart. Detects your stack and tokens, infers
9
+ the spacing scale, audits the current directory and prints a config to paste.
7
10
 
8
11
  Commands:
12
+ quickstart Same as running with no command
9
13
  audit <dir> Report design-system drift across CSS and Tailwind class strings
10
14
  init Scaffold a Rhythmguard config for your project
11
15
  doctor Validate your Rhythmguard setup
@@ -14,22 +18,24 @@ Options:
14
18
  --help Show this help message
15
19
 
16
20
  Examples:
21
+ npx rhythmguard
17
22
  npx rhythmguard audit ./src
18
- npx rhythmguard audit ./src --json
19
23
  npx rhythmguard audit ./src --format markdown
20
24
  npx rhythmguard init
21
25
  npx rhythmguard doctor
22
26
  `;
23
27
 
24
- if (!command || command === '--help' || command === '-h') {
28
+ if (command === '--help' || command === '-h') {
25
29
  process.stdout.write(HELP);
26
30
  process.exit(0);
27
31
  }
28
32
 
29
- if (command === 'audit') {
33
+ if (!command || command === 'quickstart') {
34
+ require('./quickstart').run();
35
+ } else if (command === 'audit') {
30
36
  require('./audit').run();
31
37
  } else if (command === 'init') {
32
- require('./init');
38
+ require('./init').run();
33
39
  } else if (command === 'doctor') {
34
40
  require('./doctor');
35
41
  } else {
package/src/cli/init.js CHANGED
@@ -153,4 +153,8 @@ async function run() {
153
153
  }
154
154
  }
155
155
 
156
- run();
156
+ module.exports = { detect, run, selectProfile };
157
+
158
+ if (require.main === module) {
159
+ run();
160
+ }
@@ -0,0 +1,203 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `npx rhythmguard` with no arguments.
5
+ *
6
+ * The first minute, with nothing to configure: detect the stack and any token
7
+ * files, infer the spacing scale from the project's own tokens, audit the
8
+ * current directory, and end with the exact config to paste. Never fails the
9
+ * process on findings; this is a report, not a gate.
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const { createAuditReport } = require('../audit/report');
16
+ const { detect } = require('./init');
17
+
18
+ const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
19
+ const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
20
+ const MAX_LISTED = 5;
21
+
22
+ function readPackage(cwd) {
23
+ try {
24
+ return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ function tailwindMajor(pkg) {
31
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
32
+ if (deps['@tailwindcss/postcss'] || deps['@tailwindcss/vite']) {
33
+ return 4;
34
+ }
35
+ const spec = deps.tailwindcss;
36
+ if (!spec) {
37
+ return null;
38
+ }
39
+ const match = String(spec).match(/(\d+)/);
40
+ return match ? Number(match[1]) : null;
41
+ }
42
+
43
+ function findTokenFiles(cwd) {
44
+ const found = new Set();
45
+ const consider = (dir) => {
46
+ let entries;
47
+ try {
48
+ entries = fs.readdirSync(dir, { withFileTypes: true });
49
+ } catch {
50
+ return;
51
+ }
52
+ for (const entry of entries) {
53
+ if (entry.isFile() && TOKEN_FILE_PATTERN.test(entry.name)) {
54
+ found.add(path.relative(cwd, path.join(dir, entry.name)));
55
+ }
56
+ }
57
+ };
58
+ consider(cwd);
59
+ for (const sub of TOKEN_DIRS) {
60
+ consider(path.join(cwd, sub));
61
+ }
62
+ return Array.from(found).sort();
63
+ }
64
+
65
+ function hasRhythmguardConfig(cwd) {
66
+ return fs.existsSync(path.join(cwd, '.rhythmguardrc.json'));
67
+ }
68
+
69
+ function toPosix(relativePath) {
70
+ return `./${relativePath.split(path.sep).join('/')}`;
71
+ }
72
+
73
+ function topEntries(counts, limit = MAX_LISTED) {
74
+ return Object.entries(counts || {})
75
+ .sort((a, b) => b[1] - a[1])
76
+ .slice(0, limit);
77
+ }
78
+
79
+ function suggestedStylelintConfig({ profile, tokenFiles }) {
80
+ const ruleOptions = { scale: 'auto' };
81
+ if (tokenFiles.length > 0) {
82
+ ruleOptions.scaleSources = tokenFiles.map(toPosix);
83
+ }
84
+ return {
85
+ extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
86
+ rules: {
87
+ 'rhythmguard/use-scale': [true, ruleOptions],
88
+ },
89
+ };
90
+ }
91
+
92
+ function eslintSnippet(scaleValues) {
93
+ const scale = scaleValues.filter((value) => typeof value === 'number').join(', ');
94
+ return [
95
+ "// eslint.config.js",
96
+ "import rhythmguard from 'stylelint-plugin-rhythmguard/eslint';",
97
+ '',
98
+ 'export default [',
99
+ ' {',
100
+ " plugins: { 'rhythmguard-tailwind': rhythmguard },",
101
+ ` rules: { 'rhythmguard-tailwind/tailwind-class-use-scale': ['warn', { scale: [${scale}] }] },`,
102
+ ' },',
103
+ '];',
104
+ ].join('\n');
105
+ }
106
+
107
+ function describeScale(scale) {
108
+ if (!scale) {
109
+ return 'default';
110
+ }
111
+ const files = scale.files && scale.files.length > 0 ? ` (${scale.files.slice(0, 3).join(', ')}${scale.files.length > 3 ? ', …' : ''})` : '';
112
+ return `${scale.source}${files}`;
113
+ }
114
+
115
+ async function run() {
116
+ const cwd = process.cwd();
117
+ const pkg = readPackage(cwd);
118
+ const stack = detect();
119
+ const twMajor = stack.tailwind ? tailwindMajor(pkg) : null;
120
+ const tokenFiles = findTokenFiles(cwd);
121
+ const rcPresent = hasRhythmguardConfig(cwd);
122
+ const out = [];
123
+
124
+ out.push('', ' Rhythmguard quickstart', ` ${cwd}`, '');
125
+ out.push(' Detected');
126
+ out.push(` Tailwind ${stack.tailwind ? `v${twMajor || '?'}` : 'no'}`);
127
+ out.push(` Next.js ${stack.nextjs ? 'yes' : 'no'}`);
128
+ out.push(` Stylelint config ${stack.hasExistingConfig ? 'present' : 'none'}`);
129
+ out.push(` Token files ${tokenFiles.length > 0 ? tokenFiles.join(', ') : 'none found'}`);
130
+ out.push(` .rhythmguardrc ${rcPresent ? 'present (its token sources are used)' : 'none'}`);
131
+ out.push('');
132
+
133
+ let report;
134
+ try {
135
+ report = await createAuditReport({
136
+ dir: '.',
137
+ scale: 'auto',
138
+ ...(tokenFiles.length > 0 && !rcPresent
139
+ ? { tokenSources: tokenFiles }
140
+ : {}),
141
+ });
142
+ } catch (error) {
143
+ process.stderr.write(`Quickstart could not audit ${cwd}: ${error.message}\n`);
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+
148
+ if (report.cssFilesScanned === 0 && report.templateFilesScanned === 0) {
149
+ out.push(' No CSS files found here. Run from your project root, or audit a directory directly:');
150
+ out.push(' npx rhythmguard audit ./src', '');
151
+ process.stdout.write(`${out.join('\n')}\n`);
152
+ return;
153
+ }
154
+
155
+ const scale = report.scale;
156
+ out.push(' Scale');
157
+ out.push(` Scale ${scale.values.join(', ')}`);
158
+ out.push(` Source ${describeScale(scale)}`);
159
+ if (scale.source === 'fallback') {
160
+ out.push(' Note No spacing tokens found; the rhythmic-4 preset is being used.');
161
+ out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
162
+ }
163
+ out.push('');
164
+
165
+ const offScale = report.findings.css.filter((finding) => finding.type === 'off-scale');
166
+ out.push(' Findings');
167
+ out.push(` CSS files ${report.cssFilesScanned}`);
168
+ out.push(` Template files ${report.templateFilesScanned}`);
169
+ out.push(` Off-scale ${offScale.length} in CSS, ${report.findings.tailwind.length} in class strings`);
170
+ out.push(` Token opps ${report.findings.css.length - offScale.length} raw values that match or deserve a token`);
171
+ out.push(` Cleanliness ${report.scaleCleanliness}%`);
172
+ const topValues = topEntries(report.offScaleValues);
173
+ if (topValues.length > 0) {
174
+ out.push(` Top values ${topValues.map(([value, count]) => `${value} ×${count}`).join(', ')}`);
175
+ }
176
+ const topFiles = (report.topAffectedFiles || []).slice(0, 3);
177
+ if (topFiles.length > 0) {
178
+ out.push(` Top files ${topFiles.map((entry) => `${entry.file} (${entry.count})`).join(', ')}`);
179
+ }
180
+ out.push('');
181
+
182
+ const profile = stack.tailwind ? (stack.nextjs ? 'react-tailwind' : 'tailwind') : 'recommended';
183
+ out.push(` Paste this into .stylelintrc.json${stack.hasExistingConfig ? ' (merge with your existing config)' : ''}:`, '');
184
+ out.push(JSON.stringify(suggestedStylelintConfig({ profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
185
+ out.push('');
186
+
187
+ if (stack.tailwind) {
188
+ out.push(' Tailwind class strings need the ESLint companion. Paste this into eslint.config.js:', '');
189
+ out.push(eslintSnippet(scale.values).replace(/^/gm, ' '));
190
+ out.push('');
191
+ }
192
+
193
+ out.push(' Next');
194
+ out.push(' npx stylelint "**/*.css" lint with the config above');
195
+ out.push(' npx rhythmguard audit . --format markdown PR-ready report');
196
+ out.push(' npx rhythmguard audit . --write-baseline then gate only new drift in CI');
197
+ out.push(' npx rhythmguard doctor check the setup');
198
+ out.push('');
199
+
200
+ process.stdout.write(`${out.join('\n')}\n`);
201
+ }
202
+
203
+ module.exports = { findTokenFiles, run, suggestedStylelintConfig, tailwindMajor };
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The one-liner for shared-config authors.
5
+ *
6
+ * One rule, warning level, scale inferred from the consuming project's own
7
+ * spacing tokens (scaleSources, .rhythmguardrc.json token sources, the
8
+ * stylesheet's custom properties, a Tailwind config, then rhythmic-4).
9
+ * No `extends`, no dependency beyond this plugin, and the shape is frozen for
10
+ * the life of the 2.x line so a config that embeds it never changes behaviour
11
+ * on a minor bump.
12
+ */
13
+ module.exports = {
14
+ plugins: ['stylelint-plugin-rhythmguard'],
15
+ rules: {
16
+ 'rhythmguard/use-scale': [
17
+ true,
18
+ {
19
+ scale: 'auto',
20
+ severity: 'warning',
21
+ },
22
+ ],
23
+ },
24
+ };
@@ -0,0 +1,4 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ const config = require('./embed.js');
4
+ export default config;
@@ -76,6 +76,7 @@ function buildMessage(analysis, segment, lower, upper) {
76
76
  module.exports = {
77
77
  meta: {
78
78
  docs: {
79
+ url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/tailwind-class-use-motion-scale.md',
79
80
  description: 'Enforce duration scale for Tailwind arbitrary motion utilities in class strings',
80
81
  },
81
82
  fixable: 'code',
@@ -67,6 +67,7 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
67
67
  module.exports = {
68
68
  meta: {
69
69
  docs: {
70
+ url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/tailwind-class-use-scale.md',
70
71
  description: 'Enforce spacing scale for Tailwind arbitrary spacing utilities in class strings',
71
72
  },
72
73
  fixable: 'code',
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ module.exports.rules = {
15
15
  [useMotionScale.ruleName]: useMotionScale,
16
16
  };
17
17
  module.exports.configs = {
18
+ embed: require('./configs/embed'),
18
19
  recommended: require('./configs/recommended'),
19
20
  strict: require('./configs/strict'),
20
21
  tailwind: require('./configs/tailwind'),
@@ -22,6 +23,7 @@ module.exports.configs = {
22
23
  logical: require('./configs/logical'),
23
24
  migration: require('./configs/migration'),
24
25
  motion: require('./configs/motion'),
26
+ 'react-tailwind': require('./configs/react-tailwind'),
25
27
  };
26
28
  module.exports.eslint = require('./eslint');
27
29
  module.exports.presets = require('./presets');
@@ -5,6 +5,7 @@ const valueParser = require('postcss-value-parser');
5
5
  const {
6
6
  formatLength,
7
7
  fromPx,
8
+ isHairlineLength,
8
9
  nearestScaleValues,
9
10
  normalizeScale,
10
11
  normalizeScaleByUnit,
@@ -25,6 +26,11 @@ const {
25
26
  walkTransformTranslateNodes,
26
27
  } = require('../../utils/value-utils');
27
28
 
29
+ const {
30
+ DEFAULT_AUTO_TOKEN_PATTERN,
31
+ resolveAutoScale,
32
+ } = require('../../utils/scale-inference');
33
+
28
34
  const ruleName = 'rhythmguard/no-offscale-transform';
29
35
  const messages = stylelint.utils.ruleMessages(ruleName, {
30
36
  invalidPreset: (presetName, presetNames) =>
@@ -84,6 +90,18 @@ const ruleFunction = (primary, secondaryOptions) => {
84
90
  });
85
91
  }
86
92
 
93
+ if (options.scaleAuto) {
94
+ const inference = resolveAutoScale({
95
+ baseFontSize: options.baseFontSize,
96
+ root,
97
+ scaleSources: options.scaleSources,
98
+ tailwindConfigPath: options.tailwindConfigPath,
99
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
100
+ });
101
+ options.scale = inference.scale;
102
+ options.scaleInference = inference;
103
+ }
104
+
87
105
  const scaleCache = new Map();
88
106
  const getScaleStateForProperty = (prop) => {
89
107
  const cached = scaleCache.get(prop);
@@ -152,6 +170,10 @@ const ruleFunction = (primary, secondaryOptions) => {
152
170
  return;
153
171
  }
154
172
 
173
+ if (options.allowHairlines && isHairlineLength(parsedLength, options.baseFontSize)) {
174
+ return;
175
+ }
176
+
155
177
  if (!options.allowNegative && parsedLength.number < 0) {
156
178
  return;
157
179
  }
@@ -269,7 +291,7 @@ ruleFunction.ruleName = ruleName;
269
291
  ruleFunction.messages = messages;
270
292
  ruleFunction.meta = {
271
293
  fixable: true,
272
- url: 'https://github.com/petrilahdelma/stylelint-plugin-rhythmguard#rhythmguardno-offscale-transform',
294
+ url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/no-offscale-transform.md',
273
295
  };
274
296
 
275
297
  module.exports = stylelint.createPlugin(ruleName, ruleFunction);
@@ -4,6 +4,7 @@ const stylelint = require('stylelint');
4
4
  const valueParser = require('postcss-value-parser');
5
5
  const {
6
6
  formatLength,
7
+ isHairlineLength,
7
8
  normalizeScale,
8
9
  normalizeScaleByUnit,
9
10
  numbersEqual,
@@ -28,6 +29,11 @@ const {
28
29
  } = require('../../utils/value-utils');
29
30
  const { buildEffectiveTokenMap } = require('../../utils/token-map');
30
31
 
32
+ const {
33
+ DEFAULT_AUTO_TOKEN_PATTERN,
34
+ resolveAutoScale,
35
+ } = require('../../utils/scale-inference');
36
+
31
37
  const ruleName = 'rhythmguard/prefer-token';
32
38
 
33
39
  const messages = stylelint.utils.ruleMessages(ruleName, {
@@ -112,6 +118,18 @@ const ruleFunction = (primary, secondaryOptions) => {
112
118
  });
113
119
  }
114
120
 
121
+ if (options.scaleAuto) {
122
+ const inference = resolveAutoScale({
123
+ baseFontSize: options.baseFontSize,
124
+ root,
125
+ scaleSources: options.scaleSources,
126
+ tailwindConfigPath: options.tailwindConfigPath,
127
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
128
+ });
129
+ options.scale = inference.scale;
130
+ options.scaleInference = inference;
131
+ }
132
+
115
133
  const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
116
134
  const tokenMap = buildEffectiveTokenMap({
117
135
  options,
@@ -196,6 +214,16 @@ const ruleFunction = (primary, secondaryOptions) => {
196
214
  return false;
197
215
  }
198
216
 
217
+ // Percentages are relative to the container or the element itself; they are
218
+ // never spacing-token candidates (translate(-50%, -50%) centering, inset: 100%).
219
+ if (parsedLength.unit === '%') {
220
+ return false;
221
+ }
222
+
223
+ if (options.allowHairlines && isHairlineLength(parsedLength, options.baseFontSize)) {
224
+ return false;
225
+ }
226
+
199
227
  if (
200
228
  parsedLength.unit &&
201
229
  parsedLength.unit !== '%' &&
@@ -288,7 +316,7 @@ ruleFunction.ruleName = ruleName;
288
316
  ruleFunction.messages = messages;
289
317
  ruleFunction.meta = {
290
318
  fixable: true,
291
- url: 'https://github.com/petrilahdelma/stylelint-plugin-rhythmguard#rhythmguardprefer-token',
319
+ url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/prefer-token.md',
292
320
  };
293
321
 
294
322
  module.exports = stylelint.createPlugin(ruleName, ruleFunction);
@@ -236,7 +236,7 @@ ruleFunction.ruleName = ruleName;
236
236
  ruleFunction.messages = messages;
237
237
  ruleFunction.meta = {
238
238
  fixable: true,
239
- url: 'https://github.com/petrilahdelma/stylelint-plugin-rhythmguard#rhythmguarduse-motion-scale',
239
+ url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/use-motion-scale.md',
240
240
  };
241
241
 
242
242
  module.exports = stylelint.createPlugin(ruleName, ruleFunction);
@@ -5,6 +5,7 @@ const valueParser = require('postcss-value-parser');
5
5
  const {
6
6
  formatLength,
7
7
  fromPx,
8
+ isHairlineLength,
8
9
  nearestScaleValues,
9
10
  normalizeScale,
10
11
  normalizeScaleByUnit,
@@ -29,13 +30,19 @@ const {
29
30
  walkTransformTranslateNodes,
30
31
  } = require('../../utils/value-utils');
31
32
 
33
+ const {
34
+ DEFAULT_AUTO_TOKEN_PATTERN,
35
+ autoScaleFallbackNote,
36
+ resolveAutoScale,
37
+ } = require('../../utils/scale-inference');
38
+
32
39
  const ruleName = 'rhythmguard/use-scale';
33
40
 
34
41
  const messages = stylelint.utils.ruleMessages(ruleName, {
35
42
  invalidPreset: (presetName, presetNames) =>
36
43
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
37
- rejected: (value, lower, upper) =>
38
- `Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper}).`,
44
+ rejected: (value, lower, upper, note = '') =>
45
+ `Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
39
46
  });
40
47
 
41
48
  function getFixedNodeValue(parsedLength, nearestPx, options) {
@@ -82,7 +89,16 @@ function checkLengthValue({
82
89
  return false;
83
90
  }
84
91
 
85
- if (parsedLength.unit === '%' && options.allowPercentages) {
92
+ if (parsedLength.unit === '%') {
93
+ if (options.allowPercentages) {
94
+ return false;
95
+ }
96
+
97
+ report(node.value, decl, node, null, null, '%');
98
+ return false;
99
+ }
100
+
101
+ if (options.allowHairlines && isHairlineLength(parsedLength, options.baseFontSize)) {
86
102
  return false;
87
103
  }
88
104
 
@@ -180,8 +196,21 @@ const ruleFunction = (primary, secondaryOptions) => {
180
196
  });
181
197
  }
182
198
 
199
+ if (options.scaleAuto) {
200
+ const inference = resolveAutoScale({
201
+ baseFontSize: options.baseFontSize,
202
+ root,
203
+ scaleSources: options.scaleSources,
204
+ tailwindConfigPath: options.tailwindConfigPath,
205
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
206
+ });
207
+ options.scale = inference.scale;
208
+ options.scaleInference = inference;
209
+ }
210
+
183
211
  const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
184
212
  const scaleCache = new Map();
213
+ let fallbackNote = autoScaleFallbackNote(options.scaleInference);
185
214
 
186
215
  const getScaleStateForProperty = (prop) => {
187
216
  const cached = scaleCache.get(prop);
@@ -208,7 +237,7 @@ const ruleFunction = (primary, secondaryOptions) => {
208
237
  const payload = {
209
238
  endIndex,
210
239
  index,
211
- message: messages.rejected(value, lower, upper),
240
+ message: messages.rejected(value, lower, upper, fallbackNote),
212
241
  node: decl,
213
242
  result,
214
243
  ruleName,
@@ -221,6 +250,7 @@ const ruleFunction = (primary, secondaryOptions) => {
221
250
  };
222
251
  }
223
252
 
253
+ fallbackNote = '';
224
254
  stylelint.utils.report(payload);
225
255
  };
226
256
 
@@ -332,7 +362,7 @@ ruleFunction.ruleName = ruleName;
332
362
  ruleFunction.messages = messages;
333
363
  ruleFunction.meta = {
334
364
  fixable: true,
335
- url: 'https://github.com/petrilahdelma/stylelint-plugin-rhythmguard#rhythmguarduse-scale',
365
+ url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/use-scale.md',
336
366
  };
337
367
 
338
368
  module.exports = stylelint.createPlugin(ruleName, ruleFunction);
@@ -25,6 +25,21 @@ function parseLengthToken(rawValue) {
25
25
  return { number, raw: value, unit };
26
26
  }
27
27
 
28
+ /**
29
+ * A hairline is a non-zero length that resolves to one CSS pixel or less:
30
+ * 1px, -1px, 0.5px, 0.0625rem. Such values compensate for a border width or a
31
+ * rendering quirk; they are not spacing decisions, so the scale rules exempt
32
+ * them by default (`allowHairlines`).
33
+ */
34
+ function isHairlineLength(parsedLength, baseFontSize) {
35
+ if (!parsedLength || parsedLength.number === 0 || parsedLength.unit === '%') {
36
+ return false;
37
+ }
38
+
39
+ const px = toPx(Math.abs(parsedLength.number), parsedLength.unit || 'px', baseFontSize);
40
+ return px !== null && px > 0 && px <= 1;
41
+ }
42
+
28
43
  function toPx(number, unit, baseFontSize) {
29
44
  if (unit === '' || unit === 'px') {
30
45
  return number;
@@ -168,6 +183,7 @@ function nearestScaleValues(target, scale) {
168
183
  module.exports = {
169
184
  formatLength,
170
185
  fromPx,
186
+ isHairlineLength,
171
187
  nearestScaleValues,
172
188
  normalizeScale,
173
189
  normalizeScaleByUnit,