@pedroab0/skills 1.1.0 → 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.
@@ -261,14 +261,72 @@
261
261
  setTimeout(() => URL.revokeObjectURL(url), 5000);
262
262
  }
263
263
 
264
- function flashButton(btn, text) {
265
- const original = btn.innerHTML;
264
+ function flashButton(btn, text, bgOverride = '') {
265
+ if (!btn.dataset.originalHtml) {
266
+ btn.dataset.originalHtml = btn.innerHTML;
267
+ }
268
+ if (btn._flashTimer) {
269
+ clearTimeout(btn._flashTimer);
270
+ }
266
271
  btn.innerHTML = `<span>${text}</span>`;
267
- btn.style.borderColor = '#10b981';
268
- setTimeout(() => {
269
- btn.innerHTML = original;
272
+ if (bgOverride) {
273
+ btn.style.background = bgOverride;
274
+ } else {
275
+ btn.style.borderColor = '#10b981';
276
+ }
277
+ btn._flashTimer = setTimeout(() => {
278
+ btn.innerHTML = btn.dataset.originalHtml;
279
+ btn.style.background = '';
270
280
  btn.style.borderColor = '';
271
- }, 1500);
281
+ btn._flashTimer = null;
282
+ }, 1800);
283
+ }
284
+
285
+ // Figma plugin UI runs inside a null-origin sandboxed iframe where navigator.clipboard
286
+ // is blocked by Permissions-Policy. Synchronous execCommand('copy') is the official
287
+ // cross-browser / Figma Desktop clipboard mechanism, with Async Clipboard as fallback.
288
+ function copyUsingExecCommand(text) {
289
+ const textarea = document.createElement('textarea');
290
+ textarea.value = text;
291
+ textarea.setAttribute('readonly', '');
292
+ textarea.style.position = 'fixed';
293
+ textarea.style.top = '0';
294
+ textarea.style.left = '0';
295
+ textarea.style.width = '1px';
296
+ textarea.style.height = '1px';
297
+ textarea.style.padding = '0';
298
+ textarea.style.border = 'none';
299
+ textarea.style.outline = 'none';
300
+ textarea.style.boxShadow = 'none';
301
+ textarea.style.background = 'transparent';
302
+ textarea.style.opacity = '0';
303
+ textarea.style.pointerEvents = 'none';
304
+ document.body.appendChild(textarea);
305
+ textarea.focus({ preventScroll: true });
306
+ textarea.select();
307
+ textarea.setSelectionRange(0, textarea.value.length);
308
+ let copied = false;
309
+ try {
310
+ copied = document.execCommand('copy');
311
+ } catch (_) {
312
+ copied = false;
313
+ } finally {
314
+ document.body.removeChild(textarea);
315
+ }
316
+ return copied;
317
+ }
318
+
319
+ async function copyTextToClipboard(text) {
320
+ if (copyUsingExecCommand(text)) {
321
+ return true;
322
+ }
323
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
324
+ try {
325
+ await navigator.clipboard.writeText(text);
326
+ return true;
327
+ } catch (_) {}
328
+ }
329
+ return false;
272
330
  }
273
331
 
274
332
  // Pure vanilla zero-dependency ZIP archive creator (Store format)
@@ -369,7 +427,7 @@
369
427
  document.getElementById('node-bounds').textContent = `${w} × ${h} px`;
370
428
  document.getElementById('hint-name').textContent = msg.name || 'component';
371
429
 
372
- const jsonStr = JSON.stringify(msg.spec || {}, null, 2);
430
+ const jsonStr = JSON.stringify(msg.spec || {});
373
431
  const sizeKb = (new Blob([jsonStr]).size / 1024).toFixed(1);
374
432
  document.getElementById('node-size').textContent = `${sizeKb} KB`;
375
433
 
@@ -414,30 +472,25 @@
414
472
  }
415
473
  };
416
474
 
417
- // Copy to clipboard
475
+ // Copy to clipboard (compact pruned JSON matching CLI default)
418
476
  document.getElementById('btn-copy').onclick = async () => {
419
477
  if (!currentData) return;
420
- const jsonStr = JSON.stringify(currentData.spec, null, 2);
421
- try {
422
- await navigator.clipboard.writeText(jsonStr);
423
- const btn = document.getElementById('btn-copy');
424
- const originalText = btn.innerHTML;
425
- btn.innerHTML = '<span>✔ Copied Node JSON!</span>';
426
- btn.style.background = '#16a34a';
478
+ const jsonStr = JSON.stringify(currentData.spec);
479
+ const btn = document.getElementById('btn-copy');
480
+ const ok = await copyTextToClipboard(jsonStr);
481
+ if (ok) {
482
+ flashButton(btn, '✔ Copied Node JSON!', '#16a34a');
427
483
  parent.postMessage({ pluginMessage: { type: 'NOTIFY', message: `Copied "${currentData.name}.json" to clipboard!` } }, '*');
428
- setTimeout(() => {
429
- btn.innerHTML = originalText;
430
- btn.style.background = '';
431
- }, 2000);
432
- } catch (err) {
433
- console.error('Clipboard failed', err);
484
+ } else {
485
+ flashButton(btn, '✖ Copy failed — use .json', '#dc2626');
486
+ parent.postMessage({ pluginMessage: { type: 'NOTIFY', message: 'Failed to access clipboard. Please download .json instead.', error: true } }, '*');
434
487
  }
435
488
  };
436
489
 
437
490
  // Download JSON
438
491
  document.getElementById('btn-download-json').onclick = () => {
439
492
  if (!currentData) return;
440
- const jsonStr = JSON.stringify(currentData.spec, null, 2);
493
+ const jsonStr = JSON.stringify(currentData.spec);
441
494
  const blob = new Blob([jsonStr], { type: 'application/json' });
442
495
  triggerDownload(blob, `${currentData.name}.json`);
443
496
  flashButton(document.getElementById('btn-download-json'), '✔ Saved .json');
@@ -453,7 +506,7 @@
453
506
  // Download Both (.specs.zip)
454
507
  document.getElementById('btn-download-both').onclick = () => {
455
508
  if (!currentData) return;
456
- const jsonStr = JSON.stringify(currentData.spec, null, 2);
509
+ const jsonStr = JSON.stringify(currentData.spec);
457
510
  const enc = new TextEncoder();
458
511
  const files = [
459
512
  { name: `${currentData.name}.json`, data: enc.encode(jsonStr) }
@@ -551,13 +551,234 @@ function normalizeComponentProps(componentProperties) {
551
551
  // AST Pruning & Token Optimization
552
552
  // -----------------------------------------------------------------------------
553
553
 
554
- 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) {
555
749
  if (!node || typeof node !== 'object') return node;
556
750
 
557
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
+ }
558
779
 
559
780
  const pruned = {
560
- name: node.name,
781
+ name: cleanName,
561
782
  type: node.type,
562
783
  };
563
784
 
@@ -567,16 +788,14 @@ function pruneFigmaNode(node, context = {}, options = {}) {
567
788
 
568
789
  if (node.visible === false) pruned.visible = false;
569
790
 
570
- const bound = node.boundVariables || {};
571
-
572
791
  // Component Instance & Master Resolution
573
792
  if (node.type === 'INSTANCE') {
574
793
  if (node.componentId && components && components[node.componentId]) {
575
794
  const master = components[node.componentId];
576
795
  const set = master.componentSetId && componentSets ? componentSets[master.componentSetId] : null;
577
796
  pruned.component = {
578
- name: set ? set.name : master.name,
579
- variant: master.name,
797
+ name: cleanLayerName(set ? set.name : master.name, cleanName),
798
+ variant: cleanLayerName(master.name, cleanName),
580
799
  };
581
800
  if (options.includeIds) {
582
801
  pruned.component.id = node.componentId;
@@ -588,12 +807,7 @@ function pruneFigmaNode(node, context = {}, options = {}) {
588
807
 
589
808
  // Shallow instances: skip internal rendering sub-layers if requested
590
809
  if (options.shallowInstances) {
591
- if (node.absoluteBoundingBox) {
592
- pruned.bounds = {
593
- width: Math.round(node.absoluteBoundingBox.width),
594
- height: Math.round(node.absoluteBoundingBox.height),
595
- };
596
- }
810
+ if (bounds) pruned.bounds = bounds;
597
811
  return pruned;
598
812
  }
599
813
  } else if (node.type === 'COMPONENT' || node.type === 'COMPONENT_SET') {
@@ -601,11 +815,42 @@ function pruneFigmaNode(node, context = {}, options = {}) {
601
815
  if (props) pruned.props = props;
602
816
  }
603
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
+
604
845
  // Layout & Flexbox properties
605
846
  if (node.layoutMode && node.layoutMode !== 'NONE') {
606
847
  pruned.layoutMode = node.layoutMode;
607
- if (node.primaryAxisAlignItems) pruned.primaryAxisAlignItems = node.primaryAxisAlignItems;
608
- 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
+ }
609
854
 
610
855
  if (node.itemSpacing) {
611
856
  pruned.itemSpacing = node.itemSpacing;
@@ -626,18 +871,15 @@ function pruneFigmaNode(node, context = {}, options = {}) {
626
871
  if (padToken) pruned.paddingToken = padToken;
627
872
  }
628
873
 
629
- if (node.layoutWrap) pruned.layoutWrap = node.layoutWrap;
874
+ if (node.layoutWrap && node.layoutWrap !== 'NO_WRAP') pruned.layoutWrap = node.layoutWrap;
630
875
  }
631
876
 
632
877
  if (node.layoutGrow) pruned.layoutGrow = node.layoutGrow;
633
- if (node.layoutAlign) pruned.layoutAlign = node.layoutAlign;
878
+ if (node.layoutAlign && node.layoutAlign !== 'INHERIT') pruned.layoutAlign = node.layoutAlign;
634
879
 
635
880
  // Dimensions & Coordinates
636
- if (node.absoluteBoundingBox) {
637
- pruned.bounds = {
638
- width: Math.round(node.absoluteBoundingBox.width),
639
- height: Math.round(node.absoluteBoundingBox.height),
640
- };
881
+ if (bounds) {
882
+ pruned.bounds = bounds;
641
883
  }
642
884
 
643
885
  if (node.minWidth) pruned.minWidth = node.minWidth;
@@ -656,8 +898,9 @@ function pruneFigmaNode(node, context = {}, options = {}) {
656
898
  pruned.cornerRadius = node.cornerRadius;
657
899
  const radiusToken = resolveVariableToken(bound.cornerRadius, varMap);
658
900
  if (radiusToken) pruned.cornerRadiusToken = radiusToken;
901
+ } else if (Array.isArray(node.rectangleCornerRadii) && node.rectangleCornerRadii.some((r) => r > 0)) {
902
+ pruned.cornerRadii = node.rectangleCornerRadii;
659
903
  }
660
- if (node.rectangleCornerRadii) pruned.cornerRadii = node.rectangleCornerRadii;
661
904
  if (node.opacity !== undefined && node.opacity !== 1) pruned.opacity = node.opacity;
662
905
 
663
906
  // Effects (Drop Shadows, Layer Blurs)
@@ -675,25 +918,32 @@ function pruneFigmaNode(node, context = {}, options = {}) {
675
918
 
676
919
  // Typography (for TEXT nodes)
677
920
  if (node.type === 'TEXT') {
678
- pruned.characters = node.characters;
921
+ pruned.characters = isRawAssetString(node.characters) ? '[raw-asset-omitted]' : node.characters;
679
922
  if (node.style) {
680
- pruned.typography = {
681
- fontFamily: node.style.fontFamily,
682
- fontSize: node.style.fontSize,
683
- fontWeight: node.style.fontWeight,
684
- lineHeightPx: node.style.lineHeightPx ? Math.round(node.style.lineHeightPx) : undefined,
685
- letterSpacing: node.style.letterSpacing,
686
- textAlignHorizontal: node.style.textAlignHorizontal,
687
- textAlignVertical: node.style.textAlignVertical,
688
- };
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
+ }
689
938
  }
690
939
  }
691
940
 
692
941
  // Recursively process children
693
942
  if (Array.isArray(node.children) && node.children.length > 0) {
694
- pruned.children = node.children
695
- .filter((c) => c.visible !== false)
696
- .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
+ }
697
947
  }
698
948
 
699
949
  return pruned;