@atlaskit/eslint-plugin-design-system 15.0.0 → 15.1.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.
@@ -12,9 +12,7 @@ const rule = createLintRule({
12
12
  },
13
13
  messages: {
14
14
  updateAppearance: 'Update appearance value to new semantic value.',
15
- migrateTag: 'Non-bold <Lozenge> variants should migrate to <Tag> component. For safe, staged rollout, use the `migration_fallback="lozenge"` prop which renders as Lozenge when the feature flag is off.',
16
- manualReview: "Dynamic 'isBold' props require manual review before migration.",
17
- dynamicLozengeAppearance: "Dynamic 'appearance' prop values require manual review before migrating to Tag. Please verify the appearance value and manually convert it to the appropriate color prop value.",
15
+ migrateTag: '<SimpleTag> and <RemovableTag> components should migrate to the new <Tag> or <AvatarTag> component.',
18
16
  updateBadgeAppearance: 'Update Badge appearance value "{{oldValue}}" to new semantic value "{{newValue}}".',
19
17
  dynamicBadgeAppearance: 'Dynamic appearance prop values require manual review to ensure they use the new semantic values: neutral, information, inverse, danger, success.'
20
18
  }
@@ -60,13 +58,6 @@ const rule = createLintRule({
60
58
  */
61
59
  const newTagImports = {};
62
60
 
63
- /**
64
- * Check if a JSX attribute value is a literal false
65
- */
66
- function isLiteralFalse(node) {
67
- return node && node.type === 'JSXExpressionContainer' && node.expression && node.expression.type === 'Literal' && node.expression.value === false;
68
- }
69
-
70
61
  /**
71
62
  * Check if a JSX attribute value is dynamic (not a static literal value)
72
63
  * Can be used for any prop type (boolean, string, etc.)
@@ -103,37 +94,22 @@ const rule = createLintRule({
103
94
  }
104
95
 
105
96
  /**
106
- * Map old appearance values to new semantic appearance values
107
- * Both Lozenge and Tag now use the same appearance prop with new semantic values
97
+ * Map old Lozenge appearance values to new semantic appearance values.
98
+ * The new Lozenge no longer uses legacy values it uses semantic color names
99
+ * that align with the new labelling system.
108
100
  */
109
101
  function mapToNewAppearanceValue(oldValue) {
110
102
  const mapping = {
111
- success: 'success',
112
- default: 'default',
113
- removed: 'removed',
114
- inprogress: 'inprogress',
115
- new: 'new',
116
- moved: 'moved'
103
+ default: 'neutral',
104
+ inprogress: 'information',
105
+ moved: 'warning',
106
+ removed: 'danger',
107
+ new: 'discovery',
108
+ success: 'success'
117
109
  };
118
110
  return mapping[oldValue] || oldValue;
119
111
  }
120
112
 
121
- /**
122
- * Map Lozenge appearance values to Tag color values
123
- * Used when migrating Lozenge to Tag component
124
- */
125
- function mapLozengeAppearanceToTagColor(appearanceValue) {
126
- const mapping = {
127
- success: 'lime',
128
- default: 'gray',
129
- removed: 'red',
130
- inprogress: 'blue',
131
- new: 'purple',
132
- moved: 'yellow'
133
- };
134
- return mapping[appearanceValue] || appearanceValue;
135
- }
136
-
137
113
  /**
138
114
  * Map Badge old appearance values to new semantic appearance values
139
115
  */
@@ -259,7 +235,7 @@ const rule = createLintRule({
259
235
 
260
236
  /**
261
237
  * Generate the replacement JSX element text for Tag migration
262
- * Handles both regular Tag and avatarTag migrations
238
+ * Handles both regular Tag and AvatarTag migrations for SimpleTag/RemovableTag.
263
239
  */
264
240
  function generateTagReplacement(node, options = {}) {
265
241
  var _context$sourceCode;
@@ -277,20 +253,7 @@ const rule = createLintRule({
277
253
  return;
278
254
  }
279
255
  if (attrName === 'appearance') {
280
- // For Lozenge migrations, convert appearance to color prop
281
- // For SimpleTag/RemovableTag migrations, delete appearance prop
282
- if (options.isLozengeMigration) {
283
- // Map Lozenge appearance value to Tag color value and change prop name from appearance to color
284
- const stringValue = extractStringValue(attr.value);
285
- if (stringValue && typeof stringValue === 'string') {
286
- const mappedColor = mapLozengeAppearanceToTagColor(stringValue);
287
- newAttributes.push(`color="${mappedColor}"`);
288
- }
289
- // If we can't extract the string value (dynamic expression), skip it
290
- // Dynamic expressions should be caught earlier and require manual review
291
- // This code path shouldn't be reached, but we skip to be safe
292
- }
293
- // For SimpleTag/RemovableTag migrations, skip appearance prop (delete it)
256
+ // Delete appearance prop not used in new Tag/AvatarTag API
294
257
  return;
295
258
  }
296
259
  if (attrName === 'color') {
@@ -352,15 +315,10 @@ const rule = createLintRule({
352
315
  }
353
316
  });
354
317
 
355
- // Add isRemovable={false} for SimpleTag migrations and Lozenge migrations
356
- if (options.isSimpleTag || options.isLozengeMigration) {
318
+ // Add isRemovable={false} for SimpleTag migrations
319
+ if (options.isSimpleTag) {
357
320
  newAttributes.push('isRemovable={false}');
358
321
  }
359
-
360
- // Add migration_fallback="lozenge" for Lozenge migrations to enable safe staged rollout
361
- if (options.isLozengeMigration) {
362
- newAttributes.push('migration_fallback="lozenge"');
363
- }
364
322
  const attributesText = newAttributes.length > 0 ? ` ${newAttributes.join(' ')}` : '';
365
323
  const children = node.children.length > 0 ? sourceCode.getText().slice(node.openingElement.range[1], node.closingElement ? node.closingElement.range[0] : node.range[1]) : '';
366
324
  const componentName = options.preserveComponentName ? node.openingElement.name.name : options.isAvatarTag ? 'AvatarTag' : 'Tag';
@@ -632,77 +590,22 @@ const rule = createLintRule({
632
590
  }
633
591
  const attributesMap = getAttributesMap(node.openingElement.attributes);
634
592
  const appearanceProp = attributesMap.appearance;
635
- const isBoldProp = attributesMap.isBold;
636
593
 
637
- // Handle appearance prop value migration
594
+ // Handle appearance prop value migration — always update to new semantic values.
595
+ // isBold is intentionally not flagged: users may still need it while the feature flag
596
+ // platform-dst-lozenge-tag-badge-visual-uplifts is OFF (subtle variant still rendered).
638
597
  if (appearanceProp) {
639
- const shouldMigrateToTag = !isBoldProp || isLiteralFalse(isBoldProp.value);
640
- if (!shouldMigrateToTag) {
641
- // Only update appearance values for Lozenge components that stay as Lozenge
642
- const stringValue = extractStringValue(appearanceProp.value);
643
- if (stringValue && typeof stringValue === 'string') {
644
- const mappedValue = mapToNewAppearanceValue(stringValue);
645
- if (mappedValue !== stringValue) {
646
- context.report({
647
- node: appearanceProp,
648
- messageId: 'updateAppearance',
649
- fix: createAppearanceFixer(appearanceProp.value, mappedValue)
650
- });
651
- }
652
- }
653
- }
654
- }
655
-
656
- // Handle isBold prop and Tag migration
657
- if (isBoldProp) {
658
- if (isLiteralFalse(isBoldProp.value)) {
659
- // isBold={false} should migrate to Tag
660
- // Check if appearance is dynamic - if so, require manual review
661
- if (appearanceProp && isDynamicExpression(appearanceProp.value)) {
598
+ const stringValue = extractStringValue(appearanceProp.value);
599
+ if (stringValue && typeof stringValue === 'string') {
600
+ const mappedValue = mapToNewAppearanceValue(stringValue);
601
+ if (mappedValue !== stringValue) {
662
602
  context.report({
663
603
  node: appearanceProp,
664
- messageId: 'dynamicLozengeAppearance'
604
+ messageId: 'updateAppearance',
605
+ fix: createAppearanceFixer(appearanceProp.value, mappedValue)
665
606
  });
666
- return;
667
607
  }
668
- context.report({
669
- node: node,
670
- messageId: 'migrateTag',
671
- fix: fixer => {
672
- const replacement = generateTagReplacement(node, {
673
- isLozengeMigration: true
674
- });
675
- return fixer.replaceText(node, replacement);
676
- }
677
- });
678
- } else if (isDynamicExpression(isBoldProp.value)) {
679
- // Dynamic isBold requires manual review
680
- context.report({
681
- node: isBoldProp,
682
- messageId: 'manualReview'
683
- });
684
- }
685
- // isBold={true} or isBold (implicit true) - no action needed
686
- } else {
687
- // No isBold prop means implicit false, should migrate to Tag
688
- // Check if appearance is dynamic - if so, require manual review
689
- if (appearanceProp && isDynamicExpression(appearanceProp.value)) {
690
- context.report({
691
- node: appearanceProp,
692
- messageId: 'dynamicLozengeAppearance'
693
- });
694
- return;
695
608
  }
696
- context.report({
697
- node: node,
698
- messageId: 'migrateTag',
699
- fix: fixer => {
700
- const replacement = generateTagReplacement(node, {
701
- isLozengeMigration: true
702
- });
703
- return fixer.replaceText(node, replacement);
704
- }
705
- });
706
609
  }
707
610
  }
708
611
  };
@@ -0,0 +1,489 @@
1
+ import tokenDefaultValues from '@atlaskit/tokens/token-default-values';
2
+ import { createLintRule } from '../utils/create-lint-rule';
3
+ const DURATION_TOKEN_NAMES = ['motion.duration.instant', 'motion.duration.xxshort', 'motion.duration.xshort', 'motion.duration.short', 'motion.duration.medium', 'motion.duration.long', 'motion.duration.xlong', 'motion.duration.xxlong'];
4
+ function parseDurationMs(value) {
5
+ const ms = value.match(/^(\d+(?:\.\d+)?)ms$/);
6
+ if (ms) {
7
+ return parseFloat(ms[1]);
8
+ }
9
+ const s = value.match(/^(\d+(?:\.\d+)?)s$/);
10
+ if (s) {
11
+ return parseFloat(s[1]) * 1000;
12
+ }
13
+ return null;
14
+ }
15
+ const DURATION_TOKENS = DURATION_TOKEN_NAMES.map(name => {
16
+ const rawValue = tokenDefaultValues[name];
17
+ const ms = parseDurationMs(rawValue);
18
+ if (ms === null) {
19
+ throw new Error(`use-tokens-motion: could not parse duration for token ${name}: ${rawValue}`);
20
+ }
21
+ return {
22
+ ms,
23
+ token: name
24
+ };
25
+ }).sort((a, b) => a.ms - b.ms);
26
+ const EASING_TOKEN_NAMES = ['motion.easing.in.practical', 'motion.easing.inout.bold', 'motion.easing.out.practical', 'motion.easing.out.bold'];
27
+ function parseCubicBezierParams(value) {
28
+ const match = value.match(/^cubic-bezier\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/);
29
+ if (!match) {
30
+ return null;
31
+ }
32
+ return [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4])];
33
+ }
34
+ const EASING_TOKENS = EASING_TOKEN_NAMES.map(name => {
35
+ const rawValue = tokenDefaultValues[name];
36
+ const params = parseCubicBezierParams(rawValue);
37
+ if (!params) {
38
+ throw new Error(`use-tokens-motion: could not parse cubic-bezier for token ${name}: ${rawValue}`);
39
+ }
40
+ return {
41
+ value: rawValue,
42
+ token: name,
43
+ params
44
+ };
45
+ });
46
+
47
+ // Splits on top-level commas (outside function parens) — preserves cubic-bezier(...) commas.
48
+ function splitOnTopLevelCommas(value) {
49
+ const parts = [];
50
+ let depth = 0;
51
+ let current = '';
52
+ for (const ch of value) {
53
+ if (ch === '(') {
54
+ depth++;
55
+ current += ch;
56
+ } else if (ch === ')') {
57
+ depth--;
58
+ current += ch;
59
+ } else if (ch === ',' && depth === 0) {
60
+ parts.push(current.trim());
61
+ current = '';
62
+ } else {
63
+ current += ch;
64
+ }
65
+ }
66
+ if (current.trim().length > 0) {
67
+ parts.push(current.trim());
68
+ }
69
+ return parts;
70
+ }
71
+ const DURATION_PROPERTIES = new Set(['transitionDuration', 'animationDuration']);
72
+ const EASING_PROPERTIES = new Set(['transitionTimingFunction', 'animationTimingFunction']);
73
+
74
+ // Explicit semantic mappings for CSS keyword easings to motion tokens.
75
+ // Pinned by design intent, confirmed with design system team (Alex + Akshay).
76
+ const CSS_KEYWORD_EASING_TOKEN_MAP = {
77
+ ease: 'motion.easing.out.practical',
78
+ 'ease-out': 'motion.easing.out.practical',
79
+ 'ease-in': 'motion.easing.in.practical',
80
+ 'ease-in-out': 'motion.easing.inout.bold'
81
+ // linear (0,0,1,1) — warn only, no autofix (per Akshay: too generic, no good token match)
82
+ };
83
+
84
+ // Non-curve easing values with no meaningful cubic-bezier representation — skip entirely
85
+ const SKIP_EASING_VALUES = new Set(['step-start', 'step-end', 'inherit', 'initial', 'unset', 'none']);
86
+ function euclideanDistance(a, b) {
87
+ return Math.sqrt(a.reduce((sum, val, i) => sum + Math.pow(val - b[i], 2), 0));
88
+ }
89
+
90
+ // Maximum Euclidean distance for easing autofix — beyond this threshold, we report-only
91
+ const EASING_AUTOFIX_THRESHOLD = 0.5;
92
+ function findClosestEasingToken(params) {
93
+ let minDist = Infinity;
94
+ let closest = EASING_TOKENS[0];
95
+ for (const entry of EASING_TOKENS) {
96
+ const dist = euclideanDistance(params, entry.params);
97
+ if (dist < minDist) {
98
+ minDist = dist;
99
+ closest = entry;
100
+ }
101
+ }
102
+ if (minDist > EASING_AUTOFIX_THRESHOLD) {
103
+ return null;
104
+ }
105
+ return {
106
+ token: closest.token,
107
+ value: closest.value,
108
+ dist: minDist
109
+ };
110
+ }
111
+ function findClosestDurationTokens(ms) {
112
+ const exact = DURATION_TOKENS.find(t => t.ms === ms);
113
+ if (exact) {
114
+ return [exact];
115
+ }
116
+ let minDist = Infinity;
117
+ for (const entry of DURATION_TOKENS) {
118
+ const dist = Math.abs(entry.ms - ms);
119
+ if (dist < minDist) {
120
+ minDist = dist;
121
+ }
122
+ }
123
+ const closest = DURATION_TOKENS.filter(t => Math.abs(t.ms - ms) === minDist);
124
+ return closest;
125
+ }
126
+ const useTokensMotion = createLintRule({
127
+ meta: {
128
+ name: 'use-tokens-motion',
129
+ type: 'suggestion',
130
+ hasSuggestions: true,
131
+ docs: {
132
+ description: 'Enforces usage of motion design tokens rather than hard-coded duration and easing values.',
133
+ recommended: false,
134
+ severity: 'warn'
135
+ },
136
+ messages: {
137
+ useMotionDurationToken: "Use a motion duration token instead of the hard-coded value '{{ value }}'.",
138
+ useMotionDurationTokenSuggest: 'Replace with {{ suggestion }}.',
139
+ useMotionDurationTokenNearest: "No exact token match for '{{ value }}'. Nearest: {{ suggestion1 }} or {{ suggestion2 }}.",
140
+ useMotionDurationTokenSingleNearest: "No exact token match for '{{ value }}'. Nearest: {{ suggestion }}.",
141
+ useMotionEasingToken: "Use a motion easing token instead of the hard-coded value '{{ value }}'.",
142
+ useMotionEasingTokenSuggest: 'Replace with {{ suggestion }}.',
143
+ useMotionEasingTokenUnknown: "Use a motion easing token from @atlaskit/tokens instead of the hard-coded value '{{ value }}'."
144
+ }
145
+ },
146
+ create(context) {
147
+ let tokensImportNode = null;
148
+ let hasTokenSpecifier = false;
149
+ function buildTokenCall(tokenName, fallback) {
150
+ return `token('${tokenName}', '${fallback}')`;
151
+ }
152
+ function getImportFix(fixer) {
153
+ var _context$sourceCode;
154
+ if (hasTokenSpecifier) {
155
+ return [];
156
+ }
157
+ if (tokensImportNode) {
158
+ // @atlaskit/tokens is imported but without `token` — add `token` to existing import
159
+ const lastSpecifier = tokensImportNode.specifiers[tokensImportNode.specifiers.length - 1];
160
+ if (lastSpecifier) {
161
+ return [fixer.insertTextAfter(lastSpecifier, ', token')];
162
+ }
163
+ // Empty import — replace the whole declaration
164
+ return [fixer.replaceText(tokensImportNode, `import { token } from '@atlaskit/tokens';`)];
165
+ }
166
+ const sourceCode = (_context$sourceCode = context.sourceCode) !== null && _context$sourceCode !== void 0 ? _context$sourceCode : context.getSourceCode();
167
+ const programBody = sourceCode.ast.body;
168
+ // Insert after the last existing import, or at top if no imports exist
169
+ const lastImport = [...programBody].reverse().find(n => n.type === 'ImportDeclaration');
170
+ if (lastImport) {
171
+ return [fixer.insertTextAfter(lastImport, `\nimport { token } from '@atlaskit/tokens';`)];
172
+ }
173
+ if (programBody.length > 0) {
174
+ return [fixer.insertTextBefore(programBody[0], `import { token } from '@atlaskit/tokens';\n`)];
175
+ }
176
+ return [];
177
+ }
178
+
179
+ // Returns autofix string for a single duration value, or null if ambiguous (equidistant)
180
+ function resolveDurationToken(value) {
181
+ const ms = parseDurationMs(value);
182
+ if (ms === null) {
183
+ return null;
184
+ }
185
+ const exact = DURATION_TOKENS.find(t => t.ms === ms);
186
+ if (exact) {
187
+ return buildTokenCall(exact.token, value);
188
+ }
189
+ return null;
190
+ }
191
+ function handleDurationProperty(node, rawValue) {
192
+ const segments = splitOnTopLevelCommas(rawValue);
193
+ if (segments.length === 1) {
194
+ const ms = parseDurationMs(rawValue);
195
+ if (ms === null) {
196
+ return;
197
+ }
198
+ const exactMatch = DURATION_TOKENS.find(t => t.ms === ms);
199
+ if (exactMatch) {
200
+ const suggestion = buildTokenCall(exactMatch.token, rawValue);
201
+ context.report({
202
+ node,
203
+ messageId: 'useMotionDurationToken',
204
+ data: {
205
+ value: rawValue
206
+ },
207
+ suggest: [{
208
+ messageId: 'useMotionDurationTokenSuggest',
209
+ data: {
210
+ suggestion
211
+ },
212
+ fix(fixer) {
213
+ return [...getImportFix(fixer), fixer.replaceText(node.value, suggestion)];
214
+ }
215
+ }]
216
+ });
217
+ } else {
218
+ const result = findClosestDurationTokens(ms);
219
+ if (result.length >= 2) {
220
+ const suggestion1 = buildTokenCall(result[0].token, rawValue);
221
+ const suggestion2 = buildTokenCall(result[1].token, rawValue);
222
+ context.report({
223
+ node,
224
+ messageId: 'useMotionDurationTokenNearest',
225
+ data: {
226
+ value: rawValue,
227
+ suggestion1: `${suggestion1} (${result[0].ms}ms)`,
228
+ suggestion2: `${suggestion2} (${result[1].ms}ms)`
229
+ }
230
+ });
231
+ } else {
232
+ const suggestion = buildTokenCall(result[0].token, rawValue);
233
+ context.report({
234
+ node,
235
+ messageId: 'useMotionDurationTokenSingleNearest',
236
+ data: {
237
+ value: rawValue,
238
+ suggestion: `${suggestion} (${result[0].ms}ms)`
239
+ }
240
+ });
241
+ }
242
+ }
243
+ return;
244
+ }
245
+ const resolved = segments.map(resolveDurationToken);
246
+ if (resolved.some(s => s === null)) {
247
+ return;
248
+ }
249
+ const templateLiteral = '`' + resolved.map(s => `\${${s}}`).join(', ') + '`';
250
+ context.report({
251
+ node,
252
+ messageId: 'useMotionDurationToken',
253
+ data: {
254
+ value: rawValue
255
+ },
256
+ suggest: [{
257
+ messageId: 'useMotionDurationTokenSuggest',
258
+ data: {
259
+ suggestion: templateLiteral
260
+ },
261
+ fix(fixer) {
262
+ return [...getImportFix(fixer), fixer.replaceText(node.value, templateLiteral)];
263
+ }
264
+ }]
265
+ });
266
+ }
267
+
268
+ // Returns autofix string for a single easing value, or null if no token suggestion is possible
269
+ function resolveEasingToken(value) {
270
+ const trimmed = value.trim();
271
+ if (SKIP_EASING_VALUES.has(trimmed)) {
272
+ return null;
273
+ }
274
+ if (trimmed in CSS_KEYWORD_EASING_TOKEN_MAP) {
275
+ return buildTokenCall(CSS_KEYWORD_EASING_TOKEN_MAP[trimmed], trimmed);
276
+ }
277
+ // linear has no curve (0,0,1,1) — warn only, no autofix
278
+ if (trimmed === 'linear') {
279
+ return null;
280
+ }
281
+ if (trimmed.startsWith('linear(')) {
282
+ // linear() is used for spring animations — motion.easing.spring is experimental, skip
283
+ return null;
284
+ }
285
+ const params = parseCubicBezierParams(trimmed);
286
+ if (!params) {
287
+ return null;
288
+ }
289
+ const exact = EASING_TOKENS.find(t => t.value === trimmed);
290
+ if (exact) {
291
+ return buildTokenCall(exact.token, trimmed);
292
+ }
293
+ const closest = findClosestEasingToken(params);
294
+ return closest ? buildTokenCall(closest.token, trimmed) : null;
295
+ }
296
+ function handleEasingProperty(node, rawValue) {
297
+ const segments = splitOnTopLevelCommas(rawValue);
298
+
299
+ // Multi-value path: resolve each segment, autofix only if all resolve cleanly
300
+ if (segments.length > 1) {
301
+ const resolved = segments.map(resolveEasingToken);
302
+ if (resolved.some(s => s === null)) {
303
+ return;
304
+ }
305
+ const templateLiteral = '`' + resolved.map(s => `\${${s}}`).join(', ') + '`';
306
+ context.report({
307
+ node,
308
+ messageId: 'useMotionEasingToken',
309
+ data: {
310
+ value: rawValue
311
+ },
312
+ suggest: [{
313
+ messageId: 'useMotionEasingTokenSuggest',
314
+ data: {
315
+ suggestion: templateLiteral
316
+ },
317
+ fix(fixer) {
318
+ return [...getImportFix(fixer), fixer.replaceText(node.value, templateLiteral)];
319
+ }
320
+ }]
321
+ });
322
+ return;
323
+ }
324
+ const trimmed = rawValue.trim();
325
+ if (SKIP_EASING_VALUES.has(trimmed)) {
326
+ return;
327
+ }
328
+
329
+ // CSS keyword easings: convert to cubic-bezier equivalent and find closest token
330
+ if (trimmed in CSS_KEYWORD_EASING_TOKEN_MAP) {
331
+ const suggestion = buildTokenCall(CSS_KEYWORD_EASING_TOKEN_MAP[trimmed], trimmed);
332
+ context.report({
333
+ node,
334
+ messageId: 'useMotionEasingToken',
335
+ data: {
336
+ value: trimmed
337
+ },
338
+ suggest: [{
339
+ messageId: 'useMotionEasingTokenSuggest',
340
+ data: {
341
+ suggestion
342
+ },
343
+ fix(fixer) {
344
+ return [...getImportFix(fixer), fixer.replaceText(node.value, suggestion)];
345
+ }
346
+ }]
347
+ });
348
+ return;
349
+ }
350
+ // linear has no curve (0,0,1,1) — warn only, no autofix
351
+ if (trimmed === 'linear') {
352
+ context.report({
353
+ node,
354
+ messageId: 'useMotionEasingTokenUnknown',
355
+ data: {
356
+ value: trimmed
357
+ }
358
+ });
359
+ return;
360
+ }
361
+ if (trimmed.startsWith('linear(')) {
362
+ // linear() is used for spring animations — motion.easing.spring is experimental, skip
363
+ return;
364
+ }
365
+ const params = parseCubicBezierParams(trimmed);
366
+ if (!params) {
367
+ context.report({
368
+ node,
369
+ messageId: 'useMotionEasingTokenUnknown',
370
+ data: {
371
+ value: rawValue
372
+ }
373
+ });
374
+ return;
375
+ }
376
+ const exact = EASING_TOKENS.find(t => t.value === trimmed);
377
+ if (exact) {
378
+ const suggestion = buildTokenCall(exact.token, rawValue);
379
+ context.report({
380
+ node,
381
+ messageId: 'useMotionEasingToken',
382
+ data: {
383
+ value: rawValue
384
+ },
385
+ suggest: [{
386
+ messageId: 'useMotionEasingTokenSuggest',
387
+ data: {
388
+ suggestion
389
+ },
390
+ fix(fixer) {
391
+ return [...getImportFix(fixer), fixer.replaceText(node.value, suggestion)];
392
+ }
393
+ }]
394
+ });
395
+ return;
396
+ }
397
+ const closest = findClosestEasingToken(params);
398
+ if (closest) {
399
+ const suggestion = buildTokenCall(closest.token, rawValue);
400
+ context.report({
401
+ node,
402
+ messageId: 'useMotionEasingToken',
403
+ data: {
404
+ value: rawValue
405
+ },
406
+ suggest: [{
407
+ messageId: 'useMotionEasingTokenSuggest',
408
+ data: {
409
+ suggestion
410
+ },
411
+ fix(fixer) {
412
+ return [...getImportFix(fixer), fixer.replaceText(node.value, suggestion)];
413
+ }
414
+ }]
415
+ });
416
+ } else {
417
+ context.report({
418
+ node,
419
+ messageId: 'useMotionEasingTokenUnknown',
420
+ data: {
421
+ value: rawValue
422
+ }
423
+ });
424
+ }
425
+ }
426
+ function handleProperty(node) {
427
+ const key = node.key;
428
+ if (key.type !== 'Identifier') {
429
+ return;
430
+ }
431
+ const isDuration = DURATION_PROPERTIES.has(key.name);
432
+ const isEasing = EASING_PROPERTIES.has(key.name);
433
+ if (!isDuration && !isEasing) {
434
+ return;
435
+ }
436
+ const value = node.value;
437
+ if (value.type === 'TemplateLiteral') {
438
+ // Only handle no-interpolation template literals (e.g. `200ms`) — treat as string
439
+ const tl = value;
440
+ if (tl.expressions.length === 0 && tl.quasis.length === 1) {
441
+ var _tl$quasis$0$value$co;
442
+ const rawValue = (_tl$quasis$0$value$co = tl.quasis[0].value.cooked) !== null && _tl$quasis$0$value$co !== void 0 ? _tl$quasis$0$value$co : tl.quasis[0].value.raw;
443
+ if (isDuration) {
444
+ handleDurationProperty(node, rawValue);
445
+ } else {
446
+ handleEasingProperty(node, rawValue);
447
+ }
448
+ }
449
+ return;
450
+ }
451
+ if (value.type === 'CallExpression') {
452
+ const ce = value;
453
+ if (ce.callee.type === 'Identifier' && ce.callee.name === 'token') {
454
+ return;
455
+ }
456
+ return;
457
+ }
458
+ if (value.type === 'Literal') {
459
+ const lit = value;
460
+ let rawValue;
461
+ if (typeof lit.value === 'string') {
462
+ rawValue = lit.value;
463
+ } else if (typeof lit.value === 'number') {
464
+ // Treat bare numbers as ms
465
+ rawValue = `${lit.value}ms`;
466
+ } else {
467
+ return;
468
+ }
469
+ if (isDuration) {
470
+ handleDurationProperty(node, rawValue);
471
+ } else {
472
+ handleEasingProperty(node, rawValue);
473
+ }
474
+ }
475
+ }
476
+ return {
477
+ ImportDeclaration(node) {
478
+ if (node.source.value === '@atlaskit/tokens') {
479
+ tokensImportNode = node;
480
+ hasTokenSpecifier = node.specifiers.some(s => s.type === 'ImportSpecifier' && s.local.name === 'token');
481
+ }
482
+ },
483
+ Property(node) {
484
+ handleProperty(node);
485
+ }
486
+ };
487
+ }
488
+ });
489
+ export default useTokensMotion;