@servicetitan/hammer-token 2.5.2 → 3.0.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.
Files changed (146) hide show
  1. package/CHANGELOG.md +52 -2
  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 +23 -51
  8. package/build/web/core/css-utils/a2-color.css +221 -233
  9. package/build/web/core/css-utils/a2-font.css +1 -29
  10. package/build/web/core/css-utils/a2-spacing.css +238 -483
  11. package/build/web/core/css-utils/a2-utils.css +496 -781
  12. package/build/web/core/css-utils/border.css +23 -51
  13. package/build/web/core/css-utils/color.css +221 -233
  14. package/build/web/core/css-utils/font.css +1 -29
  15. package/build/web/core/css-utils/spacing.css +238 -483
  16. package/build/web/core/css-utils/utils.css +496 -781
  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/types.d.ts +17 -0
  29. package/config.js +121 -496
  30. package/eslint.config.mjs +11 -1
  31. package/package.json +15 -5
  32. package/src/global/primitive/breakpoint.tokens.json +54 -0
  33. package/src/global/primitive/color.tokens.json +1092 -0
  34. package/src/global/primitive/duration.tokens.json +44 -0
  35. package/src/global/primitive/font.tokens.json +151 -0
  36. package/src/global/primitive/radius.tokens.json +94 -0
  37. package/src/global/primitive/size.tokens.json +174 -0
  38. package/src/global/primitive/transition.tokens.json +32 -0
  39. package/src/theme/core/background.tokens.json +1312 -0
  40. package/src/theme/core/border.tokens.json +192 -0
  41. package/src/theme/core/chart.tokens.json +982 -0
  42. package/src/theme/core/component/ai-mark.tokens.json +20 -0
  43. package/src/theme/core/component/alert.tokens.json +261 -0
  44. package/src/theme/core/component/announcement.tokens.json +460 -0
  45. package/src/theme/core/component/avatar.tokens.json +137 -0
  46. package/src/theme/core/component/badge.tokens.json +42 -0
  47. package/src/theme/core/component/breadcrumb.tokens.json +42 -0
  48. package/src/theme/core/component/button-toggle.tokens.json +428 -0
  49. package/src/theme/core/component/button.tokens.json +941 -0
  50. package/src/theme/core/component/calendar.tokens.json +391 -0
  51. package/src/theme/core/component/card.tokens.json +107 -0
  52. package/src/theme/core/component/checkbox.tokens.json +631 -0
  53. package/src/theme/core/component/chip.tokens.json +169 -0
  54. package/src/theme/core/component/combobox.tokens.json +269 -0
  55. package/src/theme/core/component/details.tokens.json +152 -0
  56. package/src/theme/core/component/dialog.tokens.json +87 -0
  57. package/src/theme/core/component/divider.tokens.json +23 -0
  58. package/src/theme/core/component/dnd.tokens.json +208 -0
  59. package/src/theme/core/component/drawer.tokens.json +61 -0
  60. package/src/theme/core/component/drilldown.tokens.json +61 -0
  61. package/src/theme/core/component/edit-card.tokens.json +381 -0
  62. package/src/theme/core/component/field-label.tokens.json +42 -0
  63. package/src/theme/core/component/field-message.tokens.json +65 -0
  64. package/src/theme/core/component/icon.tokens.json +42 -0
  65. package/src/theme/core/component/link.tokens.json +108 -0
  66. package/src/theme/core/component/list-view.tokens.json +82 -0
  67. package/src/theme/core/component/listbox.tokens.json +283 -0
  68. package/src/theme/core/component/menu.tokens.json +230 -0
  69. package/src/theme/core/component/overflow.tokens.json +84 -0
  70. package/src/theme/core/component/page.tokens.json +377 -0
  71. package/src/theme/core/component/pagination.tokens.json +63 -0
  72. package/src/theme/core/component/popover.tokens.json +122 -0
  73. package/src/theme/core/component/progress-bar.tokens.json +133 -0
  74. package/src/theme/core/component/radio.tokens.json +631 -0
  75. package/src/theme/core/component/segmented-control.tokens.json +175 -0
  76. package/src/theme/core/component/select-card.tokens.json +943 -0
  77. package/src/theme/core/component/side-nav.tokens.json +349 -0
  78. package/src/theme/core/component/skeleton.tokens.json +42 -0
  79. package/src/theme/core/component/spinner.tokens.json +96 -0
  80. package/src/theme/core/component/status-icon.tokens.json +164 -0
  81. package/src/theme/core/component/stepper.tokens.json +484 -0
  82. package/src/theme/core/component/switch.tokens.json +285 -0
  83. package/src/theme/core/component/tab.tokens.json +192 -0
  84. package/src/theme/core/component/text-field.tokens.json +160 -0
  85. package/src/theme/core/component/text.tokens.json +59 -0
  86. package/src/theme/core/component/toast.tokens.json +343 -0
  87. package/src/theme/core/component/toolbar.tokens.json +114 -0
  88. package/src/theme/core/component/tooltip.tokens.json +61 -0
  89. package/src/theme/core/focus.tokens.json +56 -0
  90. package/src/theme/core/foreground.tokens.json +416 -0
  91. package/src/theme/core/gradient.tokens.json +41 -0
  92. package/src/theme/core/opacity.tokens.json +25 -0
  93. package/src/theme/core/shadow.tokens.json +81 -0
  94. package/src/theme/core/status.tokens.json +74 -0
  95. package/src/theme/core/typography.tokens.json +163 -0
  96. package/src/utils/__tests__/css-utils-format-utils.test.js +312 -0
  97. package/src/utils/__tests__/sd-build-configs.test.js +306 -0
  98. package/src/utils/__tests__/sd-formats.test.js +942 -0
  99. package/src/utils/__tests__/sd-transforms.test.js +336 -0
  100. package/src/utils/__tests__/token-helpers.test.js +1160 -0
  101. package/src/utils/copy-css-utils-cli.js +13 -1
  102. package/src/utils/css-utils-format-utils.js +105 -176
  103. package/src/utils/figma/__tests__/sync-gradient.test.js +561 -0
  104. package/src/utils/figma/__tests__/token-conversion.test.js +117 -0
  105. package/src/utils/figma/__tests__/token-resolution.test.js +231 -0
  106. package/src/utils/figma/auth.js +355 -0
  107. package/src/utils/figma/constants.js +22 -0
  108. package/src/utils/figma/errors.js +80 -0
  109. package/src/utils/figma/figma-api.js +1069 -0
  110. package/src/utils/figma/get-token.js +348 -0
  111. package/src/utils/figma/sync-components.js +909 -0
  112. package/src/utils/figma/sync-main.js +692 -0
  113. package/src/utils/figma/sync-orchestration.js +683 -0
  114. package/src/utils/figma/sync-primitives.js +230 -0
  115. package/src/utils/figma/sync-semantic.js +1056 -0
  116. package/src/utils/figma/token-conversion.js +340 -0
  117. package/src/utils/figma/token-parsing.js +186 -0
  118. package/src/utils/figma/token-resolution.js +569 -0
  119. package/src/utils/figma/utils.js +199 -0
  120. package/src/utils/sd-build-configs.js +305 -0
  121. package/src/utils/sd-formats.js +948 -0
  122. package/src/utils/sd-transforms.js +165 -0
  123. package/src/utils/token-helpers.js +848 -0
  124. package/tsconfig.json +18 -0
  125. package/vitest.config.js +17 -0
  126. package/.turbo/turbo-build.log +0 -37
  127. package/build/web/core/raw.js +0 -234
  128. package/src/global/primitive/breakpoint.js +0 -19
  129. package/src/global/primitive/color.js +0 -231
  130. package/src/global/primitive/duration.js +0 -16
  131. package/src/global/primitive/font.js +0 -60
  132. package/src/global/primitive/radius.js +0 -31
  133. package/src/global/primitive/size.js +0 -55
  134. package/src/global/primitive/transition.js +0 -16
  135. package/src/theme/core/background.js +0 -170
  136. package/src/theme/core/border.js +0 -103
  137. package/src/theme/core/charts.js +0 -464
  138. package/src/theme/core/component/button.js +0 -708
  139. package/src/theme/core/component/checkbox.js +0 -405
  140. package/src/theme/core/focus.js +0 -35
  141. package/src/theme/core/foreground.js +0 -148
  142. package/src/theme/core/overlay.js +0 -137
  143. package/src/theme/core/shadow.js +0 -29
  144. package/src/theme/core/status.js +0 -49
  145. package/src/theme/core/typography.js +0 -82
  146. package/type/types.ts +0 -344
@@ -0,0 +1,1069 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Figma API Utilities
5
+ *
6
+ * Functions for making requests to the Figma API and managing collections, modes, and variables.
7
+ */
8
+
9
+ const fs = require("fs");
10
+ const path = require("path");
11
+ const { getAccessToken } = require("./auth");
12
+
13
+ // Debug logging helper
14
+ const DEBUG_LOG_PATH = path.join(__dirname, "../../../../.cursor/debug.log");
15
+ function debugLog(location, message, data, hypothesisId) {
16
+ try {
17
+ const logEntry =
18
+ JSON.stringify({
19
+ location,
20
+ message,
21
+ data,
22
+ timestamp: Date.now(),
23
+ sessionId: "debug-session",
24
+ runId: "run1",
25
+ hypothesisId,
26
+ }) + "\n";
27
+ fs.appendFileSync(DEBUG_LOG_PATH, logEntry);
28
+ } catch (_e) {
29
+ // Silently fail if logging fails
30
+ }
31
+ }
32
+
33
+ // Configuration
34
+ const FIGMA_API_BASE = "https://api.figma.com/v1";
35
+
36
+ // Rate limit tracking
37
+ const rateLimitInfo = {
38
+ remaining: null,
39
+ resetAt: null,
40
+ };
41
+
42
+ /**
43
+ * Sleep for specified milliseconds
44
+ * @param {number} ms - Milliseconds to sleep
45
+ * @returns {Promise} Promise that resolves after the delay
46
+ */
47
+ function sleep(ms) {
48
+ return new Promise((resolve) => setTimeout(resolve, ms));
49
+ }
50
+
51
+ /**
52
+ * Make a request to the Figma API with retry logic
53
+ * @param {Object} config - Configuration object
54
+ * @param {string} endpoint - API endpoint
55
+ * @param {Object} options - Request options
56
+ * @param {number} retries - Number of retry attempts
57
+ * @returns {Promise<any>} API response data
58
+ */
59
+ async function figmaRequest(config, endpoint, options = {}, retries = 3) {
60
+ // Get access token (OAuth2)
61
+ const accessToken = await getAccessToken(config);
62
+
63
+ const url = `${FIGMA_API_BASE}/files/${config.fileKey}${endpoint}`;
64
+
65
+ // OAuth2 uses Bearer token
66
+ const headers = {
67
+ Authorization: `Bearer ${accessToken}`,
68
+ "Content-Type": "application/json",
69
+ ...options.headers,
70
+ };
71
+
72
+ for (let attempt = 0; attempt < retries; attempt++) {
73
+ try {
74
+ // Build request config, ensuring body is preserved
75
+ const requestConfig = {
76
+ method: options.method || "GET",
77
+ headers,
78
+ };
79
+
80
+ // Only include body for POST/PUT/PATCH requests
81
+ if (
82
+ options.body &&
83
+ (options.method === "POST" ||
84
+ options.method === "PUT" ||
85
+ options.method === "PATCH")
86
+ ) {
87
+ requestConfig.body = options.body;
88
+ }
89
+
90
+ // Include any other options (but don't override method, headers, or body)
91
+ for (const [key, value] of Object.entries(options)) {
92
+ if (key !== "method" && key !== "headers" && key !== "body") {
93
+ requestConfig[key] = value;
94
+ }
95
+ }
96
+
97
+ // Debug logging for POST /variables body parse failures only
98
+ if (
99
+ endpoint === "/variables" &&
100
+ requestConfig.method === "POST" &&
101
+ options.body
102
+ ) {
103
+ try {
104
+ if (typeof options.body === "string") {
105
+ JSON.parse(options.body);
106
+ }
107
+ } catch (e) {
108
+ debugLog(
109
+ "figma-api.js:figmaRequest",
110
+ "POST /variables body parse error",
111
+ {
112
+ error: e.message,
113
+ bodyPreview:
114
+ typeof options.body === "string"
115
+ ? options.body.substring(0, 500)
116
+ : String(options.body).substring(0, 500),
117
+ },
118
+ "B3",
119
+ );
120
+ }
121
+ }
122
+
123
+ const response = await fetch(url, requestConfig);
124
+
125
+ // Update rate limit info from headers
126
+ const rateLimitRemaining = response.headers.get("X-RateLimit-Remaining");
127
+ const rateLimitReset = response.headers.get("X-RateLimit-Reset");
128
+ if (rateLimitRemaining !== null) {
129
+ rateLimitInfo.remaining = parseInt(rateLimitRemaining, 10);
130
+ }
131
+ if (rateLimitReset !== null) {
132
+ rateLimitInfo.resetAt = parseInt(rateLimitReset, 10) * 1000; // Convert to ms
133
+ }
134
+
135
+ // Handle rate limiting (429)
136
+ if (response.status === 429) {
137
+ const retryAfter = response.headers.get("Retry-After");
138
+ let waitTime;
139
+
140
+ if (retryAfter) {
141
+ waitTime = parseInt(retryAfter, 10) * 1000;
142
+ } else if (rateLimitInfo.resetAt) {
143
+ waitTime = Math.max(0, rateLimitInfo.resetAt - Date.now());
144
+ } else {
145
+ waitTime = Math.pow(2, attempt) * 1000; // Exponential backoff with jitter
146
+ }
147
+
148
+ if (attempt < retries - 1) {
149
+ console.warn(
150
+ ` ⚠️ Rate limited. Waiting ${Math.ceil(waitTime / 1000)}s before retry...`,
151
+ );
152
+ await sleep(waitTime);
153
+ continue;
154
+ }
155
+ }
156
+
157
+ // Check rate limit before making request (preventive)
158
+ if (rateLimitInfo.remaining === 0 && rateLimitInfo.resetAt) {
159
+ const waitTime = Math.max(0, rateLimitInfo.resetAt - Date.now());
160
+ if (waitTime > 0) {
161
+ console.warn(
162
+ ` ⚠️ Rate limit reached. Waiting ${Math.ceil(waitTime / 1000)}s...`,
163
+ );
164
+ await sleep(waitTime);
165
+ }
166
+ }
167
+
168
+ if (!response.ok) {
169
+ const errorText = await response.text();
170
+ debugLog(
171
+ "figma-api.js:figmaRequest",
172
+ "API error response",
173
+ {
174
+ status: response.status,
175
+ statusText: response.statusText,
176
+ errorText: errorText?.substring(0, 200),
177
+ url,
178
+ fileKey: config.fileKey,
179
+ fileKeyLength: config.fileKey?.length,
180
+ },
181
+ "C",
182
+ );
183
+
184
+ let errorMessage = `Figma API error (${response.status}): ${errorText || response.statusText}`;
185
+
186
+ // Provide helpful error messages
187
+ if (response.status === 401) {
188
+ errorMessage =
189
+ "Authentication failed. Please check your OAuth2 credentials (FIGMA_CLIENT_ID, FIGMA_CLIENT_SECRET, FIGMA_REFRESH_TOKEN).";
190
+ } else if (response.status === 403) {
191
+ // Try to parse error for more details
192
+ let details = "";
193
+ try {
194
+ const errorJson = JSON.parse(errorText);
195
+ if (errorJson.err) {
196
+ details = ` (${errorJson.err})`;
197
+ } else if (errorJson.message) {
198
+ details = ` (${errorJson.message})`;
199
+ }
200
+ } catch {
201
+ if (errorText && errorText.length < 200) {
202
+ details = ` (${errorText})`;
203
+ }
204
+ }
205
+
206
+ errorMessage =
207
+ `Access forbidden (403). Please check file permissions and token access.${details}\n\n` +
208
+ `This usually means:\n` +
209
+ ` 1. Your OAuth2 token doesn't have permission to access this file\n` +
210
+ ` 2. The file is in a team/org that requires organization-level access\n` +
211
+ ` 3. The token was created by a different account\n` +
212
+ ` 4. The token doesn't have the required scopes\n\n` +
213
+ `To fix:\n` +
214
+ ` - Verify you can open the file in Figma: https://www.figma.com/design/${config.fileKey}/...\n` +
215
+ ` - Verify your OAuth2 app has the required scopes\n` +
216
+ ` - Check that the refresh token hasn't expired (re-authenticate if needed)\n`;
217
+ } else if (response.status === 404) {
218
+ // Try to parse error for more details
219
+ let details = "";
220
+ try {
221
+ const errorJson = JSON.parse(errorText);
222
+ if (errorJson.err) {
223
+ details = ` (${errorJson.err})`;
224
+ }
225
+ } catch {
226
+ // Not JSON, use raw text
227
+ if (errorText) {
228
+ details = ` (${errorText})`;
229
+ }
230
+ }
231
+ debugLog(
232
+ "figma-api.js:figmaRequest",
233
+ "404 error details",
234
+ {
235
+ fileKey: config.fileKey,
236
+ fileKeyLength: config.fileKey?.length,
237
+ details,
238
+ errorText: errorText?.substring(0, 200),
239
+ url,
240
+ errorMessageBefore: errorMessage,
241
+ },
242
+ "D",
243
+ );
244
+ errorMessage = `File not found. Please check the file key: ${config.fileKey}${details}`;
245
+ }
246
+
247
+ // In debug mode, show full error
248
+ if (process.env.DEBUG) {
249
+ console.error(`Full API response:`, {
250
+ status: response.status,
251
+ statusText: response.statusText,
252
+ body: errorText,
253
+ url: url,
254
+ });
255
+ }
256
+
257
+ throw new Error(errorMessage);
258
+ }
259
+
260
+ return response.json();
261
+ } catch (error) {
262
+ debugLog(
263
+ "figma-api.js:figmaRequest",
264
+ "figmaRequest catch block",
265
+ {
266
+ attempt,
267
+ retries,
268
+ errorMessage: error.message,
269
+ errorMessageLength: error.message?.length,
270
+ fileKey: config.fileKey,
271
+ isLastAttempt: attempt === retries - 1,
272
+ },
273
+ "E",
274
+ );
275
+
276
+ // If it's the last attempt or not a network error, throw
277
+ if (
278
+ attempt === retries - 1 ||
279
+ (error.message && !error.message.includes("fetch"))
280
+ ) {
281
+ throw error;
282
+ }
283
+
284
+ // Wait before retrying network errors
285
+ await sleep(Math.pow(2, attempt) * 1000);
286
+ }
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Verify file access before attempting to sync
292
+ * @param {Object} config - Configuration object
293
+ * @returns {Promise<boolean>} True if access is granted
294
+ */
295
+ async function verifyFileAccess(config) {
296
+ try {
297
+ // Try to get file metadata - this will fail if we don't have access.
298
+ // Use ?depth=1 to avoid "Request too large" on large files (Figma requires depth > 0; depth=1 returns only Pages).
299
+ const fileData = await figmaRequest(config, "?depth=1");
300
+
301
+ if (process.env.DEBUG) {
302
+ console.log(` File name: ${fileData.name || "Unknown"}`);
303
+ console.log(` File key: ${config.fileKey}`);
304
+ }
305
+
306
+ return true;
307
+ } catch (error) {
308
+ if (error.message.includes("404") || error.message.includes("Not found")) {
309
+ // Check if it's an authentication issue vs access issue
310
+ const isAuthError =
311
+ error.message.includes("401") ||
312
+ error.message.includes("Authentication");
313
+
314
+ if (isAuthError) {
315
+ throw new Error(
316
+ `Authentication failed for file ${config.fileKey}.\n\n` +
317
+ `Please verify:\n` +
318
+ ` 1. Your OAuth2 credentials are correct (FIGMA_CLIENT_ID, FIGMA_CLIENT_SECRET, FIGMA_REFRESH_TOKEN)\n` +
319
+ ` 2. The refresh token hasn't expired (re-authenticate if needed)\n` +
320
+ ` 3. Your OAuth2 app has the required scopes`,
321
+ );
322
+ }
323
+
324
+ throw new Error(
325
+ `Cannot access file ${config.fileKey}.\n\n` +
326
+ `This file appears to be in a team or organization. Please verify:\n` +
327
+ ` 1. Your OAuth2 app was created by an account that has access to this file\n` +
328
+ ` 2. You are a member of the team/organization that owns this file\n` +
329
+ ` 3. Your OAuth2 app has organization-level access if the file is in an organization\n` +
330
+ ` 4. For Enterprise organizations, API access may be restricted - contact your admin\n\n` +
331
+ `To fix:\n` +
332
+ ` - Log into Figma and verify you can open this file: https://www.figma.com/design/${config.fileKey}/...\n` +
333
+ ` - Verify your OAuth2 app has the required scopes\n` +
334
+ ` - Re-authenticate to get a new refresh token if needed`,
335
+ );
336
+ }
337
+ throw error;
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Get or create an extended variable collection
343
+ * @param {Object} config - Configuration object
344
+ * @param {string} parentCollectionId - ID of the parent collection to extend
345
+ * @param {string} extendedCollectionName - Name for the extended collection
346
+ * @returns {Promise<Object>} Extended collection object
347
+ */
348
+ async function getOrCreateExtendedCollection(
349
+ config,
350
+ parentCollectionId,
351
+ extendedCollectionName,
352
+ ) {
353
+ // Get all local variables
354
+ const variablesData = await figmaRequest(config, "/variables/local");
355
+ const collections = variablesData.meta?.variableCollections
356
+ ? Object.values(variablesData.meta.variableCollections)
357
+ : [];
358
+
359
+ // Find existing extended collection
360
+ const existingCollection = collections.find(
361
+ (coll) =>
362
+ coll.name === extendedCollectionName &&
363
+ coll.parentVariableCollectionId === parentCollectionId,
364
+ );
365
+
366
+ if (existingCollection) {
367
+ return existingCollection;
368
+ }
369
+
370
+ // Create new extended collection
371
+ const tempCollectionId = "temp_extended_collection_" + Date.now();
372
+ const response = await figmaRequest(config, "/variables", {
373
+ method: "POST",
374
+ body: JSON.stringify({
375
+ variableCollections: [
376
+ {
377
+ action: "CREATE",
378
+ id: tempCollectionId,
379
+ name: extendedCollectionName,
380
+ parentVariableCollectionId: parentCollectionId,
381
+ isExtension: true,
382
+ },
383
+ ],
384
+ }),
385
+ });
386
+
387
+ const realCollectionId = response.meta?.tempIdToRealId?.[tempCollectionId];
388
+ if (!realCollectionId) {
389
+ throw new Error("Failed to create extended collection: no ID returned");
390
+ }
391
+
392
+ // Fetch the newly created collection to get full details
393
+ const updatedVariablesData = await figmaRequest(config, "/variables/local");
394
+ const updatedCollections = updatedVariablesData.meta?.variableCollections
395
+ ? Object.values(updatedVariablesData.meta.variableCollections)
396
+ : [];
397
+ const newCollection = updatedCollections.find(
398
+ (coll) => coll.id === realCollectionId,
399
+ );
400
+
401
+ if (!newCollection) {
402
+ throw new Error(
403
+ `Failed to retrieve newly created extended collection with ID: ${realCollectionId}`,
404
+ );
405
+ }
406
+
407
+ return newCollection;
408
+ }
409
+
410
+ /**
411
+ * Get or create variable collection
412
+ * @param {Object} config - Configuration object
413
+ * @returns {Promise<Object>} Collection object
414
+ */
415
+ async function getOrCreateCollection(config) {
416
+ // Get all local variables
417
+ // This will fail with 404 if file doesn't exist or token doesn't have access
418
+ try {
419
+ const variablesData = await figmaRequest(config, "/variables/local");
420
+
421
+ // variableCollections is a dictionary (object) keyed by collection ID, convert to array
422
+ const collections = variablesData.meta?.variableCollections
423
+ ? Object.values(variablesData.meta.variableCollections)
424
+ : [];
425
+
426
+ // Find existing collection
427
+ const existingCollection = collections.find(
428
+ (coll) => coll.name === config.collectionName,
429
+ );
430
+
431
+ if (existingCollection) {
432
+ return existingCollection;
433
+ }
434
+
435
+ // Create new collection using the bulk operations endpoint
436
+ const tempCollectionId = "temp_collection_" + Date.now();
437
+ const response = await figmaRequest(config, "/variables", {
438
+ method: "POST",
439
+ body: JSON.stringify({
440
+ variableCollections: [
441
+ {
442
+ action: "CREATE",
443
+ id: tempCollectionId,
444
+ name: config.collectionName,
445
+ },
446
+ ],
447
+ }),
448
+ });
449
+
450
+ // Get the real collection ID from the response
451
+ const realCollectionId = response.meta?.tempIdToRealId?.[tempCollectionId];
452
+ if (!realCollectionId) {
453
+ throw new Error("Failed to create collection: no ID returned");
454
+ }
455
+
456
+ // Fetch the newly created collection to get full details
457
+ const updatedVariablesData = await figmaRequest(config, "/variables/local");
458
+ const updatedCollections = updatedVariablesData.meta?.variableCollections
459
+ ? Object.values(updatedVariablesData.meta.variableCollections)
460
+ : [];
461
+ const newCollection = updatedCollections.find(
462
+ (coll) => coll.id === realCollectionId,
463
+ );
464
+
465
+ if (!newCollection) {
466
+ throw new Error(
467
+ `Failed to retrieve newly created collection with ID: ${realCollectionId}`,
468
+ );
469
+ }
470
+
471
+ return newCollection;
472
+ } catch (error) {
473
+ debugLog(
474
+ "figma-api.js:getOrCreateCollection",
475
+ "getOrCreateCollection error",
476
+ { errorMessage: error.message, fileKey: config.fileKey },
477
+ "F6",
478
+ );
479
+ throw error;
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Get or create variable modes (Light/Dark)
485
+ * @param {Object} config - Configuration object
486
+ * @param {string} collectionId - Collection ID
487
+ * @returns {Promise<Object>} Object with light and dark mode objects
488
+ */
489
+ async function getOrCreateModes(config, collectionId) {
490
+ // Get collection details to see existing modes
491
+ const variablesData = await figmaRequest(config, "/variables/local");
492
+
493
+ // variableCollections is a dictionary (object) keyed by collection ID
494
+ const collections = variablesData.meta?.variableCollections || {};
495
+ const collection = collections[collectionId];
496
+
497
+ const modes = collection?.modes || [];
498
+ const lightMode = modes.find((m) => m.name === config.lightModeName);
499
+ const darkMode = modes.find((m) => m.name === config.darkModeName);
500
+
501
+ const createdModes = {};
502
+
503
+ // Handle Light mode: if it doesn't exist, check if there's a default mode to rename
504
+ if (!lightMode) {
505
+ // Check if there's a default mode (usually "Mode 1" or similar default name)
506
+ // When Figma creates a collection, it automatically creates a default mode
507
+ const defaultMode = modes.find(
508
+ (m) =>
509
+ m.name === "Mode 1" ||
510
+ m.name === "Default" ||
511
+ (modes.length === 1 && m.name !== config.darkModeName),
512
+ );
513
+
514
+ if (defaultMode && defaultMode.modeId) {
515
+ // Rename the default mode to "Light" instead of creating a new one
516
+ await figmaRequest(config, "/variables", {
517
+ method: "POST",
518
+ body: JSON.stringify({
519
+ variableModes: [
520
+ {
521
+ action: "UPDATE",
522
+ id: defaultMode.modeId,
523
+ name: config.lightModeName,
524
+ variableCollectionId: collectionId,
525
+ },
526
+ ],
527
+ }),
528
+ });
529
+
530
+ // Fetch the updated collection to get the renamed mode
531
+ const updatedVariablesData = await figmaRequest(
532
+ config,
533
+ "/variables/local",
534
+ );
535
+ const updatedCollections =
536
+ updatedVariablesData.meta?.variableCollections || {};
537
+ const updatedCollection = updatedCollections[collectionId];
538
+ const renamedLightMode = updatedCollection?.modes?.find(
539
+ (m) => m.modeId === defaultMode.modeId,
540
+ );
541
+
542
+ if (!renamedLightMode) {
543
+ throw new Error(
544
+ `Failed to retrieve renamed light mode with ID: ${defaultMode.modeId}`,
545
+ );
546
+ }
547
+
548
+ // Map modeId to id for backward compatibility with existing code
549
+ createdModes.light = {
550
+ ...renamedLightMode,
551
+ id: renamedLightMode.id || renamedLightMode.modeId,
552
+ };
553
+ } else {
554
+ // No default mode found, create a new Light mode
555
+ const tempModeId = "temp_light_mode_" + Date.now();
556
+ const response = await figmaRequest(config, "/variables", {
557
+ method: "POST",
558
+ body: JSON.stringify({
559
+ variableModes: [
560
+ {
561
+ action: "CREATE",
562
+ id: tempModeId,
563
+ name: config.lightModeName,
564
+ variableCollectionId: collectionId,
565
+ },
566
+ ],
567
+ }),
568
+ });
569
+
570
+ const realModeId = response.meta?.tempIdToRealId?.[tempModeId];
571
+ if (!realModeId) {
572
+ throw new Error("Failed to create light mode: no ID returned");
573
+ }
574
+
575
+ // Fetch the updated collection to get the new mode
576
+ const updatedVariablesData = await figmaRequest(
577
+ config,
578
+ "/variables/local",
579
+ );
580
+ const updatedCollections =
581
+ updatedVariablesData.meta?.variableCollections || {};
582
+ const updatedCollection = updatedCollections[collectionId];
583
+ const newLightMode = updatedCollection?.modes?.find(
584
+ (m) => m.modeId === realModeId,
585
+ );
586
+
587
+ if (!newLightMode) {
588
+ throw new Error(
589
+ `Failed to retrieve newly created light mode with ID: ${realModeId}`,
590
+ );
591
+ }
592
+
593
+ // Map modeId to id for backward compatibility with existing code
594
+ createdModes.light = {
595
+ ...newLightMode,
596
+ id: newLightMode.id || newLightMode.modeId,
597
+ };
598
+ }
599
+ } else {
600
+ // Map modeId to id for backward compatibility with existing code
601
+ createdModes.light = { ...lightMode, id: lightMode.id || lightMode.modeId };
602
+ }
603
+
604
+ // Create Dark mode if it doesn't exist
605
+ if (!darkMode) {
606
+ const tempModeId = "temp_dark_mode_" + Date.now();
607
+ const response = await figmaRequest(config, "/variables", {
608
+ method: "POST",
609
+ body: JSON.stringify({
610
+ variableModes: [
611
+ {
612
+ action: "CREATE",
613
+ id: tempModeId,
614
+ name: config.darkModeName,
615
+ variableCollectionId: collectionId,
616
+ },
617
+ ],
618
+ }),
619
+ });
620
+
621
+ const realModeId = response.meta?.tempIdToRealId?.[tempModeId];
622
+ if (!realModeId) {
623
+ throw new Error("Failed to create dark mode: no ID returned");
624
+ }
625
+
626
+ // Fetch the updated collection to get the new mode
627
+ const updatedVariablesData = await figmaRequest(config, "/variables/local");
628
+ const updatedCollections =
629
+ updatedVariablesData.meta?.variableCollections || {};
630
+ const updatedCollection = updatedCollections[collectionId];
631
+ const newDarkMode = updatedCollection?.modes?.find(
632
+ (m) => m.modeId === realModeId,
633
+ );
634
+
635
+ if (!newDarkMode) {
636
+ throw new Error(
637
+ `Failed to retrieve newly created dark mode with ID: ${realModeId}`,
638
+ );
639
+ }
640
+
641
+ // Map modeId to id for backward compatibility with existing code
642
+ createdModes.dark = {
643
+ ...newDarkMode,
644
+ id: newDarkMode.id || newDarkMode.modeId,
645
+ };
646
+ } else {
647
+ // Map modeId to id for backward compatibility with existing code
648
+ createdModes.dark = { ...darkMode, id: darkMode.id || darkMode.modeId };
649
+ }
650
+
651
+ // Clean up any unwanted modes (e.g., default "mode 1" created by Figma)
652
+ // Fetch the collection again to get all current modes
653
+ const finalVariablesData = await figmaRequest(config, "/variables/local");
654
+ const finalCollections = finalVariablesData.meta?.variableCollections || {};
655
+ const finalCollection = finalCollections[collectionId];
656
+ const allModes = finalCollection?.modes || [];
657
+
658
+ // Find modes that aren't Light or Dark
659
+ const unwantedModes = allModes.filter(
660
+ (m) => m.name !== config.lightModeName && m.name !== config.darkModeName,
661
+ );
662
+
663
+ // Delete unwanted modes (only if they have valid modeIds)
664
+ // Note: mode.id might be a node ID (like "5:0") which is not valid for variable mode operations
665
+ // We must use mode.modeId for variable mode operations
666
+ if (unwantedModes.length > 0) {
667
+ const modeDeletions = unwantedModes
668
+ .map((mode) => {
669
+ const modeId = mode.modeId; // Only use modeId, not mode.id (which might be a node ID)
670
+ // Skip if modeId is missing or invalid
671
+ // Note: Figma mode IDs are typically in "X:Y" format (e.g., "19:0"), which is valid for deletion
672
+ if (!modeId) {
673
+ return null;
674
+ }
675
+ return { action: "DELETE", id: modeId };
676
+ })
677
+ .filter((deletion) => deletion !== null);
678
+
679
+ if (modeDeletions.length > 0) {
680
+ try {
681
+ await figmaRequest(config, "/variables", {
682
+ method: "POST",
683
+ body: JSON.stringify({
684
+ variableModes: modeDeletions,
685
+ }),
686
+ });
687
+
688
+ console.log(
689
+ ` 🗑️ Removed ${modeDeletions.length} unwanted mode(s): ${unwantedModes.map((m) => m.name).join(", ")}`,
690
+ );
691
+ } catch (error) {
692
+ // Log but don't fail the entire sync if mode deletion fails
693
+ console.warn(
694
+ ` ⚠️ Failed to remove unwanted modes: ${error.message}`,
695
+ );
696
+ if (process.env.DEBUG) {
697
+ console.warn(
698
+ ` Unwanted modes: ${JSON.stringify(unwantedModes, null, 2)}`,
699
+ );
700
+ }
701
+ }
702
+ } else {
703
+ console.warn(
704
+ ` ⚠️ Found ${unwantedModes.length} unwanted mode(s) but none have valid IDs to delete`,
705
+ );
706
+ }
707
+ }
708
+
709
+ return createdModes;
710
+ }
711
+
712
+ /**
713
+ * Create or update a variable (DEPRECATED - use buildBulkVariableRequest instead)
714
+ * @param {Object} config - Configuration object
715
+ * @param {string} collectionId - Collection ID
716
+ * @param {Object} modes - Modes object
717
+ * @param {string} variableName - Variable name
718
+ * @param {string} variableType - Variable type
719
+ * @param {any} lightValue - Light mode value
720
+ * @param {any} darkValue - Dark mode value
721
+ * @param {Map} resolvedTokens - Resolved tokens map
722
+ * @param {Array} existingVariables - Existing variables array
723
+ * @returns {Promise<Object>} Result object with action and variable
724
+ */
725
+ async function createOrUpdateVariable(
726
+ config,
727
+ collectionId,
728
+ modes,
729
+ variableName,
730
+ variableType,
731
+ lightValue,
732
+ darkValue,
733
+ resolvedTokens,
734
+ existingVariables,
735
+ ) {
736
+ // This function is deprecated but kept for backward compatibility
737
+ // It uses functions from other modules that need to be imported
738
+ const {
739
+ isReference,
740
+ extractReferencePath,
741
+ resolveReferencePath,
742
+ } = require("./token-resolution");
743
+ const { convertTokenValueToFigma } = require("./token-conversion");
744
+
745
+ // Check if this should be an alias (for semantic/component tokens)
746
+ const isLightReference = isReference(lightValue);
747
+ const isDarkReference = isReference(darkValue);
748
+
749
+ let lightAliasId = null;
750
+ let darkAliasId = null;
751
+
752
+ // Resolve alias targets (only for non-primitive tokens)
753
+ const tokenData = resolvedTokens.get(variableName);
754
+ const isPrimitive = tokenData?.isPrimitive ?? false;
755
+
756
+ if (!isPrimitive && isLightReference) {
757
+ const refPath = extractReferencePath(lightValue);
758
+ const targetPath = resolveReferencePath(refPath, resolvedTokens);
759
+ // Use the local getVariableIdByName function
760
+ const variable = existingVariables.find(
761
+ (v) => v.name === targetPath || v.key === targetPath,
762
+ );
763
+ lightAliasId = variable?.id || null;
764
+ }
765
+
766
+ if (!isPrimitive && isDarkReference) {
767
+ const refPath = extractReferencePath(darkValue);
768
+ const targetPath = resolveReferencePath(refPath, resolvedTokens);
769
+ // Use the local getVariableIdByName function
770
+ const variable = existingVariables.find(
771
+ (v) => v.name === targetPath || v.key === targetPath,
772
+ );
773
+ darkAliasId = variable?.id || null;
774
+ }
775
+
776
+ // Get existing variable
777
+ const existing = existingVariables.find(
778
+ (v) => v.name === variableName || v.key === variableName,
779
+ );
780
+
781
+ // Convert values for comparison/creation
782
+ let lightFigmaValue;
783
+ let darkFigmaValue;
784
+
785
+ const tokenScopes = tokenData?.token?.$extensions?.["com.figma.scopes"] ?? [];
786
+
787
+ if (lightAliasId) {
788
+ lightFigmaValue = { type: "VARIABLE_ALIAS", id: lightAliasId };
789
+ } else {
790
+ const converted = convertTokenValueToFigma(variableType, lightValue, {
791
+ variableName,
792
+ scopes: tokenScopes,
793
+ });
794
+ if (converted === null) {
795
+ throw new Error(`Could not convert light value for ${variableName}`);
796
+ }
797
+ lightFigmaValue = converted;
798
+ }
799
+
800
+ if (darkAliasId) {
801
+ darkFigmaValue = { type: "VARIABLE_ALIAS", id: darkAliasId };
802
+ } else {
803
+ const converted = convertTokenValueToFigma(variableType, darkValue, {
804
+ variableName,
805
+ scopes: tokenScopes,
806
+ });
807
+ if (converted === null) {
808
+ throw new Error(`Could not convert dark value for ${variableName}`);
809
+ }
810
+ darkFigmaValue = converted;
811
+ }
812
+
813
+ if (existing) {
814
+ // Check if values are the same (by actual value; normalizes Figma vs our color shape and alias key order)
815
+ const existingValues = existing.valuesByMode || {};
816
+ const existingLight = existingValues[modes.light.id];
817
+ const existingDark = existingValues[modes.dark.id];
818
+
819
+ const lightSame = valuesEqual(existingLight, lightFigmaValue);
820
+ const darkSame = valuesEqual(existingDark, darkFigmaValue);
821
+
822
+ if (lightSame && darkSame) {
823
+ return { action: "skipped", variable: existing };
824
+ }
825
+
826
+ // Update variable
827
+ await figmaRequest(config, `/variables/${existing.id}`, {
828
+ method: "PUT",
829
+ body: JSON.stringify({
830
+ name: variableName,
831
+ valuesByMode: {
832
+ [modes.light.id]: lightFigmaValue,
833
+ [modes.dark.id]: darkFigmaValue,
834
+ },
835
+ }),
836
+ });
837
+
838
+ return { action: "updated", variable: existing };
839
+ } else {
840
+ // Create new variable
841
+ const response = await figmaRequest(config, "/variables", {
842
+ method: "POST",
843
+ body: JSON.stringify({
844
+ name: variableName,
845
+ variableCollectionId: collectionId,
846
+ resolvedType: (() => {
847
+ switch (variableType) {
848
+ case "color":
849
+ return "COLOR";
850
+ case "dimension":
851
+ case "duration":
852
+ case "number":
853
+ return "FLOAT";
854
+ case "string":
855
+ return "STRING";
856
+ default:
857
+ return "STRING";
858
+ }
859
+ })(),
860
+ valuesByMode: {
861
+ [modes.light.id]: lightFigmaValue,
862
+ [modes.dark.id]: darkFigmaValue,
863
+ },
864
+ }),
865
+ });
866
+
867
+ const newVariable = response.meta.variable;
868
+ // Add to cache for future alias resolution
869
+ existingVariables.push(newVariable);
870
+
871
+ return { action: "created", variable: newVariable };
872
+ }
873
+ }
874
+
875
+ /**
876
+ * Normalize a color value to { r, g, b, a } for comparison.
877
+ * Accepts our format { r, g, b, a } or Figma's format { components, alpha, hex }.
878
+ * @param {any} val - Color object
879
+ * @returns {{ r: number, g: number, b: number, a: number } | null} Normalized RGBA or null if not a color
880
+ */
881
+ function normalizeColorForCompare(val) {
882
+ if (!val || typeof val !== "object") return null;
883
+ // Our format: { r, g, b, a }
884
+ if (val.r !== undefined && val.g !== undefined && val.b !== undefined) {
885
+ return {
886
+ r: Number(val.r),
887
+ g: Number(val.g),
888
+ b: Number(val.b),
889
+ a: Number(val.a ?? 1),
890
+ };
891
+ }
892
+ // Figma format: { components: [r,g,b], alpha, hex }
893
+ if (Array.isArray(val.components) && val.components.length >= 3) {
894
+ return {
895
+ r: Number(val.components[0]),
896
+ g: Number(val.components[1]),
897
+ b: Number(val.components[2]),
898
+ a: Number(val.alpha ?? 1),
899
+ };
900
+ }
901
+ return null;
902
+ }
903
+
904
+ /**
905
+ * Compare two Figma variable values by actual value, not object structure.
906
+ * Colors: normalizes both (our { r,g,b,a } or Figma's { components, alpha, hex }) and compares r,g,b,a with tolerance.
907
+ * Aliases: compares type and id so key order does not matter.
908
+ * @param {any} val1 - First value (e.g. from Figma API)
909
+ * @param {any} val2 - Second value (e.g. our computed value)
910
+ * @returns {boolean} True if values are equal
911
+ */
912
+ function valuesEqual(val1, val2) {
913
+ if (typeof val1 !== typeof val2) return false;
914
+
915
+ if (typeof val1 === "object" && val1 !== null && val2 !== null) {
916
+ const norm1 = normalizeColorForCompare(val1);
917
+ const norm2 = normalizeColorForCompare(val2);
918
+ if (norm1 !== null && norm2 !== null) {
919
+ return (
920
+ Math.abs(norm1.r - norm2.r) < 0.001 &&
921
+ Math.abs(norm1.g - norm2.g) < 0.001 &&
922
+ Math.abs(norm1.b - norm2.b) < 0.001 &&
923
+ Math.abs(norm1.a - norm2.a) < 0.001
924
+ );
925
+ }
926
+ // VARIABLE_ALIAS: compare by type and id only (ignore key order)
927
+ if (
928
+ val1.type === "VARIABLE_ALIAS" &&
929
+ val2.type === "VARIABLE_ALIAS" &&
930
+ val1.id !== undefined &&
931
+ val2.id !== undefined
932
+ ) {
933
+ return String(val1.id) === String(val2.id);
934
+ }
935
+ // Other objects (e.g. dimensions): structural comparison
936
+ return JSON.stringify(val1) === JSON.stringify(val2);
937
+ }
938
+
939
+ // Numbers: compare with tolerance so float precision (e.g. 0.6 vs 0.6000000238418579) doesn't force updates
940
+ // Figma FLOAT can differ by ~2e-8; use 1e-6 so opacity/dimension values match
941
+ const numericTolerance = 1e-6;
942
+ if (typeof val1 === "number" && typeof val2 === "number") {
943
+ return Math.abs(val1 - val2) < numericTolerance;
944
+ }
945
+
946
+ // Figma may return FLOAT as string (e.g. "0.6") while we send number — treat as equal if numeric
947
+ const num1 = typeof val1 === "number" ? val1 : parseFloat(val1);
948
+ const num2 = typeof val2 === "number" ? val2 : parseFloat(val2);
949
+ if (!Number.isNaN(num1) && !Number.isNaN(num2)) {
950
+ return Math.abs(num1 - num2) < numericTolerance;
951
+ }
952
+
953
+ return val1 === val2;
954
+ }
955
+
956
+ /**
957
+ * Get variable ID by name from existing variables cache
958
+ * Optimized: Use Map for O(1) lookup instead of array search
959
+ * @param {string} variableName - Variable name to find
960
+ * @param {Array} existingVariables - Array of existing variables
961
+ * @returns {string|null} Variable ID or null if not found
962
+ */
963
+ function getVariableIdByName(variableName, existingVariables) {
964
+ // Optimize: Create a Map for O(1) lookups if we have many variables
965
+ // For small arrays, linear search is fine, but for large arrays, Map is better
966
+ if (existingVariables.length > 100) {
967
+ // Create a Map for efficient lookups
968
+ const variableMap = new Map();
969
+ for (const v of existingVariables) {
970
+ if (v.name) variableMap.set(v.name, v.id);
971
+ if (v.key && v.key !== v.name) variableMap.set(v.key, v.id);
972
+ }
973
+ return variableMap.get(variableName) || null;
974
+ }
975
+
976
+ // For smaller arrays, use linear search (simpler and faster for small N)
977
+ const variable = existingVariables.find(
978
+ (v) => v.name === variableName || v.key === variableName,
979
+ );
980
+ return variable?.id || null;
981
+ }
982
+
983
+ /**
984
+ * Determine the variable name for a token path by checking token data and existing variables
985
+ * @param {string} targetPath - Token path (e.g., "color/neutral/0")
986
+ * @param {Map} resolvedTokensForRefs - Map of resolved tokens for reference resolution
987
+ * @param {Array} existingVariables - Array of existing Figma variables
988
+ * @param {Map} tempIdMap - Temporary ID map for tracking created variables
989
+ * @param {Function} isComponentToken - Function to check if a token is a component token
990
+ * @returns {string} Variable name with correct prefix (primitive/, semantic/, or component/)
991
+ */
992
+ function determineVariableName(
993
+ targetPath,
994
+ resolvedTokensForRefs,
995
+ existingVariables,
996
+ tempIdMap,
997
+ isComponentToken,
998
+ ) {
999
+ // First, check if token exists in resolvedTokensForRefs
1000
+ const targetTokenData = resolvedTokensForRefs.get(targetPath);
1001
+ if (targetTokenData) {
1002
+ if (targetTokenData.isPrimitive) {
1003
+ return `primitive/${targetPath}`;
1004
+ }
1005
+ if (isComponentToken && isComponentToken(targetTokenData.sourceFilePath)) {
1006
+ return `component/${targetPath}`;
1007
+ }
1008
+ return `semantic/${targetPath}`;
1009
+ }
1010
+
1011
+ // Token not in registry - check existing variables to determine type
1012
+ // Check in order: primitive, component, semantic (most common fallback)
1013
+ const primitiveVarName = `primitive/${targetPath}`;
1014
+ const componentVarName = `component/${targetPath}`;
1015
+ const semanticVarName = `semantic/${targetPath}`;
1016
+ const primitiveId = getVariableIdByName(primitiveVarName, existingVariables);
1017
+ const componentId = getVariableIdByName(componentVarName, existingVariables);
1018
+ const primitiveInTemp = tempIdMap.has(primitiveVarName);
1019
+ const componentInTemp = tempIdMap.has(componentVarName);
1020
+ if (primitiveId || primitiveInTemp) {
1021
+ return primitiveVarName;
1022
+ }
1023
+ if (componentId || componentInTemp) {
1024
+ return componentVarName;
1025
+ }
1026
+ // Default to semantic if not found (backward compatibility)
1027
+ return semanticVarName;
1028
+ }
1029
+
1030
+ /**
1031
+ * Compare two token values to see if they differ
1032
+ * @param {any} value1 - First value
1033
+ * @param {any} value2 - Second value
1034
+ * @returns {boolean} True if values are different
1035
+ */
1036
+ function tokenValuesDiffer(value1, value2) {
1037
+ if (value1 === value2) return false;
1038
+ if (value1 == null || value2 == null) return true;
1039
+
1040
+ // Deep comparison for objects
1041
+ if (typeof value1 === "object" && typeof value2 === "object") {
1042
+ const keys1 = Object.keys(value1).sort();
1043
+ const keys2 = Object.keys(value2).sort();
1044
+ if (keys1.length !== keys2.length) return true;
1045
+
1046
+ for (const key of keys1) {
1047
+ if (!keys2.includes(key)) return true;
1048
+ if (tokenValuesDiffer(value1[key], value2[key])) return true;
1049
+ }
1050
+ return false;
1051
+ }
1052
+
1053
+ return true;
1054
+ }
1055
+
1056
+ module.exports = {
1057
+ figmaRequest,
1058
+ verifyFileAccess,
1059
+ getOrCreateCollection,
1060
+ getOrCreateExtendedCollection,
1061
+ getOrCreateModes,
1062
+ createOrUpdateVariable,
1063
+ valuesEqual,
1064
+ getVariableIdByName,
1065
+ determineVariableName,
1066
+ tokenValuesDiffer,
1067
+ debugLog,
1068
+ sleep,
1069
+ };