@stratixlabs/core 1.7.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.
@@ -0,0 +1,738 @@
1
+ const https = require('https');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ function ensurePathWithinRoot(resolvedPath, rootDir) {
6
+ const resolved = path.resolve(resolvedPath);
7
+ const root = path.resolve(rootDir);
8
+ const normalize = s => s.replace(/\\/g, '/').toLowerCase();
9
+ const resolvedNorm = normalize(resolved);
10
+ const rootNorm = normalize(root);
11
+ if (resolvedNorm !== rootNorm && !resolvedNorm.startsWith(rootNorm + '/')) {
12
+ throw new Error(`Configured path ${resolved} is outside of project root ${root}`);
13
+ }
14
+ }
15
+
16
+ function getRequiredEnv(name) {
17
+ const value = process.env[name];
18
+ if (!value) {
19
+ console.error(`❌ Missing required environment variable: ${name}`);
20
+ process.exit(1);
21
+ }
22
+ return value;
23
+ }
24
+
25
+ function fetchJson(url, token, options) {
26
+ return new Promise((resolve, reject) => {
27
+ const method = options && options.method ? options.method : 'GET';
28
+ const body = options && options.body ? JSON.stringify(options.body) : null;
29
+ const headers = {
30
+ 'X-Figma-Token': token
31
+ };
32
+ if (body) {
33
+ headers['Content-Type'] = 'application/json';
34
+ headers['Content-Length'] = Buffer.byteLength(body);
35
+ }
36
+
37
+ const req = https.request(
38
+ url,
39
+ {
40
+ method: method,
41
+ headers: headers
42
+ },
43
+ res => {
44
+ let data = '';
45
+ res.on('data', chunk => {
46
+ data += chunk;
47
+ });
48
+ res.on('end', () => {
49
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
50
+ try {
51
+ resolve(JSON.parse(data));
52
+ } catch (error) {
53
+ reject(error);
54
+ }
55
+ } else {
56
+ console.error(`❌ Figma API error: ${res.statusCode}`);
57
+ console.error(data);
58
+ process.exit(1);
59
+ }
60
+ });
61
+ }
62
+ );
63
+
64
+ req.on('error', error => {
65
+ reject(error);
66
+ });
67
+
68
+ if (body) {
69
+ req.write(body);
70
+ }
71
+
72
+ req.end();
73
+ });
74
+ }
75
+
76
+ function setDeep(obj, pathParts, value) {
77
+ let current = obj;
78
+ for (let i = 0; i < pathParts.length; i++) {
79
+ const part = pathParts[i];
80
+ if (i === pathParts.length - 1) {
81
+ current[part] = value;
82
+ } else {
83
+ if (!current[part] || typeof current[part] !== 'object' || Array.isArray(current[part])) {
84
+ current[part] = {};
85
+ }
86
+ current = current[part];
87
+ }
88
+ }
89
+ }
90
+
91
+ function getDeep(obj, pathParts) {
92
+ let current = obj;
93
+ for (const part of pathParts) {
94
+ if (!current || typeof current !== 'object') {
95
+ return undefined;
96
+ }
97
+ current = current[part];
98
+ }
99
+ return current;
100
+ }
101
+
102
+ function deepMerge(target, source) {
103
+ for (const key of Object.keys(source)) {
104
+ const srcVal = source[key];
105
+ const tgtVal = target[key];
106
+ if (srcVal && typeof srcVal === 'object' && !Array.isArray(srcVal)) {
107
+ if (!tgtVal || typeof tgtVal !== 'object' || Array.isArray(tgtVal)) {
108
+ target[key] = {};
109
+ }
110
+ deepMerge(target[key], srcVal);
111
+ } else {
112
+ target[key] = srcVal;
113
+ }
114
+ }
115
+ return target;
116
+ }
117
+
118
+ function colorToHex(color) {
119
+ const r = Math.round(color.r * 255);
120
+ const g = Math.round(color.g * 255);
121
+ const b = Math.round(color.b * 255);
122
+ const a = typeof color.a === 'number' ? color.a : 1;
123
+
124
+ const toHex = n => n.toString(16).padStart(2, '0');
125
+
126
+ if (a === 1) {
127
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
128
+ }
129
+
130
+ const alpha = Math.round(a * 255);
131
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(alpha)}`;
132
+ }
133
+
134
+ function hexToFigmaColor(hex) {
135
+ if (typeof hex !== 'string') return null;
136
+ let value = hex.trim();
137
+ if (!value) return null;
138
+ if (value[0] === '#') {
139
+ value = value.slice(1);
140
+ }
141
+
142
+ if (value.length === 3) {
143
+ value = value
144
+ .split('')
145
+ .map(ch => ch + ch)
146
+ .join('');
147
+ } else if (value.length === 4) {
148
+ value =
149
+ value
150
+ .slice(0, 3)
151
+ .split('')
152
+ .map(ch => ch + ch)
153
+ .join('') +
154
+ value[3] +
155
+ value[3];
156
+ }
157
+
158
+ if (value.length !== 6 && value.length !== 8) return null;
159
+
160
+ const r = parseInt(value.slice(0, 2), 16);
161
+ const g = parseInt(value.slice(2, 4), 16);
162
+ const b = parseInt(value.slice(4, 6), 16);
163
+ let a = 255;
164
+
165
+ if (value.length === 8) {
166
+ a = parseInt(value.slice(6, 8), 16);
167
+ }
168
+
169
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b) || Number.isNaN(a)) {
170
+ return null;
171
+ }
172
+
173
+ return {
174
+ r: r / 255,
175
+ g: g / 255,
176
+ b: b / 255,
177
+ a: a / 255
178
+ };
179
+ }
180
+
181
+ function figmaNameToPath(name) {
182
+ return name
183
+ .trim()
184
+ .toLowerCase()
185
+ .replace(/\s+/g, '')
186
+ .split(/[\/\.]/)
187
+ .filter(Boolean);
188
+ }
189
+
190
+ function resolveOutputPath() {
191
+ let outputFile = path.join(__dirname, '../tokens.json');
192
+ const rootDir = process.cwd();
193
+ const configPath = path.join(rootDir, 'substrata.config.js');
194
+
195
+ if (fs.existsSync(configPath)) {
196
+ const config = require(configPath);
197
+ if (config.output) {
198
+ const resolvedOutputFile = path.resolve(rootDir, config.output);
199
+ ensurePathWithinRoot(resolvedOutputFile, rootDir);
200
+ outputFile = resolvedOutputFile;
201
+ }
202
+ }
203
+
204
+ return outputFile;
205
+ }
206
+
207
+ function flattenTokens(obj, prefix, result) {
208
+ const keys = Object.keys(obj || {});
209
+ for (const key of keys) {
210
+ const value = obj[key];
211
+ const nextPath = prefix.length ? `${prefix}.${key}` : key;
212
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
213
+ if (Object.prototype.hasOwnProperty.call(value, 'value') && value.type) {
214
+ result.push({
215
+ path: nextPath,
216
+ type: value.type,
217
+ value: value.value
218
+ });
219
+ } else {
220
+ flattenTokens(value, nextPath, result);
221
+ }
222
+ }
223
+ }
224
+ return result;
225
+ }
226
+
227
+ function tokenPathToFigmaName(path) {
228
+ const parts = path.split('.');
229
+ if (parts.length >= 2 && parts[0] === 'font' && parts[1] === 'size') {
230
+ const rest = parts.slice(2).join('/');
231
+ return rest ? `font-size/${rest}` : 'font-size';
232
+ }
233
+ if (parts.length >= 2 && parts[0] === 'line' && parts[1] === 'height') {
234
+ const rest = parts.slice(2).join('/');
235
+ return rest ? `line-height/${rest}` : 'line-height';
236
+ }
237
+ return parts.join('/');
238
+ }
239
+
240
+ function resolveVariableValue(variable, modeId, variables) {
241
+ let current = variable;
242
+ const visited = new Set();
243
+
244
+ while (current && current.valuesByMode) {
245
+ const raw = current.valuesByMode[modeId];
246
+
247
+ if (!raw || typeof raw !== 'object' || raw.type !== 'VARIABLE_ALIAS') {
248
+ return raw;
249
+ }
250
+
251
+ const aliasId = raw.id;
252
+ if (!aliasId || visited.has(aliasId)) {
253
+ return raw;
254
+ }
255
+
256
+ visited.add(aliasId);
257
+ const next = variables[aliasId];
258
+ if (!next) {
259
+ return raw;
260
+ }
261
+
262
+ current = next;
263
+ }
264
+
265
+ return undefined;
266
+ }
267
+
268
+ function selectModeId(collection, preferredModeName) {
269
+ if (!collection) return null;
270
+ const modes = Array.isArray(collection.modes) ? collection.modes : [];
271
+ if (preferredModeName && modes.length > 0) {
272
+ const match = modes.find(
273
+ mode => mode.name && mode.name.toLowerCase() === preferredModeName.toLowerCase()
274
+ );
275
+ if (match) {
276
+ return match.modeId;
277
+ }
278
+ }
279
+ if (collection.defaultModeId) {
280
+ return collection.defaultModeId;
281
+ }
282
+ if (modes.length > 0) {
283
+ return modes[0].modeId;
284
+ }
285
+ return null;
286
+ }
287
+
288
+ async function runPull() {
289
+ const token = getRequiredEnv('FIGMA_TOKEN');
290
+ const fileKey = getRequiredEnv('FIGMA_FILE_KEY');
291
+ const spaceUnit = process.env.FIGMA_SPACE_UNIT || 'px';
292
+ const radiusUnit = process.env.FIGMA_RADIUS_UNIT || 'px';
293
+ const motionUnit = process.env.FIGMA_MOTION_UNIT || 'ms';
294
+ const fontSizeUnit = process.env.FIGMA_FONT_SIZE_UNIT || 'px';
295
+ const preferredModeName = process.env.FIGMA_MODE_NAME || null;
296
+
297
+ const url = `https://api.figma.com/v1/files/${fileKey}/variables/local`;
298
+
299
+ console.log('📡 Fetching local variables from Figma...');
300
+ const payload = await fetchJson(url, token);
301
+
302
+ if (!payload.meta || !payload.meta.variables || !payload.meta.variableCollections) {
303
+ console.error('❌ Unexpected Figma response shape');
304
+ process.exit(1);
305
+ }
306
+
307
+ const variables = payload.meta.variables;
308
+ const collections = payload.meta.variableCollections;
309
+
310
+ const outputFile = resolveOutputPath();
311
+ let existingTokens = {};
312
+
313
+ if (fs.existsSync(outputFile)) {
314
+ try {
315
+ existingTokens = JSON.parse(fs.readFileSync(outputFile, 'utf8'));
316
+ } catch (error) {
317
+ console.error('❌ Could not parse existing tokens.json');
318
+ console.error(error);
319
+ process.exit(1);
320
+ }
321
+ }
322
+
323
+ const updates = {};
324
+ const changes = [];
325
+
326
+ for (const variableId of Object.keys(variables)) {
327
+ const variable = variables[variableId];
328
+ const collection = collections[variable.variableCollectionId];
329
+ if (!collection) continue;
330
+
331
+ const modeId = selectModeId(collection, preferredModeName);
332
+ if (!modeId) continue;
333
+
334
+ const resolvedValue = resolveVariableValue(variable, modeId, variables);
335
+
336
+ if (resolvedValue === undefined || resolvedValue === null) continue;
337
+
338
+ const name = variable.name || '';
339
+ const lowerName = name.toLowerCase();
340
+
341
+ if (variable.resolvedType === 'COLOR') {
342
+ if (
343
+ !lowerName.startsWith('neutral/') &&
344
+ !lowerName.startsWith('brand/') &&
345
+ !lowerName.startsWith('color/')
346
+ ) {
347
+ continue;
348
+ }
349
+
350
+ if (!resolvedValue || typeof resolvedValue !== 'object') continue;
351
+
352
+ const hex = colorToHex(resolvedValue);
353
+ const pathParts = figmaNameToPath(name);
354
+ const tokenValue = {
355
+ value: hex,
356
+ type: 'color',
357
+ originalVariable: `--${pathParts.join('-')}`
358
+ };
359
+
360
+ const previous = getDeep(existingTokens, pathParts);
361
+ setDeep(updates, pathParts, tokenValue);
362
+ changes.push({
363
+ path: pathParts.join('.'),
364
+ before: previous ? previous.value : undefined,
365
+ after: hex
366
+ });
367
+ }
368
+
369
+ if (variable.resolvedType === 'FLOAT') {
370
+ if (lowerName.startsWith('space/')) {
371
+ if (typeof resolvedValue !== 'number') continue;
372
+ const pathParts = figmaNameToPath(name);
373
+ const value = `${resolvedValue}${spaceUnit}`;
374
+ const tokenValue = {
375
+ value: value,
376
+ type: 'spacing',
377
+ originalVariable: `--${pathParts.join('-')}`
378
+ };
379
+ const previous = getDeep(existingTokens, pathParts);
380
+ setDeep(updates, pathParts, tokenValue);
381
+ changes.push({
382
+ path: pathParts.join('.'),
383
+ before: previous ? previous.value : undefined,
384
+ after: value
385
+ });
386
+ continue;
387
+ }
388
+
389
+ if (lowerName.startsWith('radius/') || lowerName.startsWith('border-radius/')) {
390
+ if (typeof resolvedValue !== 'number') continue;
391
+ const pathParts = figmaNameToPath(name);
392
+ const value = `${resolvedValue}${radiusUnit}`;
393
+ const tokenValue = {
394
+ value: value,
395
+ type: 'radius',
396
+ originalVariable: `--${pathParts.join('-')}`
397
+ };
398
+ const previous = getDeep(existingTokens, pathParts);
399
+ setDeep(updates, pathParts, tokenValue);
400
+ changes.push({
401
+ path: pathParts.join('.'),
402
+ before: previous ? previous.value : undefined,
403
+ after: value
404
+ });
405
+ continue;
406
+ }
407
+
408
+ if (lowerName.startsWith('motion/') || lowerName.startsWith('duration/')) {
409
+ if (typeof resolvedValue !== 'number') continue;
410
+ const pathParts = figmaNameToPath(name);
411
+ const value = `${resolvedValue}${motionUnit}`;
412
+ const tokenValue = {
413
+ value: value,
414
+ type: 'motion',
415
+ originalVariable: `--${pathParts.join('-')}`
416
+ };
417
+ const previous = getDeep(existingTokens, pathParts);
418
+ setDeep(updates, pathParts, tokenValue);
419
+ changes.push({
420
+ path: pathParts.join('.'),
421
+ before: previous ? previous.value : undefined,
422
+ after: value
423
+ });
424
+ continue;
425
+ }
426
+
427
+ if (
428
+ lowerName.startsWith('font-size/') ||
429
+ lowerName.startsWith('font/size/') ||
430
+ lowerName.startsWith('font.size/')
431
+ ) {
432
+ if (typeof resolvedValue !== 'number') continue;
433
+ const pathParts = figmaNameToPath(name);
434
+ const value = `${resolvedValue}${fontSizeUnit}`;
435
+ const tokenValue = {
436
+ value: value,
437
+ type: 'typography',
438
+ originalVariable: `--${pathParts.join('-')}`
439
+ };
440
+ const previous = getDeep(existingTokens, pathParts);
441
+ setDeep(updates, pathParts, tokenValue);
442
+ changes.push({
443
+ path: pathParts.join('.'),
444
+ before: previous ? previous.value : undefined,
445
+ after: value
446
+ });
447
+ continue;
448
+ }
449
+
450
+ if (
451
+ lowerName.startsWith('line-height/') ||
452
+ lowerName.startsWith('line/height/') ||
453
+ lowerName.startsWith('line.height/')
454
+ ) {
455
+ if (typeof resolvedValue !== 'number') continue;
456
+ const pathParts = figmaNameToPath(name);
457
+ const value = String(resolvedValue);
458
+ const tokenValue = {
459
+ value: value,
460
+ type: 'typography',
461
+ originalVariable: `--${pathParts.join('-')}`
462
+ };
463
+ const previous = getDeep(existingTokens, pathParts);
464
+ setDeep(updates, pathParts, tokenValue);
465
+ changes.push({
466
+ path: pathParts.join('.'),
467
+ before: previous ? previous.value : undefined,
468
+ after: value
469
+ });
470
+ continue;
471
+ }
472
+
473
+ if (lowerName.startsWith('opacity/')) {
474
+ if (typeof resolvedValue !== 'number') continue;
475
+ const pathParts = figmaNameToPath(name);
476
+ const value = String(resolvedValue);
477
+ const tokenValue = {
478
+ value: value,
479
+ type: 'opacity',
480
+ originalVariable: `--${pathParts.join('-')}`
481
+ };
482
+ const previous = getDeep(existingTokens, pathParts);
483
+ setDeep(updates, pathParts, tokenValue);
484
+ changes.push({
485
+ path: pathParts.join('.'),
486
+ before: previous ? previous.value : undefined,
487
+ after: value
488
+ });
489
+ continue;
490
+ }
491
+ }
492
+ }
493
+
494
+ if (changes.length === 0) {
495
+ console.log('â„šī¸ No matching color or space variables found to update.');
496
+ return;
497
+ }
498
+
499
+ const merged = deepMerge(existingTokens, updates);
500
+ fs.writeFileSync(outputFile, JSON.stringify(merged, null, 2));
501
+
502
+ const reportFile = path.join(path.dirname(outputFile), 'figma-drift-report.json');
503
+ const report = {
504
+ generatedAt: new Date().toISOString(),
505
+ fileKey: fileKey,
506
+ mode: preferredModeName || 'default',
507
+ changes: changes
508
+ };
509
+ fs.writeFileSync(reportFile, JSON.stringify(report, null, 2));
510
+
511
+ console.log(`✅ Updated ${outputFile} with Figma variables.`);
512
+ console.log(`📝 Drift report written to ${reportFile}.`);
513
+ console.log('Changed tokens:');
514
+ for (const change of changes) {
515
+ console.log(
516
+ ` - ${change.path}: ${change.before === undefined ? '∅' : change.before} → ${
517
+ change.after
518
+ }`
519
+ );
520
+ }
521
+ }
522
+
523
+ async function runPush() {
524
+ const token = getRequiredEnv('FIGMA_TOKEN');
525
+ const fileKey = getRequiredEnv('FIGMA_FILE_KEY');
526
+ const preferredModeName = process.env.FIGMA_MODE_NAME || null;
527
+
528
+ const url = `https://api.figma.com/v1/files/${fileKey}/variables/local`;
529
+
530
+ console.log('📡 Fetching local variables from Figma for push preview...');
531
+ const payload = await fetchJson(url, token);
532
+
533
+ if (!payload.meta || !payload.meta.variables || !payload.meta.variableCollections) {
534
+ console.error('❌ Unexpected Figma response shape');
535
+ process.exit(1);
536
+ }
537
+
538
+ const variables = payload.meta.variables;
539
+ const collections = payload.meta.variableCollections;
540
+
541
+ const outputFile = resolveOutputPath();
542
+ if (!fs.existsSync(outputFile)) {
543
+ console.error(`❌ tokens.json not found at: ${outputFile}`);
544
+ process.exit(1);
545
+ }
546
+
547
+ let tokens;
548
+ try {
549
+ tokens = JSON.parse(fs.readFileSync(outputFile, 'utf8'));
550
+ } catch (error) {
551
+ console.error('❌ Could not parse tokens.json');
552
+ console.error(error);
553
+ process.exit(1);
554
+ }
555
+
556
+ const flat = flattenTokens(tokens, '', []);
557
+ const allowedTypes = new Set(['color', 'spacing', 'radius', 'motion', 'opacity', 'typography']);
558
+
559
+ const filtered = flat.filter(entry => allowedTypes.has(entry.type));
560
+
561
+ const variableByName = {};
562
+ for (const id of Object.keys(variables)) {
563
+ const variable = variables[id];
564
+ const name = (variable.name || '').trim().toLowerCase();
565
+ if (!name) continue;
566
+ variableByName[name] = { id, variable };
567
+ }
568
+
569
+ const updates = [];
570
+
571
+ for (const entry of filtered) {
572
+ const figmaName = tokenPathToFigmaName(entry.path);
573
+ const key = figmaName.trim().toLowerCase();
574
+ const match = variableByName[key];
575
+ if (!match) continue;
576
+
577
+ const variable = match.variable;
578
+ const collection = collections[variable.variableCollectionId];
579
+ if (!collection) continue;
580
+
581
+ const modeId = selectModeId(collection, preferredModeName);
582
+ if (!modeId) continue;
583
+
584
+ const resolvedValue = resolveVariableValue(variable, modeId, variables);
585
+
586
+ let current = null;
587
+ if (variable.resolvedType === 'COLOR') {
588
+ if (resolvedValue && typeof resolvedValue === 'object') {
589
+ current = colorToHex(resolvedValue);
590
+ }
591
+ } else if (variable.resolvedType === 'FLOAT') {
592
+ if (typeof resolvedValue === 'number') {
593
+ current = resolvedValue;
594
+ }
595
+ }
596
+
597
+ updates.push({
598
+ path: entry.path,
599
+ figmaName: figmaName,
600
+ variableId: match.id,
601
+ type: entry.type,
602
+ resolvedType: variable.resolvedType,
603
+ modeId: modeId,
604
+ currentValue: current,
605
+ nextValue: entry.value
606
+ });
607
+ }
608
+
609
+ const previewFile = path.join(path.dirname(outputFile), 'figma-push-preview.json');
610
+ const preview = {
611
+ generatedAt: new Date().toISOString(),
612
+ fileKey: fileKey,
613
+ mode: preferredModeName || 'default',
614
+ updates: updates
615
+ };
616
+
617
+ fs.writeFileSync(previewFile, JSON.stringify(preview, null, 2));
618
+
619
+ console.log(`📝 Figma push preview written to ${previewFile}.`);
620
+ console.log(` Total candidate updates: ${updates.length}`);
621
+ }
622
+
623
+ async function runApplyFromPreview() {
624
+ const token = getRequiredEnv('FIGMA_TOKEN');
625
+ const fileKey = getRequiredEnv('FIGMA_FILE_KEY');
626
+ const applyChanges = process.env.FIGMA_APPLY_CHANGES === '1';
627
+
628
+ const outputFile = resolveOutputPath();
629
+ const previewFile = path.join(path.dirname(outputFile), 'figma-push-preview.json');
630
+
631
+ if (!fs.existsSync(previewFile)) {
632
+ console.error(`❌ figma-push-preview.json not found at: ${previewFile}`);
633
+ process.exit(1);
634
+ }
635
+
636
+ let preview;
637
+ try {
638
+ preview = JSON.parse(fs.readFileSync(previewFile, 'utf8'));
639
+ } catch (error) {
640
+ console.error('❌ Could not parse figma-push-preview.json');
641
+ console.error(error);
642
+ process.exit(1);
643
+ }
644
+
645
+ if (preview.fileKey && preview.fileKey !== fileKey) {
646
+ console.error('❌ FIGMA_FILE_KEY does not match fileKey stored in figma-push-preview.json');
647
+ process.exit(1);
648
+ }
649
+
650
+ const updates = Array.isArray(preview.updates) ? preview.updates : [];
651
+
652
+ if (updates.length === 0) {
653
+ console.log('â„šī¸ No updates found in figma-push-preview.json.');
654
+ return;
655
+ }
656
+
657
+ const variableModeValues = [];
658
+
659
+ for (const update of updates) {
660
+ if (!update.variableId || !update.modeId) continue;
661
+
662
+ const resolvedType = update.resolvedType;
663
+ let nextValue = update.nextValue;
664
+ let value = null;
665
+
666
+ if (resolvedType === 'COLOR') {
667
+ if (typeof nextValue !== 'string') continue;
668
+ const color = hexToFigmaColor(nextValue);
669
+ if (!color) continue;
670
+ value = color;
671
+ } else if (resolvedType === 'FLOAT') {
672
+ const numeric = parseFloat(String(nextValue));
673
+ if (!Number.isFinite(numeric)) continue;
674
+ value = numeric;
675
+ } else {
676
+ continue;
677
+ }
678
+
679
+ variableModeValues.push({
680
+ variableId: update.variableId,
681
+ modeId: update.modeId,
682
+ value: value
683
+ });
684
+ }
685
+
686
+ if (variableModeValues.length === 0) {
687
+ console.log('â„šī¸ No applicable variable updates to apply from preview.');
688
+ return;
689
+ }
690
+
691
+ if (!applyChanges) {
692
+ console.log('🔎 Dry run (FIGMA_APPLY_CHANGES != 1). Planned updates:');
693
+ for (const update of updates) {
694
+ console.log(
695
+ ` - ${update.path} (${update.figmaName}) [${update.variableId}] mode=${update.modeId} ` +
696
+ `current=${update.currentValue} → next=${update.nextValue}`
697
+ );
698
+ }
699
+ console.log('');
700
+ console.log(
701
+ `Set FIGMA_APPLY_CHANGES=1 to apply ${variableModeValues.length} variableModeValues via file_variables:write.`
702
+ );
703
+ return;
704
+ }
705
+
706
+ const url = `https://api.figma.com/v1/files/${fileKey}/variables`;
707
+ const body = {
708
+ variableModeValues: variableModeValues
709
+ };
710
+
711
+ console.log(
712
+ `📡 Applying ${variableModeValues.length} variable updates to Figma via file_variables:write...`
713
+ );
714
+
715
+ await fetchJson(url, token, { method: 'POST', body: body });
716
+
717
+ console.log('✅ Applied variable updates from figma-push-preview.json to Figma.');
718
+ }
719
+
720
+ const direction = (process.env.FIGMA_SYNC_DIRECTION || 'pull').toLowerCase();
721
+
722
+ if (direction === 'push') {
723
+ runPush().catch(error => {
724
+ console.error('❌ Error running Figma Sync (push preview):', error);
725
+ process.exit(1);
726
+ });
727
+ } else if (direction === 'apply') {
728
+ runApplyFromPreview().catch(error => {
729
+ console.error('❌ Error applying Figma push preview:', error);
730
+ process.exit(1);
731
+ });
732
+ } else {
733
+ runPull().catch(error => {
734
+ console.error('❌ Error running Figma Sync:', error);
735
+ process.exit(1);
736
+ });
737
+ }
738
+