@servicetitan/hammer-token 2.5.0 → 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 +56 -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 -239
  24. package/build/web/core/semantic.d.ts +221 -0
  25. package/build/web/core/semantic.js +1613 -347
  26. package/build/web/core/semantic.scss +219 -137
  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 -229
  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 -439
  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 -341
@@ -0,0 +1,1056 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Semantic Token Sync
5
+ *
6
+ * Functions for building variable requests for semantic (non-component) tokens.
7
+ */
8
+
9
+ const { isComponentToken } = require("./token-parsing");
10
+ const {
11
+ isReference,
12
+ isCompositeColorWithReference,
13
+ extractReferencePath,
14
+ resolveMultipleReferences,
15
+ resolveReferencePath,
16
+ topologicalSortSemanticTokens,
17
+ getTokenDescription,
18
+ buildReferenceChain,
19
+ } = require("./token-resolution");
20
+ const {
21
+ convertColorValue,
22
+ convertTokenValueToFigma,
23
+ getFigmaVariableType,
24
+ isShadowSizeToken,
25
+ parseShadowSizeValue,
26
+ isGradientToken,
27
+ parseGradientStops,
28
+ } = require("./token-conversion");
29
+ const {
30
+ valuesEqual,
31
+ getVariableIdByName,
32
+ determineVariableName: determineVariableNameHelper,
33
+ } = require("./figma-api");
34
+ /**
35
+ * Get Figma variable scopes from token $extensions["com.figma.scopes"]. Missing or empty = ["ALL_SCOPES"].
36
+ * @param {Object} [token] - Leaf token object
37
+ * @returns {string[]} Scopes array
38
+ */
39
+ function getScopesForVariable(token) {
40
+ const fromToken = token?.$extensions?.["com.figma.scopes"];
41
+ return Array.isArray(fromToken) && fromToken.length > 0
42
+ ? fromToken
43
+ : ["ALL_SCOPES"];
44
+ }
45
+
46
+ /**
47
+ * Determine the variable name for a token path (wrapper that includes isComponentToken)
48
+ * @param {string} targetPath - Token path (e.g., "color/neutral/0")
49
+ * @param {Map} resolvedTokensForRefs - Map of resolved tokens for reference resolution
50
+ * @param {Array} existingVariables - Array of existing Figma variables
51
+ * @param {Map} tempIdMap - Temporary ID map for tracking created variables
52
+ * @returns {string} Variable name with correct prefix (primitive/, semantic/, or component/)
53
+ */
54
+ function determineVariableName(
55
+ targetPath,
56
+ resolvedTokensForRefs,
57
+ existingVariables,
58
+ tempIdMap,
59
+ ) {
60
+ return determineVariableNameHelper(
61
+ targetPath,
62
+ resolvedTokensForRefs,
63
+ existingVariables,
64
+ tempIdMap,
65
+ isComponentToken,
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Build bulk request for semantic variable changes only
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 buildSemanticVariableRequest(
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
+ const keptSemanticNames = new Set();
93
+
94
+ // Use allResolvedTokens for reference resolution if provided, otherwise use tokensToSync
95
+ const resolvedTokensForRefs = allResolvedTokens || tokensToSync;
96
+
97
+ // Filter to only semantic tokens (exclude primitives and component tokens)
98
+ const semanticTokens = [];
99
+ for (const [tokenPath, tokenData] of tokensToSync.entries()) {
100
+ if (!tokenData.isPrimitive && !isComponentToken(tokenData.sourceFilePath)) {
101
+ semanticTokens.push([tokenPath, tokenData]);
102
+ }
103
+ }
104
+
105
+ // Topologically sort semantic tokens to process dependencies first
106
+ const sortedSemanticTokens = topologicalSortSemanticTokens(
107
+ semanticTokens,
108
+ resolvedTokensForRefs,
109
+ );
110
+
111
+ // Validate modes before processing
112
+ if (!modes?.light || !modes?.dark) {
113
+ errors.push(
114
+ `Modes are missing: light=${!!modes?.light}, dark=${!!modes?.dark}`,
115
+ );
116
+ stats.errors++;
117
+ }
118
+ if (!modes?.light?.id || !modes?.dark?.id) {
119
+ errors.push(
120
+ `Mode IDs are missing: light.id=${modes?.light?.id}, dark.id=${modes?.dark?.id}`,
121
+ );
122
+ stats.errors++;
123
+ }
124
+
125
+ // Process semantic tokens in dependency order
126
+ for (const [tokenPath, tokenData] of sortedSemanticTokens) {
127
+ try {
128
+ const { token, resolvedLight, resolvedDark } = tokenData;
129
+ const tokenType = token.$type || "string";
130
+
131
+ // Add "semantic/" prefix to variable name for organization
132
+ const variableName = `semantic/${tokenPath}`;
133
+
134
+ // Track kept variable names for delete detection (remove vars no longer in token source)
135
+ if (
136
+ isShadowSizeToken(tokenPath) &&
137
+ typeof resolvedLight === "string" &&
138
+ typeof resolvedDark === "string"
139
+ ) {
140
+ const lightComponents = parseShadowSizeValue(resolvedLight);
141
+ const darkComponents = parseShadowSizeValue(resolvedDark);
142
+ if (lightComponents && darkComponents) {
143
+ keptSemanticNames.add(`${variableName}/x`);
144
+ keptSemanticNames.add(`${variableName}/y`);
145
+ keptSemanticNames.add(`${variableName}/blur`);
146
+ } else {
147
+ keptSemanticNames.add(variableName);
148
+ }
149
+ } else if (isGradientToken(tokenType)) {
150
+ const gradientValue =
151
+ typeof token.$value === "object" ? token.$value : null;
152
+ const stops = parseGradientStops(gradientValue);
153
+ if (stops) {
154
+ for (let i = 0; i < stops.length; i++) {
155
+ keptSemanticNames.add(`${variableName}/stop-${i}`);
156
+ }
157
+ } else {
158
+ keptSemanticNames.add(variableName);
159
+ }
160
+ } else {
161
+ keptSemanticNames.add(variableName);
162
+ }
163
+
164
+ // Special handling for shadow size tokens: split into x, y, blur variables
165
+ if (
166
+ isShadowSizeToken(tokenPath) &&
167
+ typeof resolvedLight === "string" &&
168
+ typeof resolvedDark === "string"
169
+ ) {
170
+ const lightComponents = parseShadowSizeValue(resolvedLight);
171
+ const darkComponents = parseShadowSizeValue(resolvedDark);
172
+
173
+ if (lightComponents && darkComponents) {
174
+ // Create three separate variables: x, y, blur
175
+ const components = [
176
+ {
177
+ suffix: "x",
178
+ lightRef: lightComponents.x,
179
+ darkRef: darkComponents.x,
180
+ },
181
+ {
182
+ suffix: "y",
183
+ lightRef: lightComponents.y,
184
+ darkRef: darkComponents.y,
185
+ },
186
+ {
187
+ suffix: "blur",
188
+ lightRef: lightComponents.blur,
189
+ darkRef: darkComponents.blur,
190
+ },
191
+ ];
192
+
193
+ for (const component of components) {
194
+ const componentVariableName = `${variableName}/${component.suffix}`;
195
+ const componentTokenPath = `${tokenPath}/${component.suffix}`;
196
+
197
+ // Per-component description: follow the component's own ref to its resolved value
198
+ // component.lightRef is a dot-path (e.g. "size.0"), wrap in braces for buildReferenceChain
199
+ const componentChain = buildReferenceChain(
200
+ `{${component.lightRef}}`,
201
+ resolvedTokensForRefs,
202
+ );
203
+ const baseDesc = token.$description || "";
204
+ const componentDescription =
205
+ baseDesc && componentChain
206
+ ? `${baseDesc}\n${componentChain}`
207
+ : componentChain || baseDesc;
208
+
209
+ // Resolve light reference
210
+ const lightTargetPath = resolveReferencePath(
211
+ component.lightRef,
212
+ resolvedTokensForRefs,
213
+ );
214
+ const lightTargetTokenData =
215
+ resolvedTokensForRefs.get(lightTargetPath);
216
+ if (!lightTargetTokenData) {
217
+ const errorMsg = `Could not find light target token ${lightTargetPath} for ${componentTokenPath}`;
218
+ errors.push(errorMsg);
219
+ stats.errors++;
220
+ continue;
221
+ }
222
+ const lightTargetVariableName = determineVariableName(
223
+ lightTargetPath,
224
+ resolvedTokensForRefs,
225
+ existingVariables,
226
+ tempIdMap,
227
+ );
228
+ const lightAliasId =
229
+ tempIdMap.get(lightTargetPath) ||
230
+ tempIdMap.get(lightTargetVariableName) ||
231
+ getVariableIdByName(lightTargetVariableName, existingVariables) ||
232
+ getVariableIdByName(lightTargetPath, existingVariables);
233
+
234
+ if (!lightAliasId) {
235
+ const errorMsg = `Could not resolve light alias for ${componentTokenPath} -> ${lightTargetPath}`;
236
+ errors.push(errorMsg);
237
+ stats.errors++;
238
+ continue;
239
+ }
240
+
241
+ // Resolve dark reference
242
+ const darkTargetPath = resolveReferencePath(
243
+ component.darkRef,
244
+ resolvedTokensForRefs,
245
+ );
246
+ const darkTargetTokenData =
247
+ resolvedTokensForRefs.get(darkTargetPath);
248
+ if (!darkTargetTokenData) {
249
+ errors.push(
250
+ `Could not find dark target token ${darkTargetPath} for ${componentTokenPath}`,
251
+ );
252
+ stats.errors++;
253
+ continue;
254
+ }
255
+ const darkTargetVariableName = determineVariableName(
256
+ darkTargetPath,
257
+ resolvedTokensForRefs,
258
+ existingVariables,
259
+ tempIdMap,
260
+ );
261
+ const darkAliasId =
262
+ tempIdMap.get(darkTargetPath) ||
263
+ tempIdMap.get(darkTargetVariableName) ||
264
+ getVariableIdByName(darkTargetVariableName, existingVariables) ||
265
+ getVariableIdByName(darkTargetPath, existingVariables);
266
+
267
+ if (!darkAliasId) {
268
+ errors.push(
269
+ `Could not resolve dark alias for ${componentTokenPath} -> ${darkTargetPath}`,
270
+ );
271
+ stats.errors++;
272
+ continue;
273
+ }
274
+
275
+ // Check if variable already exists - optimize with getVariableIdByName
276
+ const existingId = getVariableIdByName(
277
+ componentVariableName,
278
+ existingVariables,
279
+ );
280
+ // Optimize: Use Map for O(1) lookup if we have many variables
281
+ let existing = null;
282
+ if (existingId) {
283
+ if (existingVariables.length > 100) {
284
+ const variableIdMap = new Map();
285
+ for (const v of existingVariables) {
286
+ if (v.id) variableIdMap.set(v.id, v);
287
+ }
288
+ existing = variableIdMap.get(existingId) || null;
289
+ } else {
290
+ existing = existingVariables.find((v) => v.id === existingId);
291
+ }
292
+ }
293
+
294
+ if (existing) {
295
+ // Update existing variable
296
+ if (!existing.id) {
297
+ errors.push(
298
+ `Existing variable ${componentVariableName} has no ID`,
299
+ );
300
+ stats.errors++;
301
+ continue;
302
+ }
303
+
304
+ const lightFigmaValue = {
305
+ type: "VARIABLE_ALIAS",
306
+ id: lightAliasId,
307
+ };
308
+ const darkFigmaValue = {
309
+ type: "VARIABLE_ALIAS",
310
+ id: darkAliasId,
311
+ };
312
+ const existingValues = existing.valuesByMode || {};
313
+ const existingLight = existingValues[modes.light.id];
314
+ const existingDark = existingValues[modes.dark.id];
315
+ const lightSame = valuesEqual(existingLight, lightFigmaValue);
316
+ const darkSame = valuesEqual(existingDark, darkFigmaValue);
317
+
318
+ const componentScopes = getScopesForVariable(tokenData.token);
319
+ const sortedUpdate = [...componentScopes].sort();
320
+ const sortedExisting = [...(existing.scopes ?? [])].sort();
321
+ const scopesEqual =
322
+ sortedUpdate.length === sortedExisting.length &&
323
+ sortedUpdate.every((s, i) => s === sortedExisting[i]);
324
+
325
+ const shadowDescriptionSame =
326
+ (existing.description || "") === componentDescription;
327
+
328
+ if (
329
+ lightSame &&
330
+ darkSame &&
331
+ (scopesEqual || componentScopes.length === 0) &&
332
+ shadowDescriptionSame
333
+ ) {
334
+ tempIdMap.set(componentTokenPath, existing.id);
335
+ tempIdMap.set(componentVariableName, existing.id);
336
+ stats.skipped++;
337
+ continue;
338
+ }
339
+
340
+ const updatePayload = {
341
+ action: "UPDATE",
342
+ id: existing.id,
343
+ name: componentVariableName,
344
+ scopes: componentScopes,
345
+ description: componentDescription,
346
+ };
347
+ variableChanges.push(updatePayload);
348
+
349
+ variableModeValues.push(
350
+ {
351
+ variableId: existing.id,
352
+ modeId: modes.light.id,
353
+ value: lightFigmaValue,
354
+ },
355
+ {
356
+ variableId: existing.id,
357
+ modeId: modes.dark.id,
358
+ value: darkFigmaValue,
359
+ },
360
+ );
361
+
362
+ tempIdMap.set(componentTokenPath, existing.id);
363
+ tempIdMap.set(componentVariableName, existing.id);
364
+ stats.updated++;
365
+ } else {
366
+ // Create new variable
367
+ const tempIdCounter = tempIdMap.size + variableChanges.length + 1;
368
+ const tempId = `temp_var_${tempIdCounter}_${componentVariableName.replace(/\//g, "_").replace(/[^a-zA-Z0-9_]/g, "_")}`;
369
+ tempIdMap.set(componentTokenPath, tempId);
370
+ tempIdMap.set(componentVariableName, tempId);
371
+
372
+ const componentScopes = getScopesForVariable(tokenData.token);
373
+ const createPayload = {
374
+ action: "CREATE",
375
+ id: tempId,
376
+ name: componentVariableName,
377
+ variableCollectionId: collectionId,
378
+ resolvedType: "FLOAT", // Shadow size components are always FLOAT (dimension values)
379
+ description: componentDescription,
380
+ };
381
+ createPayload.scopes = componentScopes;
382
+ variableChanges.push(createPayload);
383
+
384
+ variableModeValues.push(
385
+ {
386
+ variableId: tempId,
387
+ modeId: modes.light.id,
388
+ value: { type: "VARIABLE_ALIAS", id: lightAliasId },
389
+ },
390
+ {
391
+ variableId: tempId,
392
+ modeId: modes.dark.id,
393
+ value: { type: "VARIABLE_ALIAS", id: darkAliasId },
394
+ },
395
+ );
396
+
397
+ stats.created++;
398
+ }
399
+ }
400
+
401
+ // Skip normal processing for shadow size tokens (we've already handled them)
402
+ continue;
403
+ }
404
+ }
405
+
406
+ // Special handling for gradient tokens: unpack each stop into a COLOR variable
407
+ // Figma does not support a gradient variable type; stops are individual COLOR variables.
408
+ if (isGradientToken(tokenType)) {
409
+ const gradientValue =
410
+ typeof token.$value === "object" ? token.$value : null;
411
+ const stops = parseGradientStops(gradientValue);
412
+
413
+ if (!stops) {
414
+ errors.push(`Could not parse gradient stops for ${tokenPath}`);
415
+ stats.errors++;
416
+ continue;
417
+ }
418
+
419
+ const gradientDescription = getTokenDescription(
420
+ token,
421
+ resolvedTokensForRefs,
422
+ );
423
+
424
+ for (let i = 0; i < stops.length; i++) {
425
+ const { lightRef, darkRef } = stops[i];
426
+ const stopVariableName = `${variableName}/stop-${i}`;
427
+ const stopTokenPath = `${tokenPath}/stop-${i}`;
428
+ const lightChain =
429
+ buildReferenceChain(lightRef, resolvedTokensForRefs) || lightRef;
430
+ const darkChain =
431
+ buildReferenceChain(darkRef, resolvedTokensForRefs) || darkRef;
432
+ const stopColorDetail =
433
+ lightRef === darkRef
434
+ ? lightChain
435
+ : `light: ${lightChain}, dark: ${darkChain}`;
436
+ const rawPosition = gradientValue.stops[i]?.position;
437
+ const stopPosition =
438
+ typeof rawPosition === "number"
439
+ ? `${rawPosition * 100}%`
440
+ : String(rawPosition ?? i);
441
+ const stopDescription = gradientDescription
442
+ ? `${gradientDescription}\nstop ${i} at ${stopPosition}: ${stopColorDetail}`
443
+ : `stop ${i} at ${stopPosition}: ${stopColorDetail}`;
444
+
445
+ // Resolve light color reference to a primitive variable alias
446
+ // lightRef may be a reference string like "{color.blue.600}" — extract path first
447
+ const lightRefPath = extractReferencePath(lightRef) ?? lightRef;
448
+ const lightTargetPath = resolveReferencePath(
449
+ lightRefPath,
450
+ resolvedTokensForRefs,
451
+ );
452
+ const lightTargetVariableName = determineVariableName(
453
+ lightTargetPath,
454
+ resolvedTokensForRefs,
455
+ existingVariables,
456
+ tempIdMap,
457
+ );
458
+ const lightAliasId =
459
+ tempIdMap.get(lightTargetPath) ||
460
+ tempIdMap.get(lightTargetVariableName) ||
461
+ getVariableIdByName(lightTargetVariableName, existingVariables) ||
462
+ getVariableIdByName(lightTargetPath, existingVariables);
463
+
464
+ if (!lightAliasId) {
465
+ errors.push(
466
+ `Could not resolve light alias for ${stopTokenPath} -> ${lightTargetPath}`,
467
+ );
468
+ stats.errors++;
469
+ continue;
470
+ }
471
+
472
+ // Resolve dark color reference to a primitive variable alias
473
+ // darkRef may be a reference string like "{color.cyan.300}" — extract path first
474
+ const darkRefPath = extractReferencePath(darkRef) ?? darkRef;
475
+ const darkTargetPath = resolveReferencePath(
476
+ darkRefPath,
477
+ resolvedTokensForRefs,
478
+ );
479
+ const darkTargetVariableName = determineVariableName(
480
+ darkTargetPath,
481
+ resolvedTokensForRefs,
482
+ existingVariables,
483
+ tempIdMap,
484
+ );
485
+ const darkAliasId =
486
+ tempIdMap.get(darkTargetPath) ||
487
+ tempIdMap.get(darkTargetVariableName) ||
488
+ getVariableIdByName(darkTargetVariableName, existingVariables) ||
489
+ getVariableIdByName(darkTargetPath, existingVariables);
490
+
491
+ if (!darkAliasId) {
492
+ errors.push(
493
+ `Could not resolve dark alias for ${stopTokenPath} -> ${darkTargetPath}`,
494
+ );
495
+ stats.errors++;
496
+ continue;
497
+ }
498
+
499
+ const lightFigmaValue = { type: "VARIABLE_ALIAS", id: lightAliasId };
500
+ const darkFigmaValue = { type: "VARIABLE_ALIAS", id: darkAliasId };
501
+
502
+ // Get scopes from the stop color's extensions
503
+ const stopColor = gradientValue.stops[i]?.color;
504
+ const stopScopes =
505
+ Array.isArray(stopColor?.$extensions?.["com.figma.scopes"]) &&
506
+ stopColor.$extensions["com.figma.scopes"].length > 0
507
+ ? stopColor.$extensions["com.figma.scopes"]
508
+ : ["ALL_SCOPES"];
509
+
510
+ const existingId = getVariableIdByName(
511
+ stopVariableName,
512
+ existingVariables,
513
+ );
514
+ let existing = null;
515
+ if (existingId) {
516
+ existing =
517
+ existingVariables.length > 100
518
+ ? (() => {
519
+ const m = new Map(
520
+ existingVariables
521
+ .filter((v) => v.id)
522
+ .map((v) => [v.id, v]),
523
+ );
524
+ return m.get(existingId) || null;
525
+ })()
526
+ : existingVariables.find((v) => v.id === existingId) || null;
527
+ }
528
+
529
+ if (existing) {
530
+ const existingValues = existing.valuesByMode || {};
531
+ const lightSame = valuesEqual(
532
+ existingValues[modes.light.id],
533
+ lightFigmaValue,
534
+ );
535
+ const darkSame = valuesEqual(
536
+ existingValues[modes.dark.id],
537
+ darkFigmaValue,
538
+ );
539
+ const sortedUpdate = [...stopScopes].sort();
540
+ const sortedExisting = [...(existing.scopes ?? [])].sort();
541
+ const scopesEqual =
542
+ sortedUpdate.length === sortedExisting.length &&
543
+ sortedUpdate.every((s, idx) => s === sortedExisting[idx]);
544
+
545
+ const stopDescriptionSame =
546
+ (existing.description || "") === stopDescription;
547
+ if (lightSame && darkSame && scopesEqual && stopDescriptionSame) {
548
+ tempIdMap.set(stopTokenPath, existing.id);
549
+ tempIdMap.set(stopVariableName, existing.id);
550
+ stats.skipped++;
551
+ continue;
552
+ }
553
+
554
+ variableChanges.push({
555
+ action: "UPDATE",
556
+ id: existing.id,
557
+ name: stopVariableName,
558
+ scopes: stopScopes,
559
+ description: stopDescription,
560
+ });
561
+ variableModeValues.push(
562
+ {
563
+ variableId: existing.id,
564
+ modeId: modes.light.id,
565
+ value: lightFigmaValue,
566
+ },
567
+ {
568
+ variableId: existing.id,
569
+ modeId: modes.dark.id,
570
+ value: darkFigmaValue,
571
+ },
572
+ );
573
+ tempIdMap.set(stopTokenPath, existing.id);
574
+ tempIdMap.set(stopVariableName, existing.id);
575
+ stats.updated++;
576
+ } else {
577
+ const tempIdCounter = tempIdMap.size + variableChanges.length + 1;
578
+ const tempId = `temp_var_${tempIdCounter}_${stopVariableName.replace(/\//g, "_").replace(/[^a-zA-Z0-9_]/g, "_")}`;
579
+ tempIdMap.set(stopTokenPath, tempId);
580
+ tempIdMap.set(stopVariableName, tempId);
581
+
582
+ variableChanges.push({
583
+ action: "CREATE",
584
+ id: tempId,
585
+ name: stopVariableName,
586
+ variableCollectionId: collectionId,
587
+ resolvedType: "COLOR",
588
+ scopes: stopScopes,
589
+ description: stopDescription,
590
+ });
591
+ variableModeValues.push(
592
+ {
593
+ variableId: tempId,
594
+ modeId: modes.light.id,
595
+ value: lightFigmaValue,
596
+ },
597
+ {
598
+ variableId: tempId,
599
+ modeId: modes.dark.id,
600
+ value: darkFigmaValue,
601
+ },
602
+ );
603
+ stats.created++;
604
+ }
605
+ }
606
+
607
+ // Skip normal processing — gradient token itself has no Figma variable
608
+ continue;
609
+ }
610
+
611
+ // Check if this should be an alias (only for direct string references, not composite colors)
612
+ // For composite colors with references, we need to resolve the color value and apply alpha
613
+ const isLightReference = isReference(resolvedLight);
614
+ const isDarkReference = isReference(resolvedDark);
615
+ const isLightCompositeWithRef =
616
+ isCompositeColorWithReference(resolvedLight);
617
+ const isDarkCompositeWithRef =
618
+ isCompositeColorWithReference(resolvedDark);
619
+
620
+ let lightFigmaValue;
621
+ let darkFigmaValue;
622
+ let lightAliasId = null;
623
+ let darkAliasId = null;
624
+
625
+ // Handle composite colors with references - resolve the color and apply alpha
626
+ if (isLightCompositeWithRef) {
627
+ // Resolve the color reference to its actual value
628
+ const refString = resolvedLight.color;
629
+ const refPath = extractReferencePath(refString);
630
+ if (!refPath) {
631
+ errors.push(
632
+ `Could not extract reference path from ${refString} for ${tokenPath}`,
633
+ );
634
+ stats.errors++;
635
+ continue;
636
+ }
637
+
638
+ const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
639
+ const targetTokenData = resolvedTokensForRefs.get(targetPath);
640
+ if (!targetTokenData) {
641
+ errors.push(
642
+ `Could not find target token ${targetPath} for ${tokenPath}`,
643
+ );
644
+ stats.errors++;
645
+ continue;
646
+ }
647
+
648
+ // Get the resolved color value (use light value for light mode)
649
+ const resolvedColorValue = targetTokenData.resolvedLight;
650
+ if (!resolvedColorValue) {
651
+ errors.push(
652
+ `Target token ${targetPath} has no resolved light value for ${tokenPath}`,
653
+ );
654
+ stats.errors++;
655
+ continue;
656
+ }
657
+
658
+ // Convert the resolved color to Figma format and apply alpha
659
+ const baseColor = convertColorValue(resolvedColorValue);
660
+ if (baseColor === null) {
661
+ errors.push(
662
+ `Could not convert resolved color value for ${tokenPath}: ${JSON.stringify(resolvedColorValue)}`,
663
+ );
664
+ stats.errors++;
665
+ continue;
666
+ }
667
+
668
+ // Apply the alpha from the composite color
669
+ lightFigmaValue = {
670
+ ...baseColor,
671
+ a: resolvedLight.alpha ?? resolvedLight.a ?? baseColor.a,
672
+ };
673
+ } else if (isLightReference) {
674
+ // Direct string reference - create alias
675
+ const refPath = extractReferencePath(resolvedLight);
676
+ if (!refPath) {
677
+ errors.push(
678
+ `Could not extract reference path from ${resolvedLight} for ${tokenPath}`,
679
+ );
680
+ stats.errors++;
681
+ continue;
682
+ }
683
+
684
+ const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
685
+ // Determine the correct variable name based on token type
686
+ const targetVariableName = determineVariableName(
687
+ targetPath,
688
+ resolvedTokensForRefs,
689
+ existingVariables,
690
+ tempIdMap,
691
+ );
692
+ lightAliasId =
693
+ tempIdMap.get(targetPath) ||
694
+ tempIdMap.get(targetVariableName) ||
695
+ getVariableIdByName(targetVariableName, existingVariables) ||
696
+ getVariableIdByName(targetPath, existingVariables);
697
+
698
+ if (!lightAliasId) {
699
+ const errorMsg = `Could not resolve light alias for ${tokenPath} -> ${targetPath}`;
700
+ errors.push(errorMsg);
701
+ stats.errors++;
702
+ continue;
703
+ }
704
+
705
+ lightFigmaValue = { type: "VARIABLE_ALIAS", id: lightAliasId };
706
+ } else {
707
+ // Check if the value is a string with multiple references (e.g., "{size.0} {size.0} {size.0}")
708
+ // If so, resolve all references to their actual values
709
+ let valueToConvert = resolvedLight;
710
+ if (
711
+ typeof resolvedLight === "string" &&
712
+ resolvedLight.includes("{") &&
713
+ resolvedLight.includes("}") &&
714
+ !isReference(resolvedLight)
715
+ ) {
716
+ valueToConvert = resolveMultipleReferences(
717
+ resolvedLight,
718
+ resolvedTokensForRefs,
719
+ false,
720
+ );
721
+ }
722
+
723
+ const converted = convertTokenValueToFigma(tokenType, valueToConvert, {
724
+ tokenPath,
725
+ scopes: getScopesForVariable(token),
726
+ });
727
+
728
+ if (converted === null) {
729
+ errors.push(`Could not convert light value for ${tokenPath}`);
730
+ stats.errors++;
731
+ continue;
732
+ }
733
+ lightFigmaValue = converted;
734
+ }
735
+
736
+ // Handle composite colors with references - resolve the color and apply alpha
737
+ if (isDarkCompositeWithRef) {
738
+ // Resolve the color reference to its actual value
739
+ const refString = resolvedDark.color;
740
+ const refPath = extractReferencePath(refString);
741
+ if (!refPath) {
742
+ errors.push(
743
+ `Could not extract reference path from ${refString} for ${tokenPath}`,
744
+ );
745
+ stats.errors++;
746
+ continue;
747
+ }
748
+
749
+ const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
750
+ const targetTokenData = resolvedTokensForRefs.get(targetPath);
751
+ if (!targetTokenData) {
752
+ errors.push(
753
+ `Could not find target token ${targetPath} for ${tokenPath}`,
754
+ );
755
+ stats.errors++;
756
+ continue;
757
+ }
758
+
759
+ // Get the resolved color value (use dark value for dark mode)
760
+ const resolvedColorValue = targetTokenData.resolvedDark;
761
+ if (!resolvedColorValue) {
762
+ errors.push(
763
+ `Target token ${targetPath} has no resolved dark value for ${tokenPath}`,
764
+ );
765
+ stats.errors++;
766
+ continue;
767
+ }
768
+
769
+ // Convert the resolved color to Figma format and apply alpha
770
+ const baseColor = convertColorValue(resolvedColorValue);
771
+ if (baseColor === null) {
772
+ errors.push(
773
+ `Could not convert resolved color value for ${tokenPath}: ${JSON.stringify(resolvedColorValue)}`,
774
+ );
775
+ stats.errors++;
776
+ continue;
777
+ }
778
+
779
+ // Apply the alpha from the composite color
780
+ darkFigmaValue = {
781
+ ...baseColor,
782
+ a: resolvedDark.alpha ?? resolvedDark.a ?? baseColor.a,
783
+ };
784
+ } else if (isDarkReference) {
785
+ // Direct string reference - create alias
786
+ const refPath = extractReferencePath(resolvedDark);
787
+ if (!refPath) {
788
+ errors.push(
789
+ `Could not extract reference path from ${resolvedDark} for ${tokenPath}`,
790
+ );
791
+ stats.errors++;
792
+ continue;
793
+ }
794
+
795
+ const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
796
+ // Determine the correct variable name based on token type
797
+ const targetVariableName = determineVariableName(
798
+ targetPath,
799
+ resolvedTokensForRefs,
800
+ existingVariables,
801
+ tempIdMap,
802
+ );
803
+ darkAliasId =
804
+ tempIdMap.get(targetPath) ||
805
+ tempIdMap.get(targetVariableName) ||
806
+ getVariableIdByName(targetVariableName, existingVariables) ||
807
+ getVariableIdByName(targetPath, existingVariables);
808
+
809
+ if (!darkAliasId) {
810
+ const errorMsg = `Could not resolve dark alias for ${tokenPath} -> ${targetPath}`;
811
+ errors.push(errorMsg);
812
+ stats.errors++;
813
+ continue;
814
+ }
815
+
816
+ darkFigmaValue = { type: "VARIABLE_ALIAS", id: darkAliasId };
817
+ } else {
818
+ // Check if the value is a string with multiple references (e.g., "{size.0} {size.0} {size.0}")
819
+ // If so, resolve all references to their actual values
820
+ let valueToConvert = resolvedDark;
821
+ if (
822
+ typeof resolvedDark === "string" &&
823
+ resolvedDark.includes("{") &&
824
+ resolvedDark.includes("}") &&
825
+ !isReference(resolvedDark)
826
+ ) {
827
+ valueToConvert = resolveMultipleReferences(
828
+ resolvedDark,
829
+ resolvedTokensForRefs,
830
+ true,
831
+ );
832
+ }
833
+
834
+ const converted = convertTokenValueToFigma(tokenType, valueToConvert, {
835
+ tokenPath,
836
+ scopes: getScopesForVariable(token),
837
+ });
838
+ if (converted === null) {
839
+ errors.push(`Could not convert dark value for ${tokenPath}`);
840
+ stats.errors++;
841
+ continue;
842
+ }
843
+ darkFigmaValue = converted;
844
+ }
845
+
846
+ // Get existing variable (check both with and without prefix for backward compatibility)
847
+ // Optimize: Use getVariableIdByName for better performance
848
+ const existingId =
849
+ getVariableIdByName(variableName, existingVariables) ||
850
+ getVariableIdByName(tokenPath, existingVariables);
851
+ // Optimize: Use Map for O(1) lookup if we have many variables
852
+ let existing = null;
853
+ if (existingId) {
854
+ if (existingVariables.length > 100) {
855
+ const variableIdMap = new Map();
856
+ for (const v of existingVariables) {
857
+ if (v.id) variableIdMap.set(v.id, v);
858
+ }
859
+ existing = variableIdMap.get(existingId) || null;
860
+ } else {
861
+ existing = existingVariables.find((v) => v.id === existingId);
862
+ }
863
+ }
864
+
865
+ if (existing) {
866
+ // Validate that existing variable has an ID
867
+ if (!existing.id) {
868
+ const errorMsg = `Existing variable for ${tokenPath} has no ID`;
869
+ errors.push(errorMsg);
870
+ stats.errors++;
871
+ continue;
872
+ }
873
+
874
+ // Validate mode IDs
875
+ if (!modes.light?.id || !modes.dark?.id) {
876
+ errors.push(
877
+ `Mode IDs are missing: light=${modes.light?.id}, dark=${modes.dark?.id}`,
878
+ );
879
+ stats.errors++;
880
+ continue;
881
+ }
882
+
883
+ // Check if values are the same
884
+ const existingValues = existing.valuesByMode || {};
885
+ const existingLight = existingValues[modes.light.id];
886
+ const existingDark = existingValues[modes.dark.id];
887
+
888
+ const lightSame = valuesEqual(existingLight, lightFigmaValue);
889
+ const darkSame = valuesEqual(existingDark, darkFigmaValue);
890
+
891
+ const updateScopes = getScopesForVariable(tokenData.token);
892
+ const existingScopes = existing.scopes || [];
893
+ const sortedUpdate = [...updateScopes].sort();
894
+ const sortedExisting = [...existingScopes].sort();
895
+ const scopesEqual =
896
+ sortedUpdate.length === sortedExisting.length &&
897
+ sortedUpdate.every((s, i) => s === sortedExisting[i]);
898
+
899
+ const tokenDescription = getTokenDescription(
900
+ token,
901
+ resolvedTokensForRefs,
902
+ );
903
+ const descriptionSame =
904
+ (existing.description || "") === tokenDescription;
905
+
906
+ if (lightSame && darkSame) {
907
+ // Values unchanged: still send UPDATE if scopes or description need to be applied/updated
908
+ if (!scopesEqual || !descriptionSame) {
909
+ variableChanges.push({
910
+ action: "UPDATE",
911
+ id: existing.id,
912
+ name: variableName,
913
+ scopes: updateScopes,
914
+ description: tokenDescription,
915
+ });
916
+ stats.updated++;
917
+ } else {
918
+ stats.skipped++;
919
+ }
920
+ tempIdMap.set(tokenPath, existing.id);
921
+ tempIdMap.set(variableName, existing.id);
922
+ continue;
923
+ }
924
+
925
+ // Update variable (values changed) — always send our scopes so Figma matches code
926
+ const updatePayload = {
927
+ action: "UPDATE",
928
+ id: existing.id,
929
+ name: variableName,
930
+ scopes: updateScopes,
931
+ description: tokenDescription,
932
+ };
933
+ variableChanges.push(updatePayload);
934
+
935
+ // Validate IDs before pushing
936
+ if (!existing.id || !modes.light.id || !modes.dark.id) {
937
+ const errorMsg = `Invalid IDs for ${tokenPath}: existing.id=${existing.id}, light.id=${modes.light.id}, dark.id=${modes.dark.id}`;
938
+ errors.push(errorMsg);
939
+ stats.errors++;
940
+ continue;
941
+ }
942
+
943
+ variableModeValues.push(
944
+ {
945
+ variableId: existing.id,
946
+ modeId: modes.light.id,
947
+ value: lightFigmaValue,
948
+ },
949
+ {
950
+ variableId: existing.id,
951
+ modeId: modes.dark.id,
952
+ value: darkFigmaValue,
953
+ },
954
+ );
955
+
956
+ stats.updated++;
957
+ tempIdMap.set(tokenPath, existing.id);
958
+ tempIdMap.set(variableName, existing.id);
959
+ } else {
960
+ // Validate mode IDs before creating new variable
961
+ if (!modes.light?.id || !modes.dark?.id) {
962
+ errors.push(
963
+ `Mode IDs are missing: light=${modes.light?.id}, dark=${modes.dark?.id}`,
964
+ );
965
+ stats.errors++;
966
+ continue;
967
+ }
968
+
969
+ // Create new variable with temporary ID (use a counter to ensure uniqueness)
970
+ const tempIdCounter = tempIdMap.size + variableChanges.length + 1;
971
+ const tempId = `temp_var_${tempIdCounter}_${variableName.replace(/\//g, "_").replace(/[^a-zA-Z0-9_]/g, "_")}`;
972
+ tempIdMap.set(tokenPath, tempId);
973
+ tempIdMap.set(variableName, tempId);
974
+
975
+ // Determine variable type based on the actual resolved value
976
+ // If the value is a space-separated string (like "0rem 0rem 0rem"), it must be STRING type
977
+ // Line-height is sent as % string (e.g. "150%") - must be STRING
978
+ // Otherwise, use the token type to determine the Figma variable type
979
+ let variableType = getFigmaVariableType(tokenType);
980
+ if (
981
+ variableType === "FLOAT" &&
982
+ typeof lightFigmaValue === "string" &&
983
+ lightFigmaValue.includes(" ")
984
+ ) {
985
+ // Space-separated strings can't be FLOAT - must be STRING
986
+ variableType = "STRING";
987
+ }
988
+ if (
989
+ tokenPath.includes("line-height") &&
990
+ typeof lightFigmaValue === "string"
991
+ ) {
992
+ variableType = "STRING";
993
+ }
994
+
995
+ const createScopes = getScopesForVariable(tokenData.token);
996
+ const createPayload = {
997
+ action: "CREATE",
998
+ id: tempId,
999
+ name: variableName,
1000
+ variableCollectionId: collectionId,
1001
+ resolvedType: variableType,
1002
+ description: getTokenDescription(token, resolvedTokensForRefs),
1003
+ };
1004
+ createPayload.scopes = createScopes;
1005
+ variableChanges.push(createPayload);
1006
+
1007
+ // Validate IDs before pushing
1008
+ if (!tempId || !modes.light.id || !modes.dark.id) {
1009
+ errors.push(
1010
+ `Invalid IDs for ${tokenPath}: tempId=${tempId}, light.id=${modes.light.id}, dark.id=${modes.dark.id}`,
1011
+ );
1012
+ stats.errors++;
1013
+ continue;
1014
+ }
1015
+
1016
+ variableModeValues.push(
1017
+ {
1018
+ variableId: tempId,
1019
+ modeId: modes.light.id,
1020
+ value: lightFigmaValue,
1021
+ },
1022
+ {
1023
+ variableId: tempId,
1024
+ modeId: modes.dark.id,
1025
+ value: darkFigmaValue,
1026
+ },
1027
+ );
1028
+
1029
+ stats.created++;
1030
+ }
1031
+ } catch (error) {
1032
+ errors.push(`${tokenPath}: ${error.message}`);
1033
+ stats.errors++;
1034
+ }
1035
+ }
1036
+
1037
+ // Delete semantic variables that are no longer in token source
1038
+ for (const v of existingVariables) {
1039
+ if (v.name?.startsWith("semantic/") && !keptSemanticNames.has(v.name)) {
1040
+ variableChanges.push({ action: "DELETE", id: v.id });
1041
+ stats.deleted++;
1042
+ }
1043
+ }
1044
+
1045
+ return {
1046
+ variableChanges,
1047
+ variableModeValues,
1048
+ stats,
1049
+ errors,
1050
+ tempIdMap,
1051
+ };
1052
+ }
1053
+
1054
+ module.exports = {
1055
+ buildSemanticVariableRequest,
1056
+ };