@servicetitan/hammer-token 2.5.1 → 3.0.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.
Files changed (147) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +332 -0
  3. package/build/web/core/component-variables.scss +1088 -131
  4. package/build/web/core/component.d.ts +558 -0
  5. package/build/web/core/component.js +6685 -249
  6. package/build/web/core/component.scss +557 -69
  7. package/build/web/core/css-utils/a2-border.css +47 -45
  8. package/build/web/core/css-utils/a2-color.css +443 -227
  9. package/build/web/core/css-utils/a2-font.css +0 -2
  10. package/build/web/core/css-utils/a2-spacing.css +476 -478
  11. package/build/web/core/css-utils/a2-utils.css +992 -772
  12. package/build/web/core/css-utils/border.css +47 -45
  13. package/build/web/core/css-utils/color.css +443 -227
  14. package/build/web/core/css-utils/font.css +0 -2
  15. package/build/web/core/css-utils/spacing.css +476 -478
  16. package/build/web/core/css-utils/utils.css +992 -772
  17. package/build/web/core/index.d.ts +6 -0
  18. package/build/web/core/index.js +1 -1
  19. package/build/web/core/primitive-variables.scss +148 -65
  20. package/build/web/core/primitive.d.ts +209 -0
  21. package/build/web/core/primitive.js +779 -61
  22. package/build/web/core/primitive.scss +207 -124
  23. package/build/web/core/semantic-variables.scss +363 -245
  24. package/build/web/core/semantic.d.ts +221 -0
  25. package/build/web/core/semantic.js +1592 -347
  26. package/build/web/core/semantic.scss +219 -140
  27. package/build/web/index.d.ts +3 -4
  28. package/build/web/index.js +0 -1
  29. package/build/web/types.d.ts +17 -0
  30. package/config.js +121 -497
  31. package/eslint.config.mjs +11 -1
  32. package/package.json +15 -5
  33. package/src/global/primitive/breakpoint.tokens.json +54 -0
  34. package/src/global/primitive/color.tokens.json +1092 -0
  35. package/src/global/primitive/duration.tokens.json +44 -0
  36. package/src/global/primitive/font.tokens.json +151 -0
  37. package/src/global/primitive/radius.tokens.json +94 -0
  38. package/src/global/primitive/size.tokens.json +174 -0
  39. package/src/global/primitive/transition.tokens.json +32 -0
  40. package/src/theme/core/background.tokens.json +1312 -0
  41. package/src/theme/core/border.tokens.json +192 -0
  42. package/src/theme/core/chart.tokens.json +982 -0
  43. package/src/theme/core/component/ai-mark.tokens.json +20 -0
  44. package/src/theme/core/component/alert.tokens.json +261 -0
  45. package/src/theme/core/component/announcement.tokens.json +460 -0
  46. package/src/theme/core/component/avatar.tokens.json +137 -0
  47. package/src/theme/core/component/badge.tokens.json +42 -0
  48. package/src/theme/core/component/breadcrumb.tokens.json +42 -0
  49. package/src/theme/core/component/button-toggle.tokens.json +428 -0
  50. package/src/theme/core/component/button.tokens.json +941 -0
  51. package/src/theme/core/component/calendar.tokens.json +391 -0
  52. package/src/theme/core/component/card.tokens.json +107 -0
  53. package/src/theme/core/component/checkbox.tokens.json +631 -0
  54. package/src/theme/core/component/chip.tokens.json +169 -0
  55. package/src/theme/core/component/combobox.tokens.json +269 -0
  56. package/src/theme/core/component/details.tokens.json +152 -0
  57. package/src/theme/core/component/dialog.tokens.json +87 -0
  58. package/src/theme/core/component/divider.tokens.json +23 -0
  59. package/src/theme/core/component/dnd.tokens.json +208 -0
  60. package/src/theme/core/component/drawer.tokens.json +61 -0
  61. package/src/theme/core/component/drilldown.tokens.json +61 -0
  62. package/src/theme/core/component/edit-card.tokens.json +381 -0
  63. package/src/theme/core/component/field-label.tokens.json +42 -0
  64. package/src/theme/core/component/field-message.tokens.json +74 -0
  65. package/src/theme/core/component/icon.tokens.json +42 -0
  66. package/src/theme/core/component/link.tokens.json +108 -0
  67. package/src/theme/core/component/list-view.tokens.json +82 -0
  68. package/src/theme/core/component/listbox.tokens.json +283 -0
  69. package/src/theme/core/component/menu.tokens.json +230 -0
  70. package/src/theme/core/component/overflow.tokens.json +84 -0
  71. package/src/theme/core/component/page.tokens.json +377 -0
  72. package/src/theme/core/component/pagination.tokens.json +63 -0
  73. package/src/theme/core/component/popover.tokens.json +122 -0
  74. package/src/theme/core/component/progress-bar.tokens.json +133 -0
  75. package/src/theme/core/component/radio.tokens.json +631 -0
  76. package/src/theme/core/component/segmented-control.tokens.json +175 -0
  77. package/src/theme/core/component/select-card.tokens.json +943 -0
  78. package/src/theme/core/component/side-nav.tokens.json +349 -0
  79. package/src/theme/core/component/skeleton.tokens.json +42 -0
  80. package/src/theme/core/component/spinner.tokens.json +96 -0
  81. package/src/theme/core/component/status-icon.tokens.json +164 -0
  82. package/src/theme/core/component/stepper.tokens.json +484 -0
  83. package/src/theme/core/component/switch.tokens.json +285 -0
  84. package/src/theme/core/component/tab.tokens.json +192 -0
  85. package/src/theme/core/component/text-field.tokens.json +160 -0
  86. package/src/theme/core/component/text.tokens.json +59 -0
  87. package/src/theme/core/component/toast.tokens.json +343 -0
  88. package/src/theme/core/component/toolbar.tokens.json +114 -0
  89. package/src/theme/core/component/tooltip.tokens.json +61 -0
  90. package/src/theme/core/focus.tokens.json +56 -0
  91. package/src/theme/core/foreground.tokens.json +416 -0
  92. package/src/theme/core/gradient.tokens.json +41 -0
  93. package/src/theme/core/opacity.tokens.json +25 -0
  94. package/src/theme/core/shadow.tokens.json +81 -0
  95. package/src/theme/core/status.tokens.json +74 -0
  96. package/src/theme/core/typography.tokens.json +163 -0
  97. package/src/utils/__tests__/css-utils-format-utils.test.js +312 -0
  98. package/src/utils/__tests__/sd-build-configs.test.js +306 -0
  99. package/src/utils/__tests__/sd-formats.test.js +950 -0
  100. package/src/utils/__tests__/sd-transforms.test.js +336 -0
  101. package/src/utils/__tests__/token-helpers.test.js +1160 -0
  102. package/src/utils/copy-css-utils-cli.js +13 -1
  103. package/src/utils/css-utils-format-utils.js +105 -176
  104. package/src/utils/figma/__tests__/sync-gradient.test.js +561 -0
  105. package/src/utils/figma/__tests__/token-conversion.test.js +117 -0
  106. package/src/utils/figma/__tests__/token-resolution.test.js +231 -0
  107. package/src/utils/figma/auth.js +355 -0
  108. package/src/utils/figma/constants.js +22 -0
  109. package/src/utils/figma/errors.js +80 -0
  110. package/src/utils/figma/figma-api.js +1069 -0
  111. package/src/utils/figma/get-token.js +348 -0
  112. package/src/utils/figma/sync-components.js +909 -0
  113. package/src/utils/figma/sync-main.js +692 -0
  114. package/src/utils/figma/sync-orchestration.js +683 -0
  115. package/src/utils/figma/sync-primitives.js +230 -0
  116. package/src/utils/figma/sync-semantic.js +1056 -0
  117. package/src/utils/figma/token-conversion.js +340 -0
  118. package/src/utils/figma/token-parsing.js +186 -0
  119. package/src/utils/figma/token-resolution.js +569 -0
  120. package/src/utils/figma/utils.js +199 -0
  121. package/src/utils/sd-build-configs.js +305 -0
  122. package/src/utils/sd-formats.js +965 -0
  123. package/src/utils/sd-transforms.js +165 -0
  124. package/src/utils/token-helpers.js +848 -0
  125. package/tsconfig.json +18 -0
  126. package/vitest.config.js +17 -0
  127. package/.turbo/turbo-build.log +0 -37
  128. package/build/web/core/raw.js +0 -234
  129. package/src/global/primitive/breakpoint.js +0 -19
  130. package/src/global/primitive/color.js +0 -231
  131. package/src/global/primitive/duration.js +0 -16
  132. package/src/global/primitive/font.js +0 -60
  133. package/src/global/primitive/radius.js +0 -31
  134. package/src/global/primitive/size.js +0 -55
  135. package/src/global/primitive/transition.js +0 -16
  136. package/src/theme/core/background.js +0 -170
  137. package/src/theme/core/border.js +0 -103
  138. package/src/theme/core/charts.js +0 -464
  139. package/src/theme/core/component/button.js +0 -708
  140. package/src/theme/core/component/checkbox.js +0 -405
  141. package/src/theme/core/focus.js +0 -35
  142. package/src/theme/core/foreground.js +0 -148
  143. package/src/theme/core/overlay.js +0 -137
  144. package/src/theme/core/shadow.js +0 -29
  145. package/src/theme/core/status.js +0 -49
  146. package/src/theme/core/typography.js +0 -82
  147. package/type/types.ts +0 -344
@@ -0,0 +1,909 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Component Token Sync
5
+ *
6
+ * Functions for building variable requests for component tokens.
7
+ */
8
+
9
+ const { isComponentToken } = require("./token-parsing");
10
+ const {
11
+ isReference,
12
+ isCompositeColorWithReference,
13
+ extractReferencePath,
14
+ resolveMultipleReferences,
15
+ resolveReferencePath,
16
+ resolveValue,
17
+ topologicalSortSemanticTokens,
18
+ getTokenDescription,
19
+ buildReferenceChain,
20
+ } = require("./token-resolution");
21
+ const {
22
+ convertColorValue,
23
+ convertTokenValueToFigma,
24
+ getFigmaVariableType,
25
+ isGradientToken,
26
+ parseGradientStops,
27
+ } = require("./token-conversion");
28
+ const {
29
+ valuesEqual,
30
+ getVariableIdByName,
31
+ determineVariableName: determineVariableNameHelper,
32
+ } = require("./figma-api");
33
+ /**
34
+ * Get Figma variable scopes from token $extensions["com.figma.scopes"]. Missing or empty = ["ALL_SCOPES"].
35
+ * @param {Object} [token] - Leaf token object
36
+ * @returns {string[]} Scopes array
37
+ */
38
+ function getScopesForVariable(token) {
39
+ const fromToken = token?.$extensions?.["com.figma.scopes"];
40
+ return Array.isArray(fromToken) && fromToken.length > 0
41
+ ? fromToken
42
+ : ["ALL_SCOPES"];
43
+ }
44
+
45
+ /**
46
+ * Determine the variable name for a token path (wrapper that includes isComponentToken)
47
+ * @param {string} targetPath - Token path (e.g., "color/neutral/0")
48
+ * @param {Map} resolvedTokensForRefs - Map of resolved tokens for reference resolution
49
+ * @param {Array} existingVariables - Array of existing Figma variables
50
+ * @param {Map} tempIdMap - Temporary ID map for tracking created variables
51
+ * @returns {string} Variable name with correct prefix (primitive/, semantic/, or component/)
52
+ */
53
+ function determineVariableName(
54
+ targetPath,
55
+ resolvedTokensForRefs,
56
+ existingVariables,
57
+ tempIdMap,
58
+ ) {
59
+ return determineVariableNameHelper(
60
+ targetPath,
61
+ resolvedTokensForRefs,
62
+ existingVariables,
63
+ tempIdMap,
64
+ isComponentToken,
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Build bulk request for component variable changes only
70
+ * Similar to buildSemanticVariableRequest but for component tokens
71
+ * Returns an object with variables array and variableModeValues array
72
+ * @param {Map} tokensToSync - Map of tokens to sync (may be filtered for extended themes)
73
+ * @param {Array} existingVariables - Array of existing variables
74
+ * @param {string} collectionId - Collection ID
75
+ * @param {Object} modes - Modes object with light and dark
76
+ * @param {Map} tempIdMap - Temporary ID map for tracking created variables
77
+ * @param {Map} [allResolvedTokens] - Optional full map of all resolved tokens for reference resolution
78
+ * @returns {Object} Object with variableChanges, variableModeValues, stats, errors, and tempIdMap
79
+ */
80
+ function buildComponentVariableRequest(
81
+ tokensToSync,
82
+ existingVariables,
83
+ collectionId,
84
+ modes,
85
+ tempIdMap = new Map(),
86
+ allResolvedTokens = null,
87
+ ) {
88
+ const variableChanges = [];
89
+ const variableModeValues = [];
90
+ const stats = { created: 0, updated: 0, skipped: 0, deleted: 0, errors: 0 };
91
+ const errors = [];
92
+
93
+ // Use allResolvedTokens for reference resolution if provided, otherwise use tokensToSync
94
+ const resolvedTokensForRefs = allResolvedTokens || tokensToSync;
95
+
96
+ // Filter to only component tokens (exclude primitives and semantic tokens)
97
+ const componentTokens = [];
98
+ for (const [tokenPath, tokenData] of tokensToSync.entries()) {
99
+ if (!tokenData.isPrimitive && isComponentToken(tokenData.sourceFilePath)) {
100
+ componentTokens.push([tokenPath, tokenData]);
101
+ }
102
+ }
103
+
104
+ const keptComponentNames = new Set();
105
+ for (const [path, tokenData] of componentTokens) {
106
+ const tokenType = tokenData.token.$type || "string";
107
+ if (isGradientToken(tokenType)) {
108
+ // Gradient tokens expand to per-stop variables; don't keep the composite name
109
+ const gradRefValue = tokenData.token.$value;
110
+ const refPath =
111
+ typeof gradRefValue === "string" && gradRefValue.includes("{")
112
+ ? gradRefValue.match(/\{([^{}]+)\}/)?.[1]?.replace(/\./g, "/")
113
+ : null;
114
+ const semToken = refPath ? resolvedTokensForRefs.get(refPath) : null;
115
+ const gradientValue =
116
+ semToken && typeof semToken.token.$value === "object"
117
+ ? semToken.token.$value
118
+ : typeof gradRefValue === "object"
119
+ ? gradRefValue
120
+ : null;
121
+ const stops = parseGradientStops(gradientValue);
122
+ if (stops) {
123
+ for (let i = 0; i < stops.length; i++) {
124
+ keptComponentNames.add(`component/${path}/stop-${i}`);
125
+ }
126
+ } else {
127
+ keptComponentNames.add(`component/${path}`);
128
+ }
129
+ } else {
130
+ keptComponentNames.add(`component/${path}`);
131
+ }
132
+ }
133
+
134
+ // Topologically sort component tokens to process dependencies first
135
+ const sortedComponentTokens = topologicalSortSemanticTokens(
136
+ componentTokens,
137
+ resolvedTokensForRefs,
138
+ );
139
+
140
+ // Validate modes before processing
141
+ if (!modes?.light || !modes?.dark) {
142
+ const errorMsg = `Modes are missing: light=${!!modes?.light}, dark=${!!modes?.dark}`;
143
+ errors.push(errorMsg);
144
+ stats.errors++;
145
+ }
146
+ if (!modes?.light?.id || !modes?.dark?.id) {
147
+ const errorMsg = `Mode IDs are missing: light.id=${modes?.light?.id}, dark.id=${modes?.dark?.id}`;
148
+ errors.push(errorMsg);
149
+ stats.errors++;
150
+ }
151
+
152
+ // Process component tokens in dependency order
153
+ for (const [tokenPath, tokenData] of sortedComponentTokens) {
154
+ try {
155
+ const { token, resolvedLight, resolvedDark } = tokenData;
156
+ const tokenType = token.$type || "string";
157
+
158
+ // Add "component/" prefix to variable name for organization
159
+ const variableName = `component/${tokenPath}`;
160
+
161
+ // Special handling for gradient tokens: unpack each stop into a COLOR variable
162
+ // that aliases the corresponding semantic stop variable.
163
+ if (isGradientToken(tokenType)) {
164
+ const gradRefValue = token.$value;
165
+ const refPath =
166
+ typeof gradRefValue === "string" && gradRefValue.includes("{")
167
+ ? gradRefValue.match(/\{([^{}]+)\}/)?.[1]?.replace(/\./g, "/")
168
+ : null;
169
+
170
+ // Case 1: reference to semantic gradient token (e.g., "{gradient.primary}")
171
+ // Case 2: inline gradient object (e.g., toast-specific gradients)
172
+ const isInlineGradient = !refPath && typeof gradRefValue === "object";
173
+
174
+ if (!refPath && !isInlineGradient) {
175
+ errors.push(`Could not extract gradient reference from ${tokenPath}`);
176
+ stats.errors++;
177
+ continue;
178
+ }
179
+
180
+ let gradientValue = null;
181
+
182
+ if (refPath) {
183
+ const semTokenData = resolvedTokensForRefs.get(refPath);
184
+ if (!semTokenData) {
185
+ errors.push(
186
+ `Could not find semantic gradient token ${refPath} for ${tokenPath}`,
187
+ );
188
+ stats.errors++;
189
+ continue;
190
+ }
191
+ gradientValue =
192
+ typeof semTokenData.token.$value === "object"
193
+ ? semTokenData.token.$value
194
+ : null;
195
+ } else {
196
+ gradientValue = gradRefValue;
197
+ }
198
+
199
+ const stops = parseGradientStops(gradientValue);
200
+
201
+ if (!stops) {
202
+ errors.push(`Could not parse gradient stops for ${tokenPath}`);
203
+ stats.errors++;
204
+ continue;
205
+ }
206
+
207
+ const gradientDescription = getTokenDescription(
208
+ token,
209
+ resolvedTokensForRefs,
210
+ );
211
+
212
+ for (let i = 0; i < stops.length; i++) {
213
+ const stopVariableName = `${variableName}/stop-${i}`;
214
+ const stopTokenPath = `${tokenPath}/stop-${i}`;
215
+ const { lightRef: stopLightRef, darkRef: stopDarkRef } = stops[i];
216
+ const lightChain =
217
+ buildReferenceChain(stopLightRef, resolvedTokensForRefs) ||
218
+ stopLightRef;
219
+ const darkChain =
220
+ buildReferenceChain(stopDarkRef, resolvedTokensForRefs) ||
221
+ stopDarkRef;
222
+ const stopColorDetail =
223
+ stopLightRef === stopDarkRef
224
+ ? lightChain
225
+ : `light: ${lightChain}, dark: ${darkChain}`;
226
+ const rawPosition = gradientValue.stops[i]?.position;
227
+ const stopPosition =
228
+ typeof rawPosition === "number"
229
+ ? `${rawPosition * 100}%`
230
+ : String(rawPosition ?? i);
231
+ const stopDescription = gradientDescription
232
+ ? `${gradientDescription}\nstop ${i} at ${stopPosition}: ${stopColorDetail}`
233
+ : `stop ${i} at ${stopPosition}: ${stopColorDetail}`;
234
+
235
+ let lightAliasId, darkAliasId;
236
+
237
+ if (refPath) {
238
+ // Reference case: alias both modes to the same semantic stop variable
239
+ // (the semantic stop variable already encodes light/dark per-mode)
240
+ const semanticStopVariableName = `semantic/${refPath}/stop-${i}`;
241
+ const semanticStopTokenPath = `${refPath}/stop-${i}`;
242
+ const aliasId =
243
+ tempIdMap.get(semanticStopTokenPath) ||
244
+ tempIdMap.get(semanticStopVariableName) ||
245
+ getVariableIdByName(
246
+ semanticStopVariableName,
247
+ existingVariables,
248
+ ) ||
249
+ getVariableIdByName(semanticStopTokenPath, existingVariables);
250
+
251
+ if (!aliasId) {
252
+ errors.push(
253
+ `Could not resolve alias for ${stopTokenPath} -> ${semanticStopVariableName}`,
254
+ );
255
+ stats.errors++;
256
+ continue;
257
+ }
258
+ lightAliasId = aliasId;
259
+ darkAliasId = aliasId;
260
+ } else {
261
+ // Inline case: resolve each stop's color reference to a semantic variable
262
+ const { lightRef, darkRef } = stops[i];
263
+ const lightRefPath = extractReferencePath(lightRef) ?? lightRef;
264
+ const lightTargetPath = resolveReferencePath(
265
+ lightRefPath,
266
+ resolvedTokensForRefs,
267
+ );
268
+ const lightTargetVariableName = determineVariableName(
269
+ lightTargetPath,
270
+ resolvedTokensForRefs,
271
+ existingVariables,
272
+ tempIdMap,
273
+ );
274
+ lightAliasId =
275
+ tempIdMap.get(lightTargetPath) ||
276
+ tempIdMap.get(lightTargetVariableName) ||
277
+ getVariableIdByName(lightTargetVariableName, existingVariables) ||
278
+ getVariableIdByName(lightTargetPath, existingVariables);
279
+
280
+ if (!lightAliasId) {
281
+ errors.push(
282
+ `Could not resolve light alias for ${stopTokenPath} -> ${lightTargetPath}`,
283
+ );
284
+ stats.errors++;
285
+ continue;
286
+ }
287
+
288
+ const darkRefPath = extractReferencePath(darkRef) ?? darkRef;
289
+ const darkTargetPath = resolveReferencePath(
290
+ darkRefPath,
291
+ resolvedTokensForRefs,
292
+ );
293
+ const darkTargetVariableName = determineVariableName(
294
+ darkTargetPath,
295
+ resolvedTokensForRefs,
296
+ existingVariables,
297
+ tempIdMap,
298
+ );
299
+ darkAliasId =
300
+ tempIdMap.get(darkTargetPath) ||
301
+ tempIdMap.get(darkTargetVariableName) ||
302
+ getVariableIdByName(darkTargetVariableName, existingVariables) ||
303
+ getVariableIdByName(darkTargetPath, existingVariables);
304
+
305
+ if (!darkAliasId) {
306
+ errors.push(
307
+ `Could not resolve dark alias for ${stopTokenPath} -> ${darkTargetPath}`,
308
+ );
309
+ stats.errors++;
310
+ continue;
311
+ }
312
+ }
313
+
314
+ const lightFigmaValue = { type: "VARIABLE_ALIAS", id: lightAliasId };
315
+ const darkFigmaValue = { type: "VARIABLE_ALIAS", id: darkAliasId };
316
+
317
+ // Get scopes from the stop color's extensions on the semantic token
318
+ const stopColor = gradientValue.stops[i]?.color;
319
+ const stopScopes =
320
+ Array.isArray(stopColor?.$extensions?.["com.figma.scopes"]) &&
321
+ stopColor.$extensions["com.figma.scopes"].length > 0
322
+ ? stopColor.$extensions["com.figma.scopes"]
323
+ : ["ALL_SCOPES"];
324
+
325
+ const existingId = getVariableIdByName(
326
+ stopVariableName,
327
+ existingVariables,
328
+ );
329
+ let existing = null;
330
+ if (existingId) {
331
+ existing =
332
+ existingVariables.length > 100
333
+ ? (() => {
334
+ const m = new Map(
335
+ existingVariables
336
+ .filter((v) => v.id)
337
+ .map((v) => [v.id, v]),
338
+ );
339
+ return m.get(existingId) || null;
340
+ })()
341
+ : existingVariables.find((v) => v.id === existingId) || null;
342
+ }
343
+
344
+ if (existing) {
345
+ const existingValues = existing.valuesByMode || {};
346
+ const lightSame = valuesEqual(
347
+ existingValues[modes.light.id],
348
+ lightFigmaValue,
349
+ );
350
+ const darkSame = valuesEqual(
351
+ existingValues[modes.dark.id],
352
+ darkFigmaValue,
353
+ );
354
+ const sortedUpdate = [...stopScopes].sort();
355
+ const sortedExisting = [...(existing.scopes ?? [])].sort();
356
+ const scopesEqual =
357
+ sortedUpdate.length === sortedExisting.length &&
358
+ sortedUpdate.every((s, idx) => s === sortedExisting[idx]);
359
+
360
+ const stopDescriptionSame =
361
+ (existing.description || "") === stopDescription;
362
+ if (lightSame && darkSame && scopesEqual && stopDescriptionSame) {
363
+ tempIdMap.set(stopTokenPath, existing.id);
364
+ tempIdMap.set(stopVariableName, existing.id);
365
+ stats.skipped++;
366
+ continue;
367
+ }
368
+
369
+ variableChanges.push({
370
+ action: "UPDATE",
371
+ id: existing.id,
372
+ name: stopVariableName,
373
+ scopes: stopScopes,
374
+ description: stopDescription,
375
+ });
376
+ variableModeValues.push(
377
+ {
378
+ variableId: existing.id,
379
+ modeId: modes.light.id,
380
+ value: lightFigmaValue,
381
+ },
382
+ {
383
+ variableId: existing.id,
384
+ modeId: modes.dark.id,
385
+ value: darkFigmaValue,
386
+ },
387
+ );
388
+ tempIdMap.set(stopTokenPath, existing.id);
389
+ tempIdMap.set(stopVariableName, existing.id);
390
+ stats.updated++;
391
+ } else {
392
+ const tempIdCounter = tempIdMap.size + variableChanges.length + 1;
393
+ const tempId = `temp_var_${tempIdCounter}_${stopVariableName.replace(/\//g, "_").replace(/[^a-zA-Z0-9_]/g, "_")}`;
394
+ tempIdMap.set(stopTokenPath, tempId);
395
+ tempIdMap.set(stopVariableName, tempId);
396
+
397
+ variableChanges.push({
398
+ action: "CREATE",
399
+ id: tempId,
400
+ name: stopVariableName,
401
+ variableCollectionId: collectionId,
402
+ resolvedType: "COLOR",
403
+ scopes: stopScopes,
404
+ description: stopDescription,
405
+ });
406
+ variableModeValues.push(
407
+ {
408
+ variableId: tempId,
409
+ modeId: modes.light.id,
410
+ value: lightFigmaValue,
411
+ },
412
+ {
413
+ variableId: tempId,
414
+ modeId: modes.dark.id,
415
+ value: darkFigmaValue,
416
+ },
417
+ );
418
+ stats.created++;
419
+ }
420
+ }
421
+
422
+ // Skip normal processing — the gradient composite token has no Figma variable
423
+ continue;
424
+ }
425
+
426
+ // Check if this should be an alias (only for direct string references, not composite colors)
427
+ const isLightReference = isReference(resolvedLight);
428
+ const isDarkReference = isReference(resolvedDark);
429
+ const isLightCompositeWithRef =
430
+ isCompositeColorWithReference(resolvedLight);
431
+ const isDarkCompositeWithRef =
432
+ isCompositeColorWithReference(resolvedDark);
433
+
434
+ let lightFigmaValue;
435
+ let darkFigmaValue;
436
+ let lightAliasId = null;
437
+ let darkAliasId = null;
438
+
439
+ // Handle composite colors with references - resolve the color and apply alpha
440
+ if (isLightCompositeWithRef) {
441
+ const refString = resolvedLight.color;
442
+ const refPath = extractReferencePath(refString);
443
+ if (!refPath) {
444
+ errors.push(
445
+ `Could not extract reference path from ${refString} for ${tokenPath}`,
446
+ );
447
+ stats.errors++;
448
+ continue;
449
+ }
450
+
451
+ const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
452
+ const targetTokenData = resolvedTokensForRefs.get(targetPath);
453
+ if (!targetTokenData) {
454
+ errors.push(
455
+ `Could not find target token ${targetPath} for ${tokenPath}`,
456
+ );
457
+ stats.errors++;
458
+ continue;
459
+ }
460
+
461
+ let resolvedColorValue = targetTokenData.resolvedLight;
462
+ if (!resolvedColorValue) {
463
+ errors.push(
464
+ `Target token ${targetPath} has no resolved light value for ${tokenPath}`,
465
+ );
466
+ stats.errors++;
467
+ continue;
468
+ }
469
+
470
+ // Semantic tokens may have resolvedLight still as a reference (e.g. "{color.neutral.950}"); resolve to literal
471
+ if (isReference(resolvedColorValue)) {
472
+ resolvedColorValue = resolveValue(
473
+ resolvedColorValue,
474
+ resolvedTokensForRefs,
475
+ false,
476
+ );
477
+ }
478
+
479
+ const baseColor = convertColorValue(resolvedColorValue);
480
+ if (baseColor === null) {
481
+ errors.push(
482
+ `Could not convert resolved color value for ${tokenPath}: ${JSON.stringify(resolvedColorValue)}`,
483
+ );
484
+ stats.errors++;
485
+ continue;
486
+ }
487
+
488
+ lightFigmaValue = {
489
+ ...baseColor,
490
+ a: resolvedLight.alpha ?? resolvedLight.a ?? baseColor.a,
491
+ };
492
+ } else if (isLightReference) {
493
+ // Direct string reference - create alias
494
+ const refPath = extractReferencePath(resolvedLight);
495
+ if (!refPath) {
496
+ errors.push(
497
+ `Could not extract reference path from ${resolvedLight} for ${tokenPath}`,
498
+ );
499
+ stats.errors++;
500
+ continue;
501
+ }
502
+
503
+ const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
504
+ // Determine the correct variable name based on token type
505
+ const targetVariableName = determineVariableName(
506
+ targetPath,
507
+ resolvedTokensForRefs,
508
+ existingVariables,
509
+ tempIdMap,
510
+ );
511
+ lightAliasId =
512
+ tempIdMap.get(targetPath) ||
513
+ tempIdMap.get(targetVariableName) ||
514
+ getVariableIdByName(targetVariableName, existingVariables) ||
515
+ getVariableIdByName(targetPath, existingVariables);
516
+
517
+ if (!lightAliasId) {
518
+ const errorMsg = `Could not resolve light alias for ${tokenPath} -> ${targetPath}`;
519
+ errors.push(errorMsg);
520
+ stats.errors++;
521
+ continue;
522
+ }
523
+
524
+ lightFigmaValue = { type: "VARIABLE_ALIAS", id: lightAliasId };
525
+ } else {
526
+ // Check if the value is a string with multiple references
527
+ let valueToConvert = resolvedLight;
528
+ if (
529
+ typeof resolvedLight === "string" &&
530
+ resolvedLight.includes("{") &&
531
+ resolvedLight.includes("}") &&
532
+ !isReference(resolvedLight)
533
+ ) {
534
+ valueToConvert = resolveMultipleReferences(
535
+ resolvedLight,
536
+ resolvedTokensForRefs,
537
+ false,
538
+ );
539
+ }
540
+
541
+ // When value is currentColor, use com.figma.alternateValue.mode if present
542
+ if (valueToConvert === "currentColor") {
543
+ const altMode = token.$extensions?.["com.figma.alternateValue"]?.mode;
544
+ if (altMode?.light != null) {
545
+ let altLight = altMode.light;
546
+ if (isReference(altLight)) {
547
+ altLight = resolveValue(altLight, resolvedTokensForRefs, false);
548
+ }
549
+ const converted = convertColorValue(altLight);
550
+ if (converted !== null) {
551
+ lightFigmaValue = converted;
552
+ } else {
553
+ errors.push(
554
+ `Could not convert com.figma.alternateValue.mode.light for ${tokenPath}: ${JSON.stringify(altMode.light)}`,
555
+ );
556
+ stats.errors++;
557
+ continue;
558
+ }
559
+ } else {
560
+ errors.push(
561
+ `Token ${tokenPath} uses currentColor but has no com.figma.alternateValue.mode (required for Figma sync)`,
562
+ );
563
+ stats.errors++;
564
+ continue;
565
+ }
566
+ } else {
567
+ const converted = convertTokenValueToFigma(
568
+ tokenType,
569
+ valueToConvert,
570
+ {
571
+ tokenPath,
572
+ scopes: getScopesForVariable(token),
573
+ },
574
+ );
575
+ if (converted === null) {
576
+ errors.push(`Could not convert light value for ${tokenPath}`);
577
+ stats.errors++;
578
+ continue;
579
+ }
580
+ lightFigmaValue = converted;
581
+ }
582
+ }
583
+
584
+ // Handle dark mode values (same logic as light)
585
+ if (isDarkCompositeWithRef) {
586
+ const refString = resolvedDark.color;
587
+ const refPath = extractReferencePath(refString);
588
+ if (!refPath) {
589
+ errors.push(
590
+ `Could not extract reference path from ${refString} for ${tokenPath}`,
591
+ );
592
+ stats.errors++;
593
+ continue;
594
+ }
595
+
596
+ const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
597
+ const targetTokenData = resolvedTokensForRefs.get(targetPath);
598
+ if (!targetTokenData) {
599
+ errors.push(
600
+ `Could not find target token ${targetPath} for ${tokenPath}`,
601
+ );
602
+ stats.errors++;
603
+ continue;
604
+ }
605
+
606
+ let resolvedColorValueDark = targetTokenData.resolvedDark;
607
+ if (!resolvedColorValueDark) {
608
+ errors.push(
609
+ `Target token ${targetPath} has no resolved dark value for ${tokenPath}`,
610
+ );
611
+ stats.errors++;
612
+ continue;
613
+ }
614
+
615
+ if (isReference(resolvedColorValueDark)) {
616
+ resolvedColorValueDark = resolveValue(
617
+ resolvedColorValueDark,
618
+ resolvedTokensForRefs,
619
+ true,
620
+ );
621
+ }
622
+
623
+ const baseColorDark = convertColorValue(resolvedColorValueDark);
624
+ if (baseColorDark === null) {
625
+ errors.push(
626
+ `Could not convert resolved color value for ${tokenPath}: ${JSON.stringify(resolvedColorValueDark)}`,
627
+ );
628
+ stats.errors++;
629
+ continue;
630
+ }
631
+
632
+ darkFigmaValue = {
633
+ ...baseColorDark,
634
+ a: resolvedDark.alpha ?? resolvedDark.a ?? baseColorDark.a,
635
+ };
636
+ } else if (isDarkReference) {
637
+ const refPath = extractReferencePath(resolvedDark);
638
+ if (!refPath) {
639
+ errors.push(
640
+ `Could not extract reference path from ${resolvedDark} for ${tokenPath}`,
641
+ );
642
+ stats.errors++;
643
+ continue;
644
+ }
645
+
646
+ const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
647
+ // Determine the correct variable name based on token type
648
+ const targetVariableName = determineVariableName(
649
+ targetPath,
650
+ resolvedTokensForRefs,
651
+ existingVariables,
652
+ tempIdMap,
653
+ );
654
+ darkAliasId =
655
+ tempIdMap.get(targetPath) ||
656
+ tempIdMap.get(targetVariableName) ||
657
+ getVariableIdByName(targetVariableName, existingVariables) ||
658
+ getVariableIdByName(targetPath, existingVariables);
659
+
660
+ if (!darkAliasId) {
661
+ const errorMsg = `Could not resolve dark alias for ${tokenPath} -> ${targetPath}`;
662
+ errors.push(errorMsg);
663
+ stats.errors++;
664
+ continue;
665
+ }
666
+
667
+ darkFigmaValue = { type: "VARIABLE_ALIAS", id: darkAliasId };
668
+ } else {
669
+ let valueToConvert = resolvedDark;
670
+ if (
671
+ typeof resolvedDark === "string" &&
672
+ resolvedDark.includes("{") &&
673
+ resolvedDark.includes("}") &&
674
+ !isReference(resolvedDark)
675
+ ) {
676
+ valueToConvert = resolveMultipleReferences(
677
+ resolvedDark,
678
+ resolvedTokensForRefs,
679
+ true,
680
+ );
681
+ }
682
+
683
+ // When value is currentColor, use com.figma.alternateValue.mode if present
684
+ if (valueToConvert === "currentColor") {
685
+ const altMode = token.$extensions?.["com.figma.alternateValue"]?.mode;
686
+ if (altMode?.dark != null) {
687
+ let altDark = altMode.dark;
688
+ if (isReference(altDark)) {
689
+ altDark = resolveValue(altDark, resolvedTokensForRefs, true);
690
+ }
691
+ const converted = convertColorValue(altDark);
692
+ if (converted !== null) {
693
+ darkFigmaValue = converted;
694
+ } else {
695
+ errors.push(
696
+ `Could not convert com.figma.alternateValue.mode.dark for ${tokenPath}: ${JSON.stringify(altMode.dark)}`,
697
+ );
698
+ stats.errors++;
699
+ continue;
700
+ }
701
+ } else if (lightFigmaValue != null) {
702
+ // No dark alternate; reuse light value so the variable has both modes
703
+ darkFigmaValue = lightFigmaValue;
704
+ } else {
705
+ errors.push(
706
+ `Token ${tokenPath} uses currentColor but has no com.figma.alternateValue.mode (required for Figma sync)`,
707
+ );
708
+ stats.errors++;
709
+ continue;
710
+ }
711
+ } else {
712
+ const converted = convertTokenValueToFigma(
713
+ tokenType,
714
+ valueToConvert,
715
+ {
716
+ tokenPath,
717
+ scopes: getScopesForVariable(token),
718
+ },
719
+ );
720
+ if (converted === null) {
721
+ errors.push(`Could not convert dark value for ${tokenPath}`);
722
+ stats.errors++;
723
+ continue;
724
+ }
725
+ darkFigmaValue = converted;
726
+ }
727
+ }
728
+
729
+ // Get existing variable - optimize with getVariableIdByName
730
+ const existingId =
731
+ getVariableIdByName(variableName, existingVariables) ||
732
+ getVariableIdByName(tokenPath, existingVariables);
733
+ // Optimize: Use Map for O(1) lookup if we have many variables
734
+ let existing = null;
735
+ if (existingId) {
736
+ if (existingVariables.length > 100) {
737
+ const variableIdMap = new Map();
738
+ for (const v of existingVariables) {
739
+ if (v.id) variableIdMap.set(v.id, v);
740
+ }
741
+ existing = variableIdMap.get(existingId) || null;
742
+ } else {
743
+ existing = existingVariables.find((v) => v.id === existingId);
744
+ }
745
+ }
746
+
747
+ if (existing) {
748
+ if (!existing.id) {
749
+ const errorMsg = `Existing variable for ${tokenPath} has no ID`;
750
+ errors.push(errorMsg);
751
+ stats.errors++;
752
+ continue;
753
+ }
754
+
755
+ if (!modes.light?.id || !modes.dark?.id) {
756
+ errors.push(
757
+ `Mode IDs are missing: light=${modes.light?.id}, dark=${modes.dark?.id}`,
758
+ );
759
+ stats.errors++;
760
+ continue;
761
+ }
762
+
763
+ // Check if values are the same
764
+ const existingValues = existing.valuesByMode || {};
765
+ const existingLight = existingValues[modes.light.id];
766
+ const existingDark = existingValues[modes.dark.id];
767
+
768
+ const lightSame = valuesEqual(existingLight, lightFigmaValue);
769
+ const darkSame = valuesEqual(existingDark, darkFigmaValue);
770
+
771
+ const updateScopes = getScopesForVariable(tokenData.token);
772
+ const existingScopes = existing.scopes || [];
773
+ const sortedUpdate = [...updateScopes].sort();
774
+ const sortedExisting = [...existingScopes].sort();
775
+ const scopesEqual =
776
+ sortedUpdate.length === sortedExisting.length &&
777
+ sortedUpdate.every((s, i) => s === sortedExisting[i]);
778
+
779
+ const tokenDescription = getTokenDescription(
780
+ token,
781
+ resolvedTokensForRefs,
782
+ );
783
+ const descriptionSame =
784
+ (existing.description || "") === tokenDescription;
785
+
786
+ if (lightSame && darkSame) {
787
+ // Values unchanged: still send UPDATE if scopes or description need to be applied/updated
788
+ if (!scopesEqual || !descriptionSame) {
789
+ variableChanges.push({
790
+ action: "UPDATE",
791
+ id: existing.id,
792
+ name: variableName,
793
+ scopes: updateScopes,
794
+ description: tokenDescription,
795
+ });
796
+ stats.updated++;
797
+ } else {
798
+ stats.skipped++;
799
+ }
800
+ tempIdMap.set(tokenPath, existing.id);
801
+ tempIdMap.set(variableName, existing.id);
802
+ continue;
803
+ }
804
+
805
+ // Update variable (values changed) — always send our scopes so Figma matches code
806
+ const updatePayload = {
807
+ action: "UPDATE",
808
+ id: existing.id,
809
+ name: variableName,
810
+ scopes: updateScopes,
811
+ description: tokenDescription,
812
+ };
813
+ variableChanges.push(updatePayload);
814
+
815
+ variableModeValues.push(
816
+ {
817
+ variableId: existing.id,
818
+ modeId: modes.light.id,
819
+ value: lightFigmaValue,
820
+ },
821
+ {
822
+ variableId: existing.id,
823
+ modeId: modes.dark.id,
824
+ value: darkFigmaValue,
825
+ },
826
+ );
827
+
828
+ tempIdMap.set(tokenPath, existing.id);
829
+ tempIdMap.set(variableName, existing.id);
830
+ stats.updated++;
831
+ } else {
832
+ // Validate mode IDs before creating new variable
833
+ if (!modes.light?.id || !modes.dark?.id) {
834
+ errors.push(
835
+ `Mode IDs are missing: light.id=${modes.light?.id}, dark.id=${modes.dark?.id}`,
836
+ );
837
+ stats.errors++;
838
+ continue;
839
+ }
840
+
841
+ // Create new variable with temporary ID
842
+ const tempIdCounter = tempIdMap.size + variableChanges.length + 1;
843
+ const tempId = `temp_var_${tempIdCounter}_${variableName.replace(/\//g, "_").replace(/[^a-zA-Z0-9_]/g, "_")}`;
844
+ tempIdMap.set(tokenPath, tempId);
845
+ tempIdMap.set(variableName, tempId);
846
+
847
+ // Determine variable type based on the actual resolved value
848
+ let variableType = getFigmaVariableType(tokenType);
849
+ if (
850
+ variableType === "FLOAT" &&
851
+ typeof lightFigmaValue === "string" &&
852
+ lightFigmaValue.includes(" ")
853
+ ) {
854
+ variableType = "STRING";
855
+ }
856
+
857
+ const createScopes = getScopesForVariable(tokenData.token);
858
+ const createPayload = {
859
+ action: "CREATE",
860
+ id: tempId,
861
+ name: variableName,
862
+ variableCollectionId: collectionId,
863
+ resolvedType: variableType,
864
+ description: getTokenDescription(token, resolvedTokensForRefs),
865
+ };
866
+ createPayload.scopes = createScopes;
867
+ variableChanges.push(createPayload);
868
+
869
+ variableModeValues.push(
870
+ {
871
+ variableId: tempId,
872
+ modeId: modes.light.id,
873
+ value: lightFigmaValue,
874
+ },
875
+ {
876
+ variableId: tempId,
877
+ modeId: modes.dark.id,
878
+ value: darkFigmaValue,
879
+ },
880
+ );
881
+
882
+ stats.created++;
883
+ }
884
+ } catch (error) {
885
+ errors.push(`${tokenPath}: ${error.message}`);
886
+ stats.errors++;
887
+ }
888
+ }
889
+
890
+ // Delete component variables that are no longer in token source
891
+ for (const v of existingVariables) {
892
+ if (v.name?.startsWith("component/") && !keptComponentNames.has(v.name)) {
893
+ variableChanges.push({ action: "DELETE", id: v.id });
894
+ stats.deleted++;
895
+ }
896
+ }
897
+
898
+ return {
899
+ variableChanges,
900
+ variableModeValues,
901
+ stats,
902
+ errors,
903
+ tempIdMap,
904
+ };
905
+ }
906
+
907
+ module.exports = {
908
+ buildComponentVariableRequest,
909
+ };