@pedroab0/skills 1.0.1 → 1.2.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 (29) hide show
  1. package/README.md +19 -3
  2. package/bin/cli.js +175 -35
  3. package/figma-extractor/README.md +14 -6
  4. package/figma-extractor/SKILL.md +18 -16
  5. package/figma-extractor/figma-plugin/README.md +10 -5
  6. package/figma-extractor/figma-plugin/code.js +461 -87
  7. package/figma-extractor/figma-plugin/ui.html +76 -23
  8. package/figma-extractor/scripts/fetch_figma.js +465 -61
  9. package/figma-node-builder/README.md +1 -1
  10. package/figma-node-builder/SKILL.md +7 -8
  11. package/figma-node-builder/scripts/inspect_spec.js +15 -2
  12. package/figma-to-code/README.md +10 -4
  13. package/figma-to-code/SKILL.md +18 -16
  14. package/figma-to-code/scripts/fetch_figma.js +448 -53
  15. package/package.json +12 -3
  16. package/figma-extractor/demo/desktop_home.json +0 -2434
  17. package/figma-extractor/demo/dt_money_desktop_home.png +0 -0
  18. package/figma-extractor/demo/dt_money_preview.png +0 -0
  19. package/figma-extractor/demo/dt_money_selection.json +0 -472
  20. package/figma-extractor/demo/index.html +0 -188
  21. package/figma-extractor/demo/style.css +0 -360
  22. package/figma-extractor/figma-plugin/assets/cover-1920.png +0 -0
  23. package/figma-extractor/figma-plugin/assets/cover.jpg +0 -0
  24. package/figma-extractor/figma-plugin/assets/cover.png +0 -0
  25. package/figma-extractor/figma-plugin/assets/icon-128.png +0 -0
  26. package/figma-extractor/figma-plugin/assets/icon.png +0 -0
  27. package/figma-extractor/scripts/test_fetch_figma.js +0 -601
  28. package/figma-node-builder/scripts/test_inspect_spec.js +0 -148
  29. package/figma-to-code/scripts/test_fetch_figma.js +0 -581
@@ -11,27 +11,138 @@ const { URL } = require('url');
11
11
  // Environment and Auth Utilities
12
12
  // -----------------------------------------------------------------------------
13
13
 
14
- function loadEnv() {
15
- const home = process.env.HOME || '';
16
- const candidates = [
17
- path.join(process.cwd(), '.env'),
14
+ function resolveHomeDir() {
15
+ return (
16
+ process.env.HOME ||
17
+ process.env.USERPROFILE ||
18
+ (typeof os.homedir === 'function' ? os.homedir() : '') ||
19
+ ''
20
+ );
21
+ }
22
+
23
+ function decodeEnvBuffer(buf) {
24
+ if (!Buffer.isBuffer(buf)) {
25
+ const str = String(buf || '');
26
+ return str.charCodeAt(0) === 0xfeff ? str.slice(1) : str;
27
+ }
28
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
29
+ return buf.toString('utf16le', 2);
30
+ }
31
+ const str = buf.toString('utf8');
32
+ return str.charCodeAt(0) === 0xfeff ? str.slice(1) : str;
33
+ }
34
+
35
+ function getPowerShellProfilePaths(home) {
36
+ const paths = [];
37
+ if (process.env.PROFILE && process.env.PROFILE.toLowerCase().endsWith('.ps1')) {
38
+ paths.push(process.env.PROFILE);
39
+ }
40
+
41
+ const docRoots = [];
42
+ if (home) {
43
+ docRoots.push(
44
+ path.join(home, 'Documents'),
45
+ path.join(home, 'Documentos'),
46
+ path.join(home, 'OneDrive', 'Documents'),
47
+ path.join(home, 'OneDrive', 'Documentos'),
48
+ );
49
+ }
50
+
51
+ const oneDriveEnvs = [
52
+ process.env.OneDrive,
53
+ process.env.OneDriveConsumer,
54
+ process.env.OneDriveCommercial,
55
+ ].filter(Boolean);
56
+
57
+ for (const od of oneDriveEnvs) {
58
+ docRoots.push(path.join(od, 'Documents'), path.join(od, 'Documentos'));
59
+ }
60
+
61
+ const shellDirs = ['PowerShell', 'WindowsPowerShell'];
62
+ const profileFiles = [
63
+ 'Microsoft.PowerShell_profile.ps1',
64
+ 'Microsoft.VSCode_profile.ps1',
65
+ 'profile.ps1',
66
+ ];
67
+
68
+ for (const docRoot of docRoots) {
69
+ if (!fs.existsSync(docRoot)) continue;
70
+ for (const shellDir of shellDirs) {
71
+ const fullShellDir = path.join(docRoot, shellDir);
72
+ if (!fs.existsSync(fullShellDir)) continue;
73
+ for (const profileFile of profileFiles) {
74
+ paths.push(path.join(fullShellDir, profileFile));
75
+ }
76
+ }
77
+ }
78
+
79
+ if (home) {
80
+ const xdgPsDir = path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'powershell');
81
+ if (fs.existsSync(xdgPsDir)) {
82
+ for (const profileFile of profileFiles) {
83
+ paths.push(path.join(xdgPsDir, profileFile));
84
+ }
85
+ }
86
+ }
87
+
88
+ return Array.from(new Set(paths));
89
+ }
90
+
91
+ function loadEnv(options = {}) {
92
+ const cwd = options.cwd || process.cwd();
93
+ const home = options.home !== undefined ? options.home : resolveHomeDir();
94
+
95
+ const dotEnvCandidates = [
96
+ path.join(cwd, '.env'),
97
+ path.join(cwd, '.env.local'),
18
98
  home ? path.join(home, '.env') : null,
99
+ ].filter(Boolean);
100
+
101
+ const shellConfigCandidates = [
19
102
  home ? path.join(home, '.zshrc') : null,
103
+ home ? path.join(home, '.zprofile') : null,
104
+ home ? path.join(home, '.zshenv') : null,
20
105
  home ? path.join(home, '.bashrc') : null,
106
+ home ? path.join(home, '.bash_profile') : null,
107
+ home ? path.join(home, '.profile') : null,
108
+ ...getPowerShellProfilePaths(home),
21
109
  ].filter(Boolean);
22
110
 
23
- for (const envPath of candidates) {
24
- if (fs.existsSync(envPath)) {
111
+ const seen = new Set();
112
+ const candidates = [];
113
+ for (const p of dotEnvCandidates) {
114
+ if (!seen.has(p)) {
115
+ seen.add(p);
116
+ candidates.push({ filePath: p, isShellConfig: false });
117
+ }
118
+ }
119
+ for (const p of shellConfigCandidates) {
120
+ if (!seen.has(p)) {
121
+ seen.add(p);
122
+ candidates.push({ filePath: p, isShellConfig: true });
123
+ }
124
+ }
125
+
126
+ for (const { filePath, isShellConfig } of candidates) {
127
+ if (fs.existsSync(filePath)) {
25
128
  try {
26
- const envContent = fs.readFileSync(envPath, 'utf8');
27
- const lines = envContent.split('\n');
129
+ const rawBuf = fs.readFileSync(filePath);
130
+ const envContent = decodeEnvBuffer(rawBuf);
131
+ if (isShellConfig && !/figma_/i.test(envContent)) {
132
+ continue;
133
+ }
134
+ const lines = envContent.split(/\r?\n/);
28
135
  for (const line of lines) {
136
+ if (isShellConfig && !/figma_/i.test(line)) {
137
+ continue;
138
+ }
29
139
  const parsed = parseEnvLine(line);
30
140
  if (parsed && parsed.key) {
141
+ const isFigmaVar = /^FIGMA_/i.test(parsed.key);
142
+ const envKey = isFigmaVar ? parsed.key.toUpperCase() : parsed.key;
31
143
  // For dot-env files, load any variable; for shell configs, only load FIGMA_ variables
32
- const isShellConfig = envPath.endsWith('.zshrc') || envPath.endsWith('.bashrc');
33
- if (!process.env[parsed.key] && (!isShellConfig || parsed.key.startsWith('FIGMA_'))) {
34
- process.env[parsed.key] = parsed.val;
144
+ if (!process.env[envKey] && (!isShellConfig || isFigmaVar)) {
145
+ process.env[envKey] = parsed.val;
35
146
  }
36
147
  }
37
148
  }
@@ -44,16 +155,47 @@ function loadEnv() {
44
155
 
45
156
  function parseEnvLine(line) {
46
157
  if (!line || typeof line !== 'string') return null;
47
- let trimmed = line.trim();
48
- if (trimmed.startsWith('export ')) {
49
- trimmed = trimmed.substring(7).trim();
158
+ let trimmed = line.replace(/^\uFEFF/, '').trim();
159
+ if (!trimmed || trimmed.startsWith('#')) {
160
+ return null;
50
161
  }
51
- if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) {
162
+
163
+ // Handle PowerShell [Environment]::SetEnvironmentVariable("KEY", "VAL", ...)
164
+ const setEnvMatch = trimmed.match(
165
+ /^\[(?:System\.)?Environment\]::SetEnvironmentVariable\(\s*(['"])([A-Za-z_][A-Za-z0-9_]*)\1\s*,\s*(['"])([^'"]*)\3/i,
166
+ );
167
+ if (setEnvMatch) {
168
+ return { key: setEnvMatch[2], val: setEnvMatch[4] };
169
+ }
170
+
171
+ // Strip Unix 'export ' or Windows CMD 'set ' prefix (requires whitespace after keyword)
172
+ if (/^export\s+/i.test(trimmed)) {
173
+ trimmed = trimmed.replace(/^export\s+/i, '').trim();
174
+ } else if (/^set\s+/i.test(trimmed)) {
175
+ trimmed = trimmed.replace(/^set\s+/i, '').trim();
176
+ if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.includes('=')) {
177
+ trimmed = trimmed.slice(1, -1).trim();
178
+ }
179
+ }
180
+
181
+ if (!trimmed.includes('=')) {
52
182
  return null;
53
183
  }
54
184
 
55
185
  const eqIdx = trimmed.indexOf('=');
56
- const key = trimmed.slice(0, eqIdx).trim();
186
+ let key = trimmed.slice(0, eqIdx).trim();
187
+
188
+ // Strip PowerShell $env:KEY or ${env:KEY} syntax
189
+ if (/^\$env:/i.test(key)) {
190
+ key = key.replace(/^\$env:/i, '').trim();
191
+ } else if (/^\$\{env:([^}]+)\}$/i.test(key)) {
192
+ key = key.replace(/^\$\{env:([^}]+)\}$/i, '$1').trim();
193
+ }
194
+
195
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
196
+ return null;
197
+ }
198
+
57
199
  let rest = trimmed.slice(eqIdx + 1).trim();
58
200
 
59
201
  let val = '';
@@ -409,13 +551,234 @@ function normalizeComponentProps(componentProperties) {
409
551
  // AST Pruning & Token Optimization
410
552
  // -----------------------------------------------------------------------------
411
553
 
412
- function pruneFigmaNode(node, context = {}, options = {}) {
554
+ const VECTOR_PRIMITIVE_TYPES = new Set([
555
+ 'VECTOR',
556
+ 'BOOLEAN_OPERATION',
557
+ 'STAR',
558
+ 'LINE',
559
+ 'REGULAR_POLYGON',
560
+ 'POLYGON',
561
+ ]);
562
+
563
+ function cleanLayerName(rawName, fallback = 'Node') {
564
+ if (!rawName || typeof rawName !== 'string') return fallback;
565
+ const trimmed = rawName.trim();
566
+ if (!trimmed) return fallback;
567
+ if (/^data:image\//i.test(trimmed)) return 'Image';
568
+ if (/^(?:<svg\b|<\?xml\b)/i.test(trimmed)) return 'SVG';
569
+ if (/^https?:\/\//i.test(trimmed) && trimmed.length > 60) {
570
+ try {
571
+ const lastSeg = trimmed.split('?')[0].split('/').pop();
572
+ return lastSeg && lastSeg.length <= 60 ? lastSeg : 'Image';
573
+ } catch (_) {
574
+ return 'Image';
575
+ }
576
+ }
577
+ const singleLine = trimmed.replace(/\s+/g, ' ');
578
+ return singleLine.length > 80 ? singleLine.slice(0, 80) + '…' : singleLine;
579
+ }
580
+
581
+ function isRawAssetString(str) {
582
+ if (!str || typeof str !== 'string') return false;
583
+ const t = str.trim();
584
+ return /^data:image\//i.test(t) || /^(?:<svg\b|<\?xml\b)/i.test(t);
585
+ }
586
+
587
+ function nodeHasImageFill(node) {
588
+ return Array.isArray(node.fills) && node.fills.some((f) => f && f.visible !== false && f.type === 'IMAGE');
589
+ }
590
+
591
+ const GRAPHIC_NAME_RE =
592
+ /svg|icon|ícone|icone|logo|marca|brand|illustrat|ilustra|vector|vetor|symbol|simbol|símbolo|glyph|graphic|ornament|pattern|artwork/i;
593
+
594
+ function hasSvgExportSetting(node) {
595
+ if (!Array.isArray(node.exportSettings) || node.exportSettings.length === 0) return false;
596
+ return node.exportSettings.some((s) => s && String(s.format || '').toUpperCase() === 'SVG');
597
+ }
598
+
599
+ function subtreeHasNoInstances(node, maxNodes = 400) {
600
+ let visited = 0;
601
+ function walk(curr, isRoot) {
602
+ if (!curr || typeof curr !== 'object' || ++visited > maxNodes) return false;
603
+ const t = curr.type || '';
604
+ if (!isRoot && (t === 'INSTANCE' || t === 'COMPONENT' || t === 'COMPONENT_SET')) return false;
605
+ const kids = Array.isArray(curr.children) ? curr.children.filter((c) => c && c.visible !== false) : [];
606
+ for (const k of kids) {
607
+ if (!walk(k, false)) return false;
608
+ }
609
+ return true;
610
+ }
611
+ return walk(node, true);
612
+ }
613
+
614
+ function analyzeVectorSubtree(node, varMap, maxNodes = 600) {
615
+ let visited = 0;
616
+ let vectorPrimitiveCount = 0;
617
+ let shapeCount = 0;
618
+ let plainRectCount = 0;
619
+ let primaryFill = null;
620
+
621
+ const rootType = node.type || '';
622
+ const hasSvgExport = hasSvgExportSetting(node);
623
+
624
+ // Preserve Auto-Layout wrapper frames with their own styling (e.g., Icon Badge) unless explicitly exported as SVG
625
+ if (rootType === 'FRAME' && !hasSvgExport && node.layoutMode && node.layoutMode !== 'NONE') {
626
+ const ownFills = simplifyFills(node.fills, node.boundVariables || {}, varMap);
627
+ const ownStrokes = simplifyStrokes(node.strokes, node.strokeWeight, node.strokeAlign, node.boundVariables || {}, varMap);
628
+ if (ownFills || ownStrokes || node.cornerRadius) {
629
+ return { isPure: false, primaryFill: null };
630
+ }
631
+ }
632
+
633
+ function check(curr, isRoot) {
634
+ if (!curr || typeof curr !== 'object' || ++visited > maxNodes) return false;
635
+ const t = curr.type || '';
636
+ if (t === 'TEXT' || t === 'SLICE' || t === 'STICKY' || t === 'TABLE') return false;
637
+ if (!isRoot && (t === 'INSTANCE' || t === 'COMPONENT' || t === 'COMPONENT_SET')) return false;
638
+ if (nodeHasImageFill(curr)) return false;
639
+
640
+ if (!primaryFill) {
641
+ const fills = simplifyFills(curr.fills, curr.boundVariables || {}, varMap);
642
+ const solidFill = fills && fills.find((f) => f.type === 'SOLID' && f.color);
643
+ if (solidFill) {
644
+ primaryFill = solidFill;
645
+ } else {
646
+ const strokes = simplifyStrokes(curr.strokes, curr.strokeWeight, curr.strokeAlign, curr.boundVariables || {}, varMap);
647
+ if (strokes && strokes.colors && strokes.colors[0]) {
648
+ const c0 = strokes.colors[0];
649
+ primaryFill = typeof c0 === 'object' ? { type: 'SOLID', ...c0 } : { type: 'SOLID', color: c0 };
650
+ }
651
+ }
652
+ }
653
+
654
+ if (VECTOR_PRIMITIVE_TYPES.has(t)) {
655
+ vectorPrimitiveCount++;
656
+ return true;
657
+ }
658
+
659
+ if (t === 'ELLIPSE') {
660
+ shapeCount++;
661
+ return true;
662
+ }
663
+
664
+ if (t === 'RECTANGLE') {
665
+ if (curr.isMask === true || /clip|mask|vector|path|rect|bound|bg/i.test(String(curr.name || ''))) {
666
+ shapeCount++;
667
+ } else {
668
+ plainRectCount++;
669
+ }
670
+ return true;
671
+ }
672
+
673
+ if (t === 'GROUP' || t === 'FRAME' || (isRoot && (t === 'INSTANCE' || t === 'COMPONENT'))) {
674
+ if (!isRoot && t === 'FRAME' && curr.layoutMode && curr.layoutMode !== 'NONE') {
675
+ return false;
676
+ }
677
+ const kids = Array.isArray(curr.children) ? curr.children.filter((c) => c && c.visible !== false) : [];
678
+ if (kids.length === 0) return !isRoot;
679
+ for (const k of kids) {
680
+ if (!check(k, false)) return false;
681
+ }
682
+ return true;
683
+ }
684
+
685
+ return false;
686
+ }
687
+
688
+ const treeOk = check(node, true);
689
+ const w = node.absoluteBoundingBox ? node.absoluteBoundingBox.width : 9999;
690
+ const h = node.absoluteBoundingBox ? node.absoluteBoundingBox.height : 9999;
691
+
692
+ if (!treeOk) {
693
+ if (hasSvgExport && w <= 800 && h <= 800 && !nodeHasImageFill(node) && subtreeHasNoInstances(node)) {
694
+ return { isPure: true, primaryFill };
695
+ }
696
+ return { isPure: false, primaryFill: null };
697
+ }
698
+
699
+ const isSmallGraphic = w <= 160 && h <= 160;
700
+ const hasGraphicName = GRAPHIC_NAME_RE.test(String(node.name || ''));
701
+
702
+ const hasEnoughPrimitives =
703
+ vectorPrimitiveCount > 0 ||
704
+ (shapeCount + plainRectCount >= 2 && (hasSvgExport || (isSmallGraphic && hasGraphicName)));
705
+
706
+ const rectGuardOk =
707
+ plainRectCount === 0 ||
708
+ vectorPrimitiveCount >= 2 ||
709
+ isSmallGraphic ||
710
+ hasGraphicName ||
711
+ hasSvgExport;
712
+
713
+ const isPure = hasEnoughPrimitives && rectGuardOk;
714
+ return { isPure, primaryFill };
715
+ }
716
+
717
+ function analyzeMaskedImageSubtree(node) {
718
+ const t = node.type || '';
719
+ if (t !== 'GROUP' && t !== 'FRAME') return null;
720
+ if (node.layoutMode && node.layoutMode !== 'NONE') return null;
721
+
722
+ const kids = Array.isArray(node.children) ? node.children.filter((c) => c && c.visible !== false) : [];
723
+ if (kids.length === 0 || kids.length > 20) return null;
724
+
725
+ let foundImageFill = false;
726
+ let hasMaskOrClip = false;
727
+
728
+ function scan(curr) {
729
+ const ct = curr.type || '';
730
+ if (ct === 'TEXT' || ct === 'INSTANCE' || ct === 'COMPONENT') return false;
731
+ if (curr.isMask === true) hasMaskOrClip = true;
732
+ if (nodeHasImageFill(curr)) foundImageFill = true;
733
+
734
+ const sub = Array.isArray(curr.children) ? curr.children.filter((c) => c && c.visible !== false) : [];
735
+ for (const s of sub) {
736
+ if (!scan(s)) return false;
737
+ }
738
+ return true;
739
+ }
740
+
741
+ if (!scan(node) || !foundImageFill) return null;
742
+ if (!hasMaskOrClip && !/mask|image|img|photo|avatar|banner|thumb/i.test(String(node.name || ''))) {
743
+ return null;
744
+ }
745
+ return true;
746
+ }
747
+
748
+ function pruneFigmaNode(node, context = {}, options = {}, _depth = 0) {
413
749
  if (!node || typeof node !== 'object') return node;
414
750
 
415
751
  const { varMap, components, componentSets } = context;
752
+ const cleanName = cleanLayerName(node.name, 'Node');
753
+ const bound = node.boundVariables || {};
754
+ const bounds = node.absoluteBoundingBox
755
+ ? {
756
+ width: Math.round(node.absoluteBoundingBox.width),
757
+ height: Math.round(node.absoluteBoundingBox.height),
758
+ }
759
+ : undefined;
760
+
761
+ // 1. Leaf Vector Primitives (VECTOR, BOOLEAN_OPERATION, STAR, LINE, POLYGON)
762
+ if (VECTOR_PRIMITIVE_TYPES.has(node.type)) {
763
+ const vecNode = { name: cleanName, type: 'VECTOR' };
764
+ if (options.includeIds && node.id) vecNode.id = node.id;
765
+ if (node.visible === false) vecNode.visible = false;
766
+ if (bounds) vecNode.bounds = bounds;
767
+ const fills = simplifyFills(node.fills, bound, varMap);
768
+ if (fills) {
769
+ vecNode.fills = fills;
770
+ } else {
771
+ const strokes = simplifyStrokes(node.strokes, node.strokeWeight, node.strokeAlign, bound, varMap);
772
+ if (strokes && strokes.colors && strokes.colors[0]) {
773
+ const c0 = strokes.colors[0];
774
+ vecNode.fills = [typeof c0 === 'object' ? { type: 'SOLID', ...c0 } : { type: 'SOLID', color: c0 }];
775
+ }
776
+ }
777
+ return vecNode;
778
+ }
416
779
 
417
780
  const pruned = {
418
- name: node.name,
781
+ name: cleanName,
419
782
  type: node.type,
420
783
  };
421
784
 
@@ -425,16 +788,14 @@ function pruneFigmaNode(node, context = {}, options = {}) {
425
788
 
426
789
  if (node.visible === false) pruned.visible = false;
427
790
 
428
- const bound = node.boundVariables || {};
429
-
430
791
  // Component Instance & Master Resolution
431
792
  if (node.type === 'INSTANCE') {
432
793
  if (node.componentId && components && components[node.componentId]) {
433
794
  const master = components[node.componentId];
434
795
  const set = master.componentSetId && componentSets ? componentSets[master.componentSetId] : null;
435
796
  pruned.component = {
436
- name: set ? set.name : master.name,
437
- variant: master.name,
797
+ name: cleanLayerName(set ? set.name : master.name, cleanName),
798
+ variant: cleanLayerName(master.name, cleanName),
438
799
  };
439
800
  if (options.includeIds) {
440
801
  pruned.component.id = node.componentId;
@@ -446,12 +807,7 @@ function pruneFigmaNode(node, context = {}, options = {}) {
446
807
 
447
808
  // Shallow instances: skip internal rendering sub-layers if requested
448
809
  if (options.shallowInstances) {
449
- if (node.absoluteBoundingBox) {
450
- pruned.bounds = {
451
- width: Math.round(node.absoluteBoundingBox.width),
452
- height: Math.round(node.absoluteBoundingBox.height),
453
- };
454
- }
810
+ if (bounds) pruned.bounds = bounds;
455
811
  return pruned;
456
812
  }
457
813
  } else if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') {
@@ -459,11 +815,42 @@ function pruneFigmaNode(node, context = {}, options = {}) {
459
815
  if (props) pruned.props = props;
460
816
  }
461
817
 
818
+ // 2. Collapse Pure SVG / Icon / Vector Graphic Containers
819
+ if (_depth > 0 || node.type === 'GROUP' || node.type === 'INSTANCE') {
820
+ const vecCheck = analyzeVectorSubtree(node, varMap);
821
+ if (vecCheck.isPure) {
822
+ if (node.type !== 'INSTANCE' && node.type !== 'COMPONENT') {
823
+ pruned.type = 'VECTOR';
824
+ pruned.isSvg = true;
825
+ }
826
+ if (bounds) pruned.bounds = bounds;
827
+ const ownFills = simplifyFills(node.fills, bound, varMap);
828
+ if (ownFills) {
829
+ pruned.fills = ownFills;
830
+ } else if (vecCheck.primaryFill) {
831
+ pruned.fills = [vecCheck.primaryFill];
832
+ }
833
+ return pruned;
834
+ }
835
+ }
836
+
837
+ // 3. Collapse Masked Image Groups
838
+ if (_depth > 0 && analyzeMaskedImageSubtree(node)) {
839
+ if (bounds) pruned.bounds = bounds;
840
+ pruned.fills = [{ type: 'IMAGE' }];
841
+ if (node.cornerRadius) pruned.cornerRadius = node.cornerRadius;
842
+ return pruned;
843
+ }
844
+
462
845
  // Layout & Flexbox properties
463
846
  if (node.layoutMode && node.layoutMode !== 'NONE') {
464
847
  pruned.layoutMode = node.layoutMode;
465
- if (node.primaryAxisAlignItems) pruned.primaryAxisAlignItems = node.primaryAxisAlignItems;
466
- if (node.counterAxisAlignItems) pruned.counterAxisAlignItems = node.counterAxisAlignItems;
848
+ if (node.primaryAxisAlignItems && node.primaryAxisAlignItems !== 'MIN') {
849
+ pruned.primaryAxisAlignItems = node.primaryAxisAlignItems;
850
+ }
851
+ if (node.counterAxisAlignItems && node.counterAxisAlignItems !== 'MIN') {
852
+ pruned.counterAxisAlignItems = node.counterAxisAlignItems;
853
+ }
467
854
 
468
855
  if (node.itemSpacing) {
469
856
  pruned.itemSpacing = node.itemSpacing;
@@ -484,18 +871,15 @@ function pruneFigmaNode(node, context = {}, options = {}) {
484
871
  if (padToken) pruned.paddingToken = padToken;
485
872
  }
486
873
 
487
- if (node.layoutWrap) pruned.layoutWrap = node.layoutWrap;
874
+ if (node.layoutWrap && node.layoutWrap !== 'NO_WRAP') pruned.layoutWrap = node.layoutWrap;
488
875
  }
489
876
 
490
877
  if (node.layoutGrow) pruned.layoutGrow = node.layoutGrow;
491
- if (node.layoutAlign) pruned.layoutAlign = node.layoutAlign;
878
+ if (node.layoutAlign && node.layoutAlign !== 'INHERIT') pruned.layoutAlign = node.layoutAlign;
492
879
 
493
880
  // Dimensions & Coordinates
494
- if (node.absoluteBoundingBox) {
495
- pruned.bounds = {
496
- width: Math.round(node.absoluteBoundingBox.width),
497
- height: Math.round(node.absoluteBoundingBox.height),
498
- };
881
+ if (bounds) {
882
+ pruned.bounds = bounds;
499
883
  }
500
884
 
501
885
  if (node.minWidth) pruned.minWidth = node.minWidth;
@@ -514,8 +898,9 @@ function pruneFigmaNode(node, context = {}, options = {}) {
514
898
  pruned.cornerRadius = node.cornerRadius;
515
899
  const radiusToken = resolveVariableToken(bound.cornerRadius, varMap);
516
900
  if (radiusToken) pruned.cornerRadiusToken = radiusToken;
901
+ } else if (Array.isArray(node.rectangleCornerRadii) && node.rectangleCornerRadii.some((r) => r > 0)) {
902
+ pruned.cornerRadii = node.rectangleCornerRadii;
517
903
  }
518
- if (node.rectangleCornerRadii) pruned.cornerRadii = node.rectangleCornerRadii;
519
904
  if (node.opacity !== undefined && node.opacity !== 1) pruned.opacity = node.opacity;
520
905
 
521
906
  // Effects (Drop Shadows, Layer Blurs)
@@ -533,25 +918,32 @@ function pruneFigmaNode(node, context = {}, options = {}) {
533
918
 
534
919
  // Typography (for TEXT nodes)
535
920
  if (node.type === 'TEXT') {
536
- pruned.characters = node.characters;
921
+ pruned.characters = isRawAssetString(node.characters) ? '[raw-asset-omitted]' : node.characters;
537
922
  if (node.style) {
538
- pruned.typography = {
539
- fontFamily: node.style.fontFamily,
540
- fontSize: node.style.fontSize,
541
- fontWeight: node.style.fontWeight,
542
- lineHeightPx: node.style.lineHeightPx ? Math.round(node.style.lineHeightPx) : undefined,
543
- letterSpacing: node.style.letterSpacing,
544
- textAlignHorizontal: node.style.textAlignHorizontal,
545
- textAlignVertical: node.style.textAlignVertical,
546
- };
923
+ const typo = {};
924
+ if (node.style.fontFamily) typo.fontFamily = node.style.fontFamily;
925
+ if (node.style.fontSize) typo.fontSize = node.style.fontSize;
926
+ if (node.style.fontWeight) typo.fontWeight = node.style.fontWeight;
927
+ if (node.style.lineHeightPx) typo.lineHeightPx = Math.round(node.style.lineHeightPx);
928
+ if (node.style.letterSpacing) typo.letterSpacing = node.style.letterSpacing;
929
+ if (node.style.textAlignHorizontal && node.style.textAlignHorizontal !== 'LEFT') {
930
+ typo.textAlignHorizontal = node.style.textAlignHorizontal;
931
+ }
932
+ if (node.style.textAlignVertical && node.style.textAlignVertical !== 'TOP') {
933
+ typo.textAlignVertical = node.style.textAlignVertical;
934
+ }
935
+ if (Object.keys(typo).length > 0) {
936
+ pruned.typography = typo;
937
+ }
547
938
  }
548
939
  }
549
940
 
550
941
  // Recursively process children
551
942
  if (Array.isArray(node.children) && node.children.length > 0) {
552
- pruned.children = node.children
553
- .filter((c) => c.visible !== false)
554
- .map((c) => pruneFigmaNode(c, context, options));
943
+ const visibleChildren = node.children.filter((c) => c && c.visible !== false);
944
+ if (visibleChildren.length > 0) {
945
+ pruned.children = visibleChildren.map((c) => pruneFigmaNode(c, context, options, _depth + 1));
946
+ }
555
947
  }
556
948
 
557
949
  return pruned;
@@ -566,7 +958,7 @@ const MAX_REQUESTS_PER_WINDOW = 5; // Max 5 calls per minute (well below Figma 1
566
958
  const MIN_REQUEST_INTERVAL_MS = 2500; // Minimum 2.5s between consecutive requests
567
959
 
568
960
  function getRateGateFilePath() {
569
- const home = process.env.HOME || (typeof os.homedir === 'function' ? os.homedir() : '');
961
+ const home = resolveHomeDir();
570
962
  return home ? path.join(home, '.figma_ratelimit.json') : path.join(process.cwd(), '.figma_ratelimit.json');
571
963
  }
572
964
 
@@ -1132,15 +1524,24 @@ async function main() {
1132
1524
  process.exit(1);
1133
1525
  }
1134
1526
  const { dictionary } = buildVariableMap(varsMeta);
1135
- console.log(
1136
- JSON.stringify(
1137
- {
1138
- tokens: dictionary,
1139
- },
1140
- null,
1141
- config.pretty ? 2 : 0,
1142
- ),
1527
+ const tokenPayload = JSON.stringify(
1528
+ {
1529
+ tokens: dictionary,
1530
+ },
1531
+ null,
1532
+ config.pretty ? 2 : 0,
1143
1533
  );
1534
+ if (config.specJsonPath) {
1535
+ const jsonDir = path.dirname(path.resolve(config.specJsonPath));
1536
+ if (!fs.existsSync(jsonDir)) {
1537
+ fs.mkdirSync(jsonDir, { recursive: true });
1538
+ }
1539
+ fs.writeFileSync(path.resolve(config.specJsonPath), tokenPayload, 'utf8');
1540
+ const sizeKb = (Buffer.byteLength(tokenPayload, 'utf8') / 1024).toFixed(1);
1541
+ console.log(`\n✔ Saved Figma design tokens to ${config.specJsonPath} (${sizeKb} KB)\n`);
1542
+ } else {
1543
+ console.log(tokenPayload);
1544
+ }
1144
1545
  return;
1145
1546
  }
1146
1547
 
@@ -1236,7 +1637,7 @@ function formatApiError(error) {
1236
1637
  } else if (error.statusCode === 401 || error.statusCode === 403) {
1237
1638
  isTokenExpiredOrInvalid = true;
1238
1639
  errorMessage =
1239
- 'FIGMA_TOKEN_EXPIRED_OR_INVALID: The provided Figma access token is expired, invalid, or unauthorized. Create a new token in Figma Settings -> Personal Access Tokens and export FIGMA_ACCESS_TOKEN or add it to .env.';
1640
+ 'FIGMA_TOKEN_EXPIRED_OR_INVALID: The provided Figma access token is expired, invalid, or unauthorized. Create a new token in Figma Settings -> Personal Access Tokens and set FIGMA_ACCESS_TOKEN in your environment or workspace .env file.';
1240
1641
  } else if (error.statusCode === 429) {
1241
1642
  isRateLimited = true;
1242
1643
  let retryMsg = ' Please wait 30-60 seconds before making more requests.';
@@ -1284,6 +1685,9 @@ function formatApiError(error) {
1284
1685
 
1285
1686
  // Export internal functions for unit testing
1286
1687
  module.exports = {
1688
+ resolveHomeDir,
1689
+ decodeEnvBuffer,
1690
+ getPowerShellProfilePaths,
1287
1691
  loadEnv,
1288
1692
  parseEnvLine,
1289
1693
  getFigmaToken,
@@ -67,7 +67,7 @@ When invoked, the agent systematically executes four phases:
67
67
 
68
68
  ## How to Use the Skill
69
69
 
70
- Simply prompt your AI coding assistant (Claude Code, Cursor, OpenCode, Codex, or Antigravity):
70
+ Simply prompt your AI coding assistant (Claude Code, Kiro, Cursor, OpenCode, Codex, or Antigravity):
71
71
 
72
72
  ```text
73
73
  "Build the component from .specs/transaction-card.json using .specs/transaction-card.png as visual reference"