stylelint-plugin-rhythmguard 1.8.0 → 1.9.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/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ The format follows Keep a Changelog principles and semantic versioning.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.9.0] - 2026-05-23
10
+
11
+ ### Added
12
+
13
+ - Added opt-in `rhythmguard/use-motion-scale` for duration/delay scale enforcement and raw easing reporting.
14
+ - Added `stylelint-plugin-rhythmguard/configs/motion`.
15
+ - Added ESLint companion rule `rhythmguard-tailwind/tailwind-class-use-motion-scale` for Tailwind `duration-[...]`, `delay-[...]`, and `ease-[...]` arbitrary values.
16
+ - Added `rhythmguard audit --include-motion` and `.rhythmguardrc.json` `includeMotion` support.
17
+
9
18
  ## [1.8.0] - 2026-05-23
10
19
 
11
20
  ### Added
package/README.md CHANGED
@@ -66,6 +66,7 @@ This gives you spacing governance in both CSS files and JSX/TSX templates.
66
66
  | `rhythmguard/use-scale` | Enforces spacing values must be on your configured scale | Yes, nearest safe value |
67
67
  | `rhythmguard/prefer-token` | Enforces token usage over raw spacing literals | Yes, with `tokenMap` |
68
68
  | `rhythmguard/no-offscale-transform` | Enforces scale-aligned `translate*` motion offsets | Yes, nearest safe value |
69
+ | `rhythmguard/use-motion-scale` | Enforces opt-in duration/delay rhythm and flags raw easing curves | Yes, for duration/delay values |
69
70
 
70
71
  ## Demo
71
72
 
@@ -95,6 +96,7 @@ npx rhythmguard audit ./src --since-baseline --fail-on-new-drift
95
96
  npx rhythmguard audit ./src --staged --max-findings 0
96
97
  npx rhythmguard audit ./src --token-source ./tokens.json
97
98
  npx rhythmguard audit ./src --token-source ./theme.css --token-source-format css
99
+ npx rhythmguard audit ./src --include-motion
98
100
  ```
99
101
 
100
102
  The report covers authored CSS declarations, Tailwind arbitrary spacing values in common template/source files, and token-contract drift such as missing spacing tokens, unused spacing tokens, repeated raw values that deserve token review, raw values that match known tokens, and conflicting token values. Scan paths are scoped to the directory argument. Use `--ignore`, `.rhythmguardignore`, or `--ignore-path` for generated or legacy subtrees, then add baselines and CI thresholds when you are ready to gate new drift. Markdown output is PR-ready for UX developers, UX designers, and design-system owners:
@@ -125,6 +127,7 @@ For large codebases, put shared audit settings in `.rhythmguardrc.json`:
125
127
  { "path": "./src/theme.css", "format": "css" }
126
128
  ],
127
129
  "tokenKind": "spacing",
130
+ "includeMotion": false,
128
131
  "tokenCandidateMinCount": 2,
129
132
  "minCleanliness": 90
130
133
  }
@@ -221,6 +224,16 @@ npm install --save-dev stylelint-plugin-rhythmguard
221
224
 
222
225
  `react-tailwind` extends the tailwind config with CSS Modules overrides (spacing + radius enforcement) and ignores Next.js build directories.
223
226
 
227
+ ### Motion config
228
+
229
+ ```json
230
+ {
231
+ "extends": ["stylelint-plugin-rhythmguard/configs/motion"]
232
+ }
233
+ ```
234
+
235
+ `motion` enables opt-in duration/delay rhythm checks with `rhythmguard/use-motion-scale`.
236
+
224
237
  Stable shared config entry points:
225
238
 
226
239
  - `stylelint-plugin-rhythmguard/configs/recommended`
@@ -230,6 +243,7 @@ Stable shared config entry points:
230
243
  - `stylelint-plugin-rhythmguard/configs/expanded`
231
244
  - `stylelint-plugin-rhythmguard/configs/logical`
232
245
  - `stylelint-plugin-rhythmguard/configs/migration`
246
+ - `stylelint-plugin-rhythmguard/configs/motion`
233
247
 
234
248
  Framework-specific setup for Vue, Lit, Astro, and SvelteKit: [`docs/FRAMEWORKS.md`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/FRAMEWORKS.md)
235
249
 
@@ -471,7 +485,7 @@ Options:
471
485
  | `enforceInsideMathFunctions` | `boolean` | `false` | Lints `calc()/clamp()/min()/max()` internals |
472
486
  | `mathFunctionArguments` | `Record<mathFn, number[]>` | `{}` | Restricts linting to specific 1-based argument indexes per math function |
473
487
  | `ignoreMathFunctionArguments` | `Record<mathFn, number[]>` | `{}` | Excludes specific 1-based argument indexes per math function |
474
- | `propertyGroups` | `Array<'spacing' \| 'radius' \| 'typography' \| 'size'>` | `['spacing']` | Selects built-in property groups when `properties` is not provided |
488
+ | `propertyGroups` | `Array<'spacing' \| 'radius' \| 'typography' \| 'size' \| 'motion'>` | `['spacing']` | Selects built-in property groups when `properties` is not provided |
475
489
  | `properties` | `Array<string|RegExp>` | built-in spacing patterns | Override targeted property set; string values may be supported property names or regex-like strings (`/pattern/flags`) |
476
490
  | `propertyScales` | `Record<propertyOrRegex, scaleOrPreset>` | `{}` | Per-property scale overrides (supports exact names or `/regex/flags` keys; stateful `g`/`y` flags are normalized for deterministic matching) |
477
491
 
@@ -543,6 +557,35 @@ Options:
543
557
 
544
558
  `rhythmguard/no-offscale-transform` accepts the same scale options as `rhythmguard/use-scale` (including `unitStrategy`, math argument targeting, and deterministic autofix), but only for transform translation properties. Its secondary options are also validated for unknown keys and invalid value shapes.
545
559
 
560
+ ### `rhythmguard/use-motion-scale`
561
+
562
+ Opt-in guardrail for duration, delay, and easing rhythm.
563
+
564
+ Example:
565
+
566
+ ```css
567
+ /* ❌ Off-scale timing + raw easing */
568
+ .button {
569
+ transition: opacity 175ms cubic-bezier(.2, 0, 0, 1);
570
+ }
571
+
572
+ /* ✅ Timing on motion scale */
573
+ .button {
574
+ transition: opacity 150ms var(--ease-snappy);
575
+ }
576
+ ```
577
+
578
+ Options:
579
+
580
+ | Option | Type | Default | Description |
581
+ | --- | --- | --- | --- |
582
+ | `durationScale` | `number[]` | `[0,75,100,150,200,300,500,700,1000]` | Allowed duration and delay values in milliseconds |
583
+ | `durationUnits` | `Array<'ms' \| 's'>` | `['ms','s']` | Time units considered by the rule |
584
+ | `fixToScale` | `boolean` | `true` | Autofixes simple duration/delay values to the nearest scale value |
585
+ | `easingTokenMap` | `Record<string,string>` | `{}` | Optional exact replacements for raw easing functions |
586
+
587
+ Tailwind class strings can use the ESLint companion rule `rhythmguard-tailwind/tailwind-class-use-motion-scale` for `duration-[...]`, `delay-[...]`, and `ease-[...]` arbitrary values.
588
+
546
589
  ## Tailwind CSS Integration
547
590
 
548
591
  Rhythmguard works well in Tailwind projects, but it enforces what Stylelint can parse: CSS declarations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Token governance for CSS and Tailwind — enforce spacing scales, require design tokens, catch arbitrary values",
5
5
  "bin": {
6
6
  "rhythmguard": "src/cli/index.js"
@@ -49,6 +49,10 @@
49
49
  "require": "./src/configs/migration.js",
50
50
  "import": "./src/configs/migration.mjs"
51
51
  },
52
+ "./configs/motion": {
53
+ "require": "./src/configs/motion.js",
54
+ "import": "./src/configs/motion.mjs"
55
+ },
52
56
  "./configs/react-tailwind": {
53
57
  "require": "./src/configs/react-tailwind.js",
54
58
  "import": "./src/configs/react-tailwind.mjs"
@@ -69,6 +73,10 @@
69
73
  "require": "./src/rules/no-offscale-transform/index.js",
70
74
  "import": "./src/rules/no-offscale-transform/index.mjs"
71
75
  },
76
+ "./rules/use-motion-scale": {
77
+ "require": "./src/rules/use-motion-scale/index.js",
78
+ "import": "./src/rules/use-motion-scale/index.mjs"
79
+ },
72
80
  "./eslint": {
73
81
  "require": "./src/eslint/index.js",
74
82
  "import": "./src/eslint/index.mjs"
package/src/cli/audit.js CHANGED
@@ -6,6 +6,8 @@ const path = require('node:path');
6
6
 
7
7
  const { formatLength } = require('../utils/length');
8
8
  const { createTailwindClassAnalyzer } = require('../utils/tailwind-class-analysis');
9
+ const { createTailwindMotionAnalyzer } = require('../utils/tailwind-motion-analysis');
10
+ const { formatTime } = require('../utils/time');
9
11
  const {
10
12
  addDefinition,
11
13
  createTokenKindMatcher,
@@ -69,9 +71,10 @@ Options:
69
71
  --min-cleanliness <percent> Exit 1 when scale cleanliness is lower than this percent
70
72
  --since <git-ref> Scan only changed files since a git ref
71
73
  --staged Scan only staged files
74
+ --include-motion Include opt-in motion duration/easing drift
72
75
  --token-source <file> External token source (repeatable, comma-separated)
73
76
  --token-source-format <format> Token source format: auto, css, flat-json, style-dictionary, dtcg (default: auto)
74
- --token-kind <kind> Token kind: spacing, radius, typography, size, all (default: spacing)
77
+ --token-kind <kind> Token kind: spacing, radius, typography, size, motion, all (default: spacing)
75
78
  --token-candidate-min-count <n> Minimum repeated raw value count for token candidates (default: 2)
76
79
  --scale <values> Comma-separated scale values (default: 0,4,8,12,16,24,32)
77
80
  --base-font-size <number> px base for rem/em conversion (default: 16)
@@ -89,6 +92,7 @@ function parseArgs(argv) {
89
92
  format: 'text',
90
93
  ignorePath: DEFAULT_IGNORE_PATH,
91
94
  ignorePatterns: [],
95
+ includeMotion: false,
92
96
  maxFindings: null,
93
97
  minCleanliness: null,
94
98
  noConfig: false,
@@ -261,6 +265,12 @@ function parseArgs(argv) {
261
265
  continue;
262
266
  }
263
267
 
268
+ if (arg === '--include-motion') {
269
+ parsed.includeMotion = true;
270
+ parsed.cliOptions.add('includeMotion');
271
+ continue;
272
+ }
273
+
264
274
  if (arg === '--token-source') {
265
275
  parsed.tokenSources.push(...parseTokenSourcePaths(argv[++index]));
266
276
  parsed.cliOptions.add('tokenSources');
@@ -572,6 +582,7 @@ function applyAuditConfig(parsed, configResult) {
572
582
  applyConfigScalar(next, audit, cliOptions, 'tokenCandidateMinCount', 'tokenCandidateMinCount', parsePositiveInteger);
573
583
  applyConfigScalar(next, audit, cliOptions, 'tokenKind', 'tokenKind', normalizeTokenKind);
574
584
  applyConfigScalar(next, audit, cliOptions, 'baseFontSize', 'baseFontSize', parseBaseFontSize);
585
+ applyConfigScalar(next, audit, cliOptions, 'includeMotion', 'includeMotion', parseBooleanOption);
575
586
  applyConfigScalar(next, audit, cliOptions, 'scale', 'scale', (value) => {
576
587
  if (Array.isArray(value)) {
577
588
  return value;
@@ -591,6 +602,14 @@ function applyConfigScalar(target, audit, cliOptions, targetKey, configKey, pars
591
602
  target[targetKey] = parser(audit[configKey], configKey);
592
603
  }
593
604
 
605
+ function parseBooleanOption(value, optionName) {
606
+ if (typeof value !== 'boolean') {
607
+ throw new Error(`${optionName} must be a boolean.`);
608
+ }
609
+
610
+ return value;
611
+ }
612
+
594
613
  function normalizeCliTokenSources(sources, format) {
595
614
  return sources.map((sourcePath) => ({
596
615
  baseDir: process.cwd(),
@@ -866,31 +885,41 @@ async function runStylelintAudit(cssFiles, options) {
866
885
  }
867
886
 
868
887
  const { default: stylelint } = await import('stylelint');
888
+ const rules = {
889
+ 'rhythmguard/use-scale': [
890
+ true,
891
+ {
892
+ baseFontSize: options.baseFontSize,
893
+ scale: options.scale,
894
+ severity: 'warning',
895
+ },
896
+ ],
897
+ 'rhythmguard/prefer-token': [
898
+ true,
899
+ {
900
+ baseFontSize: options.baseFontSize,
901
+ scale: options.scale,
902
+ severity: 'warning',
903
+ tokenMapFromCssCustomProperties: true,
904
+ tokenPattern: '^--spac(e|ing)-',
905
+ },
906
+ ],
907
+ };
908
+
909
+ if (options.includeMotion) {
910
+ rules['rhythmguard/use-motion-scale'] = [
911
+ true,
912
+ {
913
+ severity: 'warning',
914
+ },
915
+ ];
916
+ }
869
917
 
870
918
  const result = await stylelint.lint({
871
919
  files: cssFiles,
872
920
  config: {
873
921
  plugins: [pluginPath],
874
- rules: {
875
- 'rhythmguard/use-scale': [
876
- true,
877
- {
878
- baseFontSize: options.baseFontSize,
879
- scale: options.scale,
880
- severity: 'warning',
881
- },
882
- ],
883
- 'rhythmguard/prefer-token': [
884
- true,
885
- {
886
- baseFontSize: options.baseFontSize,
887
- scale: options.scale,
888
- severity: 'warning',
889
- tokenMapFromCssCustomProperties: true,
890
- tokenPattern: '^--spac(e|ing)-',
891
- },
892
- ],
893
- },
922
+ rules,
894
923
  },
895
924
  });
896
925
 
@@ -909,6 +938,12 @@ function collectCssFindings(fileResults) {
909
938
  const tokenMatch = text.match(
910
939
  /Unexpected raw scale value "([^"]+)"/,
911
940
  );
941
+ const motionDurationMatch = text.match(
942
+ /Unexpected (?:motion duration|negative motion duration) "([^"]+)"/,
943
+ );
944
+ const motionEasingMatch = text.match(
945
+ /Unexpected raw motion easing "([^"]+)"/,
946
+ );
912
947
 
913
948
  findings.push({
914
949
  column: warning.column || 1,
@@ -916,8 +951,13 @@ function collectCssFindings(fileResults) {
916
951
  line: warning.line || 1,
917
952
  rule: warning.rule || 'rhythmguard',
918
953
  text,
919
- type: tokenMatch ? 'token-opportunity' : 'off-scale',
920
- value: tokenMatch ? tokenMatch[1] : offScaleMatch ? offScaleMatch[1] : null,
954
+ type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
955
+ value: getCssFindingValue({
956
+ motionDurationMatch,
957
+ motionEasingMatch,
958
+ offScaleMatch,
959
+ tokenMatch,
960
+ }),
921
961
  });
922
962
  }
923
963
  }
@@ -925,6 +965,47 @@ function collectCssFindings(fileResults) {
925
965
  return findings;
926
966
  }
927
967
 
968
+ function getCssFindingValue({
969
+ motionDurationMatch,
970
+ motionEasingMatch,
971
+ offScaleMatch,
972
+ tokenMatch,
973
+ }) {
974
+ if (tokenMatch) {
975
+ return tokenMatch[1];
976
+ }
977
+
978
+ if (offScaleMatch) {
979
+ return offScaleMatch[1];
980
+ }
981
+
982
+ if (motionDurationMatch) {
983
+ return motionDurationMatch[1];
984
+ }
985
+
986
+ if (motionEasingMatch) {
987
+ return motionEasingMatch[1];
988
+ }
989
+
990
+ return null;
991
+ }
992
+
993
+ function getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }) {
994
+ if (motionDurationMatch) {
995
+ return 'motion-duration';
996
+ }
997
+
998
+ if (motionEasingMatch) {
999
+ return 'motion-easing';
1000
+ }
1001
+
1002
+ if (tokenMatch) {
1003
+ return 'token-opportunity';
1004
+ }
1005
+
1006
+ return 'off-scale';
1007
+ }
1008
+
928
1009
  function collectTailwindFindings(templateFiles, options) {
929
1010
  const analyzer = createTailwindClassAnalyzer(options);
930
1011
  const findings = [];
@@ -969,6 +1050,66 @@ function collectTailwindFindings(templateFiles, options) {
969
1050
  return findings;
970
1051
  }
971
1052
 
1053
+ function collectTailwindMotionFindings(templateFiles, options) {
1054
+ if (!options.includeMotion) {
1055
+ return [];
1056
+ }
1057
+
1058
+ const analyzer = createTailwindMotionAnalyzer(options);
1059
+ const findings = [];
1060
+
1061
+ for (const filePath of templateFiles) {
1062
+ let source = '';
1063
+ try {
1064
+ source = fs.readFileSync(filePath, 'utf8');
1065
+ } catch {
1066
+ continue;
1067
+ }
1068
+
1069
+ const lineStarts = getLineStarts(source);
1070
+
1071
+ for (const literal of findStringLiterals(source)) {
1072
+ for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
1073
+ const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
1074
+ findings.push({
1075
+ column: position.column,
1076
+ file: formatPath(filePath),
1077
+ fixedToken: analysis.fixedToken,
1078
+ line: position.line,
1079
+ nearest: analysis.nearest
1080
+ ? {
1081
+ lower: formatTime(analysis.nearest.lower, 'ms'),
1082
+ upper: formatTime(analysis.nearest.upper, 'ms'),
1083
+ }
1084
+ : null,
1085
+ rawValue: analysis.rawValue,
1086
+ rule: 'rhythmguard-tailwind/tailwind-class-use-motion-scale',
1087
+ text: buildTailwindMotionFindingText(segment.token, analysis),
1088
+ token: segment.token,
1089
+ type: analysis.reason === 'easing'
1090
+ ? 'tailwind-motion-easing'
1091
+ : 'tailwind-motion-duration',
1092
+ utility: analysis.utility,
1093
+ });
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ return findings;
1099
+ }
1100
+
1101
+ function buildTailwindMotionFindingText(token, analysis) {
1102
+ if (analysis.reason === 'easing') {
1103
+ return `Unexpected Tailwind arbitrary motion easing "${token}". Use motion tokens for easing decisions.`;
1104
+ }
1105
+
1106
+ if (analysis.reason === 'negative') {
1107
+ return `Unexpected Tailwind arbitrary motion duration "${token}". Use non-negative duration values.`;
1108
+ }
1109
+
1110
+ return `Unexpected Tailwind arbitrary motion duration "${token}". Use duration scale values.`;
1111
+ }
1112
+
972
1113
  function findStringLiterals(source) {
973
1114
  const literals = [];
974
1115
  const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
@@ -1250,6 +1391,8 @@ function buildReport({
1250
1391
  cssFindings,
1251
1392
  dir,
1252
1393
  externalTokenDefinitions,
1394
+ includeMotion,
1395
+ motionFindings,
1253
1396
  scanScope,
1254
1397
  templateFiles,
1255
1398
  tailwindFindings,
@@ -1266,17 +1409,21 @@ function buildReport({
1266
1409
  .map((finding) => finding.value));
1267
1410
  const tailwindArbitraryValues = countByValue(tailwindFindings
1268
1411
  .map((finding) => finding.rawValue));
1412
+ const motionValues = countByValue(motionFindings
1413
+ .map((finding) => finding.value || finding.rawValue));
1269
1414
  const issueFiles = new Set([
1270
1415
  ...cssFindings.map((finding) => finding.file),
1416
+ ...motionFindings.map((finding) => finding.file),
1271
1417
  ...tailwindFindings.map((finding) => finding.file),
1272
1418
  ]);
1273
1419
  const topAffectedFiles = sortCountMap(countByValue([
1274
1420
  ...cssFindings.map((finding) => finding.file),
1421
+ ...motionFindings.map((finding) => finding.file),
1275
1422
  ...tailwindFindings.map((finding) => finding.file),
1276
1423
  ])).slice(0, 10);
1277
1424
 
1278
1425
  const totalFiles = cssFiles.length + templateFiles.length;
1279
- const totalWarnings = cssFindings.length + tailwindFindings.length;
1426
+ const totalWarnings = cssFindings.length + motionFindings.length + tailwindFindings.length;
1280
1427
  const filesWithIssues = issueFiles.size;
1281
1428
  const scaleCleanliness = totalFiles > 0
1282
1429
  ? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
@@ -1299,9 +1446,15 @@ function buildReport({
1299
1446
  filesWithIssues,
1300
1447
  findings: {
1301
1448
  css: cssFindings,
1449
+ motion: motionFindings,
1302
1450
  tailwind: tailwindFindings,
1303
1451
  },
1304
- formatVersion: 4,
1452
+ formatVersion: 5,
1453
+ motion: {
1454
+ enabled: includeMotion,
1455
+ findings: motionFindings.length,
1456
+ values: Object.fromEntries(sortCountMap(motionValues).slice(0, 10)),
1457
+ },
1305
1458
  offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
1306
1459
  scaleCleanliness,
1307
1460
  scanScope,
@@ -1313,6 +1466,7 @@ function buildReport({
1313
1466
  summary: {
1314
1467
  cssWarnings: cssFindings.length,
1315
1468
  filesWithIssues,
1469
+ motionFindings: motionFindings.length,
1316
1470
  rawValueMatches: tokenContract.summary.rawValueMatches,
1317
1471
  missingTokens: tokenContract.summary.missingTokens,
1318
1472
  rawValueCandidates: tokenContract.summary.rawValueCandidates,
@@ -1405,6 +1559,7 @@ function writeBaseline(report, baselinePath) {
1405
1559
  function getAllFindings(report) {
1406
1560
  return [
1407
1561
  ...report.findings.css,
1562
+ ...report.findings.motion,
1408
1563
  ...report.findings.tailwind,
1409
1564
  ];
1410
1565
  }
@@ -1470,6 +1625,7 @@ function renderText(report) {
1470
1625
  appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
1471
1626
  appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
1472
1627
  appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
1628
+ appendHistogram(lines, 'MOTION RHYTHM DRIFT', report.motion.values);
1473
1629
  appendTokenContractText(lines, report.tokenContract);
1474
1630
  appendBaselineText(lines, report);
1475
1631
 
@@ -1622,6 +1778,7 @@ function renderMarkdown(report) {
1622
1778
  appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
1623
1779
  appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
1624
1780
  appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
1781
+ appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
1625
1782
  appendTokenContractMarkdown(lines, report.tokenContract);
1626
1783
  appendBaselineMarkdown(lines, report);
1627
1784
 
@@ -1872,6 +2029,7 @@ async function run() {
1872
2029
  const { cssFiles, scanScope, templateFiles } = scanFiles;
1873
2030
  const options = {
1874
2031
  baseFontSize: parsed.baseFontSize,
2032
+ includeMotion: parsed.includeMotion,
1875
2033
  scale: parsed.scale,
1876
2034
  };
1877
2035
 
@@ -1888,14 +2046,22 @@ async function run() {
1888
2046
  sources: parsed.tokenSources,
1889
2047
  tokenKind: parsed.tokenKind,
1890
2048
  });
2049
+ const stylelintFindings = collectCssFindings(cssResults);
2050
+ const cssFindings = stylelintFindings.filter((finding) => !finding.type.startsWith('motion-'));
2051
+ const motionFindings = [
2052
+ ...stylelintFindings.filter((finding) => finding.type.startsWith('motion-')),
2053
+ ...collectTailwindMotionFindings(templateFiles, options),
2054
+ ];
1891
2055
 
1892
2056
  const report = buildReport({
1893
2057
  baseFontSize: parsed.baseFontSize,
1894
2058
  config: parsed.config,
1895
2059
  cssFiles,
1896
- cssFindings: collectCssFindings(cssResults),
2060
+ cssFindings,
1897
2061
  dir: parsed.dir,
1898
2062
  externalTokenDefinitions: tokenSourceResult.definitions,
2063
+ includeMotion: parsed.includeMotion,
2064
+ motionFindings,
1899
2065
  scanScope,
1900
2066
  tailwindFindings: collectTailwindFindings(templateFiles, options),
1901
2067
  templateFiles,
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ plugins: ['stylelint-plugin-rhythmguard'],
5
+ rules: {
6
+ 'rhythmguard/use-motion-scale': [
7
+ true,
8
+ {
9
+ durationScale: [0, 75, 100, 150, 200, 300, 500, 700, 1000],
10
+ },
11
+ ],
12
+ },
13
+ };
@@ -0,0 +1,4 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ const config = require('./motion.js');
4
+ export default config;
@@ -1,15 +1,18 @@
1
1
  'use strict';
2
2
 
3
3
  const tailwindClassUseScale = require('./rules/tailwind-class-use-scale');
4
+ const tailwindClassUseMotionScale = require('./rules/tailwind-class-use-motion-scale');
4
5
 
5
6
  module.exports = {
6
7
  rules: {
7
8
  'tailwind-class-use-scale': tailwindClassUseScale,
9
+ 'tailwind-class-use-motion-scale': tailwindClassUseMotionScale,
8
10
  },
9
11
  configs: {
10
12
  recommended: {
11
13
  rules: {
12
14
  'rhythmguard-tailwind/tailwind-class-use-scale': 'warn',
15
+ 'rhythmguard-tailwind/tailwind-class-use-motion-scale': 'off',
13
16
  },
14
17
  },
15
18
  },
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ const { formatTime } = require('../../utils/time');
4
+ const { createTailwindMotionAnalyzer } = require('../../utils/tailwind-motion-analysis');
5
+
6
+ const RULE_NAME = 'tailwind-class-use-motion-scale';
7
+
8
+ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
9
+ const rawText = sourceCode.getText(node);
10
+ let value = null;
11
+ let quote = null;
12
+
13
+ if (node.type === 'Literal' && typeof node.value === 'string') {
14
+ value = node.value;
15
+ quote = rawText[0] === '"' || rawText[0] === "'" ? rawText[0] : '"';
16
+ }
17
+
18
+ if (node.type === 'TemplateElement') {
19
+ value = node.value.raw;
20
+ }
21
+
22
+ if (!value || typeof value !== 'string') {
23
+ return;
24
+ }
25
+
26
+ const findings = analyzer.analyzeClassString(value);
27
+ if (findings.length === 0) {
28
+ return;
29
+ }
30
+
31
+ let fixedValue = value;
32
+ let fixedValueOffset = 0;
33
+
34
+ for (const { analysis, segment } of findings) {
35
+ if (allowFix && analysis.reason === 'duration' && node.type === 'Literal') {
36
+ const replacementStart = segment.start + fixedValueOffset;
37
+ fixedValue = `${fixedValue.slice(0, replacementStart)}${analysis.fixedToken}${fixedValue.slice(replacementStart + segment.token.length)}`;
38
+ fixedValueOffset += analysis.fixedToken.length - segment.token.length;
39
+ }
40
+ }
41
+
42
+ const fixedText = allowFix && node.type === 'Literal' && fixedValue !== value
43
+ ? `${quote}${fixedValue.replace(new RegExp(quote, 'g'), `\\${quote}`)}${quote}`
44
+ : null;
45
+
46
+ for (const { analysis, segment } of findings) {
47
+ const lower = analysis.nearest
48
+ ? formatTime(analysis.nearest.lower, 'ms')
49
+ : 'n/a';
50
+ const upper = analysis.nearest
51
+ ? formatTime(analysis.nearest.upper, 'ms')
52
+ : 'n/a';
53
+ context.report({
54
+ message: buildMessage(analysis, segment, lower, upper),
55
+ node,
56
+ fix:
57
+ fixedText && analysis.reason === 'duration'
58
+ ? (fixer) => fixer.replaceText(node, fixedText)
59
+ : null,
60
+ });
61
+ }
62
+ }
63
+
64
+ function buildMessage(analysis, segment, lower, upper) {
65
+ if (analysis.reason === 'easing') {
66
+ return `Unexpected Tailwind arbitrary motion easing "${segment.token}". Use motion tokens for easing decisions.`;
67
+ }
68
+
69
+ if (analysis.reason === 'negative') {
70
+ return `Unexpected Tailwind arbitrary motion duration "${segment.token}". Use non-negative duration values.`;
71
+ }
72
+
73
+ return `Unexpected Tailwind arbitrary motion duration "${segment.token}". Use duration scale values (nearest: ${lower} or ${upper}).`;
74
+ }
75
+
76
+ module.exports = {
77
+ meta: {
78
+ docs: {
79
+ description: 'Enforce duration scale for Tailwind arbitrary motion utilities in class strings',
80
+ },
81
+ fixable: 'code',
82
+ schema: [
83
+ {
84
+ additionalProperties: false,
85
+ properties: {
86
+ durationScale: {
87
+ items: { type: 'number' },
88
+ type: 'array',
89
+ },
90
+ durationUnits: {
91
+ items: {
92
+ enum: ['ms', 's'],
93
+ type: 'string',
94
+ },
95
+ type: 'array',
96
+ },
97
+ },
98
+ type: 'object',
99
+ },
100
+ ],
101
+ },
102
+ create(context) {
103
+ const analyzer = createTailwindMotionAnalyzer(context.options && context.options[0]);
104
+ const sourceCode = context.sourceCode || context.getSourceCode();
105
+
106
+ return {
107
+ Literal(node) {
108
+ maybeCheckNodeText(node, sourceCode, context, analyzer, true);
109
+ },
110
+ TemplateElement(node) {
111
+ maybeCheckNodeText(node, sourceCode, context, analyzer, false);
112
+ },
113
+ };
114
+ },
115
+ ruleName: RULE_NAME,
116
+ };
package/src/index.js CHANGED
@@ -3,14 +3,16 @@
3
3
  const useScale = require('./rules/use-scale');
4
4
  const preferToken = require('./rules/prefer-token');
5
5
  const noOffscaleTransform = require('./rules/no-offscale-transform');
6
+ const useMotionScale = require('./rules/use-motion-scale');
6
7
 
7
- const rules = [useScale, preferToken, noOffscaleTransform];
8
+ const rules = [useScale, preferToken, noOffscaleTransform, useMotionScale];
8
9
 
9
10
  module.exports = rules;
10
11
  module.exports.rules = {
11
12
  [useScale.ruleName]: useScale,
12
13
  [preferToken.ruleName]: preferToken,
13
14
  [noOffscaleTransform.ruleName]: noOffscaleTransform,
15
+ [useMotionScale.ruleName]: useMotionScale,
14
16
  };
15
17
  module.exports.configs = {
16
18
  recommended: require('./configs/recommended'),
@@ -19,6 +21,7 @@ module.exports.configs = {
19
21
  expanded: require('./configs/expanded'),
20
22
  logical: require('./configs/logical'),
21
23
  migration: require('./configs/migration'),
24
+ motion: require('./configs/motion'),
22
25
  };
23
26
  module.exports.eslint = require('./eslint');
24
27
  module.exports.presets = require('./presets');
@@ -0,0 +1,245 @@
1
+ 'use strict';
2
+
3
+ const stylelint = require('stylelint');
4
+ const valueParser = require('postcss-value-parser');
5
+ const {
6
+ nearestScaleValues,
7
+ numbersEqual,
8
+ } = require('../../utils/length');
9
+ const {
10
+ declarationValueIndex,
11
+ walkRootValueNodes,
12
+ } = require('../../utils/value-utils');
13
+ const {
14
+ formatTime,
15
+ fromMs,
16
+ normalizeDurationScale,
17
+ normalizeDurationUnits,
18
+ parseTimeToken,
19
+ toMs,
20
+ } = require('../../utils/time');
21
+
22
+ const ruleName = 'rhythmguard/use-motion-scale';
23
+ const DURATION_PROPERTIES = new Set([
24
+ 'transition-duration',
25
+ 'transition-delay',
26
+ 'animation-duration',
27
+ 'animation-delay',
28
+ 'transition',
29
+ 'animation',
30
+ ]);
31
+ const EASING_PROPERTIES = new Set([
32
+ 'transition-timing-function',
33
+ 'animation-timing-function',
34
+ 'transition',
35
+ 'animation',
36
+ ]);
37
+ const EASING_FUNCTIONS = new Set(['cubic-bezier', 'linear', 'steps']);
38
+
39
+ const messages = stylelint.utils.ruleMessages(ruleName, {
40
+ invalidDuration: (value) =>
41
+ `Unexpected negative motion duration "${value}". Use non-negative duration values.`,
42
+ rejectedDuration: (value, lower, upper) =>
43
+ `Unexpected motion duration "${value}". Use duration scale values (nearest: ${lower} or ${upper}).`,
44
+ rejectedEasing: (value) =>
45
+ `Unexpected raw motion easing "${value}". Use motion tokens for easing decisions.`,
46
+ });
47
+
48
+ function isPlainObject(value) {
49
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
50
+ }
51
+
52
+ function buildOptions(rawOptions) {
53
+ const options = rawOptions || {};
54
+
55
+ return {
56
+ durationScale: normalizeDurationScale(options.durationScale),
57
+ durationUnits: normalizeDurationUnits(options.durationUnits),
58
+ easingTokenMap: isPlainObject(options.easingTokenMap) ? options.easingTokenMap : {},
59
+ fixToScale: options.fixToScale !== false,
60
+ };
61
+ }
62
+
63
+ function validateSecondaryOptions(result, secondaryOptions) {
64
+ return stylelint.utils.validateOptions(result, ruleName, {
65
+ actual: secondaryOptions,
66
+ optional: true,
67
+ possible: {
68
+ durationScale: [(value) =>
69
+ typeof value === 'number' &&
70
+ Number.isFinite(value) &&
71
+ value >= 0],
72
+ durationUnits: [(value) => value === 'ms' || value === 's'],
73
+ easingTokenMap: [(value) =>
74
+ isPlainObject(value) &&
75
+ Object.values(value).every((entry) =>
76
+ typeof entry === 'string' && entry.trim().length > 0,
77
+ )],
78
+ fixToScale: [true, false],
79
+ },
80
+ });
81
+ }
82
+
83
+ function getFixedNodeValue(parsedTime, nearestMs, options) {
84
+ if (!options.durationUnits.includes(parsedTime.unit)) {
85
+ return null;
86
+ }
87
+
88
+ const converted = fromMs(nearestMs, parsedTime.unit);
89
+ if (converted === null) {
90
+ return null;
91
+ }
92
+
93
+ return formatTime(converted, parsedTime.unit);
94
+ }
95
+
96
+ function functionToString(node) {
97
+ return valueParser.stringify(node);
98
+ }
99
+
100
+ const ruleFunction = (primary, secondaryOptions) => {
101
+ return (root, result) => {
102
+ const valid = stylelint.utils.validateOptions(result, ruleName, {
103
+ actual: primary,
104
+ possible: [true],
105
+ });
106
+
107
+ if (!valid || !validateSecondaryOptions(result, secondaryOptions)) {
108
+ return;
109
+ }
110
+
111
+ const options = buildOptions(secondaryOptions);
112
+
113
+ root.walkDecls((decl) => {
114
+ const prop = decl.prop.toLowerCase();
115
+ if (!DURATION_PROPERTIES.has(prop) && !EASING_PROPERTIES.has(prop)) {
116
+ return;
117
+ }
118
+
119
+ const parsed = valueParser(decl.value);
120
+ let changed = false;
121
+
122
+ const reportDuration = (node, nearest, fixedValue = null) => {
123
+ const index = declarationValueIndex(decl) + node.sourceIndex;
124
+ const payload = {
125
+ endIndex: index + node.value.length,
126
+ index,
127
+ message: nearest
128
+ ? messages.rejectedDuration(
129
+ node.value,
130
+ formatTime(nearest.lower, 'ms'),
131
+ formatTime(nearest.upper, 'ms'),
132
+ )
133
+ : messages.invalidDuration(node.value),
134
+ node: decl,
135
+ result,
136
+ ruleName,
137
+ };
138
+
139
+ if (fixedValue) {
140
+ payload.fix = () => {
141
+ node.value = fixedValue;
142
+ return true;
143
+ };
144
+ }
145
+
146
+ stylelint.utils.report(payload);
147
+ };
148
+
149
+ const reportEasing = (node, replacement = null) => {
150
+ const source = functionToString(node);
151
+ const index = declarationValueIndex(decl) + node.sourceIndex;
152
+ const payload = {
153
+ endIndex: index + source.length,
154
+ index,
155
+ message: messages.rejectedEasing(source),
156
+ node: decl,
157
+ result,
158
+ ruleName,
159
+ };
160
+
161
+ if (replacement) {
162
+ payload.fix = () => {
163
+ node.type = 'word';
164
+ node.value = replacement;
165
+ delete node.nodes;
166
+ return true;
167
+ };
168
+ }
169
+
170
+ stylelint.utils.report(payload);
171
+ };
172
+
173
+ walkRootValueNodes(parsed, (node) => {
174
+ if (node.type === 'function') {
175
+ const functionName = node.value.toLowerCase();
176
+ if (!EASING_PROPERTIES.has(prop) || !EASING_FUNCTIONS.has(functionName)) {
177
+ return false;
178
+ }
179
+
180
+ const source = functionToString(node);
181
+ reportEasing(node, options.easingTokenMap[source] || null);
182
+ changed = changed || Boolean(options.easingTokenMap[source]);
183
+ return true;
184
+ }
185
+
186
+ if (node.type !== 'word' || !DURATION_PROPERTIES.has(prop)) {
187
+ return false;
188
+ }
189
+
190
+ const parsedTime = parseTimeToken(node.value);
191
+ if (!parsedTime) {
192
+ return false;
193
+ }
194
+
195
+ if (parsedTime.number < 0) {
196
+ reportDuration(node, null);
197
+ return false;
198
+ }
199
+
200
+ if (!options.durationUnits.includes(parsedTime.unit)) {
201
+ return false;
202
+ }
203
+
204
+ const msValue = toMs(parsedTime.number, parsedTime.unit);
205
+ if (msValue === null) {
206
+ return false;
207
+ }
208
+
209
+ const isOnScale = options.durationScale.some((scaleValue) => numbersEqual(scaleValue, msValue));
210
+ if (isOnScale) {
211
+ return false;
212
+ }
213
+
214
+ const nearest = nearestScaleValues(msValue, options.durationScale);
215
+ if (!nearest) {
216
+ return false;
217
+ }
218
+
219
+ const fixedValue = options.fixToScale
220
+ ? getFixedNodeValue(parsedTime, nearest.nearest, options)
221
+ : null;
222
+
223
+ reportDuration(node, nearest, fixedValue);
224
+ changed = changed || Boolean(fixedValue);
225
+ return false;
226
+ });
227
+
228
+ if (changed) {
229
+ decl.value = parsed.toString();
230
+ }
231
+ });
232
+ };
233
+ };
234
+
235
+ ruleFunction.ruleName = ruleName;
236
+ ruleFunction.messages = messages;
237
+ ruleFunction.meta = {
238
+ fixable: true,
239
+ url: 'https://github.com/petrilahdelma/stylelint-plugin-rhythmguard#rhythmguarduse-motion-scale',
240
+ };
241
+
242
+ module.exports = stylelint.createPlugin(ruleName, ruleFunction);
243
+ module.exports.ruleName = ruleName;
244
+ module.exports.messages = messages;
245
+ module.exports.meta = ruleFunction.meta;
@@ -0,0 +1,7 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ const rule = require('./index.js');
4
+ export default rule;
5
+ export const ruleName = rule.ruleName;
6
+ export const messages = rule.messages;
7
+ export const meta = rule.meta;
@@ -35,6 +35,10 @@ const PROPERTY_GROUP_PATTERNS = Object.freeze({
35
35
  /^letter-spacing$/,
36
36
  /^word-spacing$/,
37
37
  ]),
38
+ motion: Object.freeze([
39
+ /^transition(?:-.+)?$/,
40
+ /^animation(?:-.+)?$/,
41
+ ]),
38
42
  });
39
43
 
40
44
  const DEFAULT_PROPERTY_GROUPS = Object.freeze(['spacing']);
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ nearestScaleValues,
5
+ numbersEqual,
6
+ } = require('./length');
7
+ const {
8
+ formatTime,
9
+ fromMs,
10
+ normalizeDurationScale,
11
+ normalizeDurationUnits,
12
+ parseTimeToken,
13
+ toMs,
14
+ } = require('./time');
15
+ const { findClassSegments } = require('./tailwind-class-analysis');
16
+
17
+ const ARBITRARY_MOTION_CLASS = /^(?<utility>duration|delay|ease)-\[(?<rawValue>[^\]]+)\]$/;
18
+
19
+ function normalizeTailwindMotionOptions(option = {}) {
20
+ return {
21
+ durationScale: normalizeDurationScale(option.durationScale),
22
+ durationUnits: normalizeDurationUnits(option.durationUnits),
23
+ };
24
+ }
25
+
26
+ function findLastVariantSeparator(token) {
27
+ let bracketDepth = 0;
28
+ let separatorIndex = -1;
29
+
30
+ for (let index = 0; index < token.length; index++) {
31
+ const character = token[index];
32
+
33
+ if (character === '[') {
34
+ bracketDepth++;
35
+ continue;
36
+ }
37
+
38
+ if (character === ']' && bracketDepth > 0) {
39
+ bracketDepth--;
40
+ continue;
41
+ }
42
+
43
+ if (character === ':' && bracketDepth === 0) {
44
+ separatorIndex = index;
45
+ }
46
+ }
47
+
48
+ return separatorIndex;
49
+ }
50
+
51
+ function parseClassToken(token) {
52
+ const separatorIndex = findLastVariantSeparator(token);
53
+ const prefix = separatorIndex === -1
54
+ ? ''
55
+ : token.slice(0, separatorIndex + 1);
56
+ let candidate = separatorIndex === -1
57
+ ? token
58
+ : token.slice(separatorIndex + 1);
59
+ let leadingImportant = '';
60
+ let trailingImportant = '';
61
+
62
+ if (candidate.startsWith('!')) {
63
+ leadingImportant = '!';
64
+ candidate = candidate.slice(1);
65
+ }
66
+
67
+ if (candidate.endsWith('!')) {
68
+ trailingImportant = '!';
69
+ candidate = candidate.slice(0, -1);
70
+ }
71
+
72
+ return {
73
+ candidate,
74
+ leadingImportant,
75
+ prefix,
76
+ trailingImportant,
77
+ };
78
+ }
79
+
80
+ function analyzeMotionToken(token, options) {
81
+ const parsedToken = parseClassToken(token);
82
+ const match = parsedToken.candidate.match(ARBITRARY_MOTION_CLASS);
83
+ if (!match || !match.groups) {
84
+ return null;
85
+ }
86
+
87
+ if (match.groups.utility === 'ease') {
88
+ return {
89
+ fixedToken: null,
90
+ rawValue: match.groups.rawValue,
91
+ reason: 'easing',
92
+ utility: match.groups.utility,
93
+ };
94
+ }
95
+
96
+ const parsedTime = parseTimeToken(match.groups.rawValue);
97
+ if (!parsedTime) {
98
+ return null;
99
+ }
100
+
101
+ if (parsedTime.number < 0) {
102
+ return {
103
+ fixedToken: null,
104
+ nearest: null,
105
+ rawValue: match.groups.rawValue,
106
+ reason: 'negative',
107
+ utility: match.groups.utility,
108
+ };
109
+ }
110
+
111
+ if (!options.durationUnits.includes(parsedTime.unit)) {
112
+ return null;
113
+ }
114
+
115
+ const msValue = toMs(parsedTime.number, parsedTime.unit);
116
+ if (msValue === null) {
117
+ return null;
118
+ }
119
+
120
+ const isOnScale = options.durationScale.some((entry) => numbersEqual(entry, msValue));
121
+ if (isOnScale) {
122
+ return null;
123
+ }
124
+
125
+ const nearest = nearestScaleValues(msValue, options.durationScale);
126
+ if (!nearest) {
127
+ return null;
128
+ }
129
+
130
+ const converted = fromMs(nearest.nearest, parsedTime.unit);
131
+ if (converted === null) {
132
+ return null;
133
+ }
134
+
135
+ const replacementValue = formatTime(converted, parsedTime.unit);
136
+ const fixedCandidate = parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
137
+ const fixedToken = `${parsedToken.prefix}${parsedToken.leadingImportant}${fixedCandidate}${parsedToken.trailingImportant}`;
138
+
139
+ return {
140
+ fixedToken,
141
+ nearest,
142
+ rawValue: match.groups.rawValue,
143
+ reason: 'duration',
144
+ utility: match.groups.utility,
145
+ };
146
+ }
147
+
148
+ function createTailwindMotionAnalyzer(option = {}) {
149
+ const options = normalizeTailwindMotionOptions(option);
150
+
151
+ return {
152
+ analyzeClassString(value) {
153
+ const findings = [];
154
+
155
+ for (const segment of findClassSegments(value)) {
156
+ const analysis = analyzeMotionToken(segment.token, options);
157
+ if (analysis) {
158
+ findings.push({ analysis, segment });
159
+ }
160
+ }
161
+
162
+ return findings;
163
+ },
164
+ analyzeToken(token) {
165
+ return analyzeMotionToken(token, options);
166
+ },
167
+ options,
168
+ };
169
+ }
170
+
171
+ module.exports = {
172
+ createTailwindMotionAnalyzer,
173
+ normalizeTailwindMotionOptions,
174
+ };
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const TIME_RE = /^(-?(?:\d+|\d*\.\d+))(ms|s)$/i;
4
+
5
+ function parseTimeToken(value) {
6
+ if (typeof value !== 'string') {
7
+ return null;
8
+ }
9
+
10
+ const match = value.trim().match(TIME_RE);
11
+ if (!match) {
12
+ return null;
13
+ }
14
+
15
+ const number = Number(match[1]);
16
+ if (!Number.isFinite(number)) {
17
+ return null;
18
+ }
19
+
20
+ return {
21
+ number,
22
+ unit: match[2].toLowerCase(),
23
+ };
24
+ }
25
+
26
+ function toMs(number, unit) {
27
+ if (unit === 'ms') {
28
+ return number;
29
+ }
30
+
31
+ if (unit === 's') {
32
+ return number * 1000;
33
+ }
34
+
35
+ return null;
36
+ }
37
+
38
+ function fromMs(ms, unit) {
39
+ if (unit === 'ms') {
40
+ return ms;
41
+ }
42
+
43
+ if (unit === 's') {
44
+ return ms / 1000;
45
+ }
46
+
47
+ return null;
48
+ }
49
+
50
+ function formatTime(number, unit) {
51
+ const normalized = Object.is(number, -0) ? 0 : number;
52
+ if (Number.isInteger(normalized)) {
53
+ return `${normalized}${unit}`;
54
+ }
55
+
56
+ return `${Number(normalized.toFixed(4)).toString()}${unit}`;
57
+ }
58
+
59
+ function normalizeDurationScale(scale) {
60
+ const source = Array.isArray(scale) && scale.length > 0
61
+ ? scale
62
+ : [0, 75, 100, 150, 200, 300, 500, 700, 1000];
63
+
64
+ return [...new Set(source
65
+ .map((entry) => Number(entry))
66
+ .filter((entry) => Number.isFinite(entry) && entry >= 0))]
67
+ .sort((a, b) => a - b);
68
+ }
69
+
70
+ function normalizeDurationUnits(units) {
71
+ const source = Array.isArray(units) && units.length > 0
72
+ ? units
73
+ : ['ms', 's'];
74
+
75
+ const normalized = source
76
+ .map((unit) => String(unit).trim().toLowerCase())
77
+ .filter((unit) => unit === 'ms' || unit === 's');
78
+
79
+ return normalized.length > 0 ? [...new Set(normalized)] : ['ms', 's'];
80
+ }
81
+
82
+ module.exports = {
83
+ formatTime,
84
+ fromMs,
85
+ normalizeDurationScale,
86
+ normalizeDurationUnits,
87
+ parseTimeToken,
88
+ toMs,
89
+ };
@@ -22,12 +22,14 @@ const VALID_TOKEN_KINDS = new Set([
22
22
  'radius',
23
23
  'typography',
24
24
  'size',
25
+ 'motion',
25
26
  'all',
26
27
  ]);
27
28
 
28
29
  const TOKEN_KIND_PATTERNS = Object.freeze({
29
30
  all: /^--/,
30
31
  radius: /^--radius-/,
32
+ motion: /^--(?:motion|duration|delay|ease|easing)-/,
31
33
  size: /^--(?:size|width|height|container)-/,
32
34
  spacing: /^--(?:space|spacing)-/,
33
35
  typography: /^--(?:font|font-size|line-height|leading|tracking|typography)-/,
@@ -53,7 +55,7 @@ function normalizeTokenSourceFormat(format) {
53
55
  function normalizeTokenKind(kind) {
54
56
  const normalized = String(kind || 'spacing').trim().toLowerCase();
55
57
  if (!VALID_TOKEN_KINDS.has(normalized)) {
56
- throw new Error(`Invalid token kind "${kind}". Expected spacing, radius, typography, size, or all.`);
58
+ throw new Error(`Invalid token kind "${kind}". Expected spacing, radius, typography, size, motion, or all.`);
57
59
  }
58
60
 
59
61
  return normalized;