@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
@@ -111,35 +111,353 @@ function safeGet(obj, prop, fallback = undefined) {
111
111
  }
112
112
  }
113
113
 
114
- function serializeNode(node, depth = 0) {
114
+ // Safely resolve TextNode properties with automatic fallback to character range (0, 1) when figma.mixed
115
+ function safeGetTextProp(node, prop, rangeMethod, hasChars) {
116
+ const direct = safeGet(node, prop);
117
+ if (direct !== undefined) return direct;
118
+ if (hasChars && rangeMethod && typeof node[rangeMethod] === 'function') {
119
+ try {
120
+ const ranged = node[rangeMethod](0, 1);
121
+ if (typeof ranged !== 'symbol' && ranged !== undefined) {
122
+ return ranged;
123
+ }
124
+ } catch (_) {}
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ function resolveFontWeight(explicitWeight, fontStyle) {
130
+ if (typeof explicitWeight === 'number' && explicitWeight > 0) {
131
+ return explicitWeight;
132
+ }
133
+ if (!fontStyle || typeof fontStyle !== 'string') return undefined;
134
+ const s = fontStyle.toLowerCase();
135
+ if (/thin|hairline/.test(s)) return 100;
136
+ if (/extra\s*light|ultra\s*light/.test(s)) return 200;
137
+ if (/light/.test(s)) return 300;
138
+ if (/semi\s*bold|demi\s*bold/.test(s)) return 600;
139
+ if (/extra\s*bold|ultra\s*bold/.test(s)) return 800;
140
+ if (/bold/.test(s)) return 700;
141
+ if (/black|heavy/.test(s)) return 900;
142
+ if (/medium/.test(s)) return 500;
143
+ if (/regular|normal|book|roman/.test(s)) return 400;
144
+ return undefined;
145
+ }
146
+
147
+ const VECTOR_PRIMITIVE_TYPES = new Set([
148
+ 'VECTOR',
149
+ 'BOOLEAN_OPERATION',
150
+ 'STAR',
151
+ 'LINE',
152
+ 'REGULAR_POLYGON',
153
+ 'POLYGON',
154
+ ]);
155
+
156
+ function cleanLayerName(rawName, fallback = 'Node') {
157
+ if (!rawName || typeof rawName !== 'string') return fallback;
158
+ const trimmed = rawName.trim();
159
+ if (!trimmed) return fallback;
160
+ if (/^data:image\//i.test(trimmed)) return 'Image';
161
+ if (/^(?:<svg\b|<\?xml\b)/i.test(trimmed)) return 'SVG';
162
+ if (/^https?:\/\//i.test(trimmed) && trimmed.length > 60) {
163
+ try {
164
+ const lastSeg = trimmed.split('?')[0].split('/').pop();
165
+ return (lastSeg && lastSeg.length <= 60) ? lastSeg : 'Image';
166
+ } catch (_) {
167
+ return 'Image';
168
+ }
169
+ }
170
+ const singleLine = trimmed.replace(/\s+/g, ' ');
171
+ return singleLine.length > 80 ? singleLine.slice(0, 80) + '…' : singleLine;
172
+ }
173
+
174
+ function isRawAssetString(str) {
175
+ if (!str || typeof str !== 'string') return false;
176
+ const t = str.trim();
177
+ return /^data:image\//i.test(t) || /^(?:<svg\b|<\?xml\b)/i.test(t);
178
+ }
179
+
180
+ function nodeHasImageFill(node) {
181
+ const fills = safeGet(node, 'fills');
182
+ if (!Array.isArray(fills)) return false;
183
+ return fills.some((f) => f && f.visible !== false && f.type === 'IMAGE');
184
+ }
185
+
186
+ function getVisibleChildren(node) {
187
+ const children = safeGet(node, 'children');
188
+ if (!Array.isArray(children) || children.length === 0) return [];
189
+ return children.filter((c) => {
190
+ try {
191
+ return c && safeGet(c, 'visible', true) !== false;
192
+ } catch (_) {
193
+ return false;
194
+ }
195
+ });
196
+ }
197
+
198
+ const GRAPHIC_NAME_RE =
199
+ /svg|icon|ícone|icone|logo|marca|brand|illustrat|ilustra|vector|vetor|symbol|simbol|símbolo|glyph|graphic|ornament|pattern|artwork/i;
200
+
201
+ function hasSvgExportSetting(node) {
202
+ const settings = safeGet(node, 'exportSettings');
203
+ if (!Array.isArray(settings) || settings.length === 0) return false;
204
+ return settings.some((s) => s && String(safeGet(s, 'format', '')).toUpperCase() === 'SVG');
205
+ }
206
+
207
+ function subtreeHasNoInstances(node, maxNodes = 400) {
208
+ let visited = 0;
209
+ function walk(curr, isRoot) {
210
+ if (++visited > maxNodes) return false;
211
+ const t = safeGet(curr, 'type', '');
212
+ if (!isRoot && (t === 'INSTANCE' || t === 'COMPONENT' || t === 'COMPONENT_SET')) return false;
213
+ for (const k of getVisibleChildren(curr)) {
214
+ if (!walk(k, false)) return false;
215
+ }
216
+ return true;
217
+ }
218
+ return walk(node, true);
219
+ }
220
+
221
+ // Detects whether a container (GROUP, FRAME, INSTANCE, COMPONENT) is purely an SVG / icon / vector illustration
222
+ function analyzeVectorSubtree(node, maxNodes = 600) {
223
+ let visited = 0;
224
+ let vectorPrimitiveCount = 0;
225
+ let shapeCount = 0;
226
+ let plainRectCount = 0;
227
+ let primarySolidColor = null;
228
+
229
+ const rootType = safeGet(node, 'type', '');
230
+ const hasSvgExport = hasSvgExportSetting(node);
231
+
232
+ // An Auto-Layout FRAME with its own styling (e.g., Icon Badge) should keep its wrapper unless marked as SVG export
233
+ if (rootType === 'FRAME' && !hasSvgExport) {
234
+ const rootLm = safeGet(node, 'layoutMode');
235
+ if (rootLm && rootLm !== 'NONE') {
236
+ const ownFills = simplifyFills(safeGet(node, 'fills'));
237
+ const ownStrokes = simplifyStrokes(safeGet(node, 'strokes'), safeGet(node, 'strokeWeight'), safeGet(node, 'strokeAlign'));
238
+ const ownCr = safeGet(node, 'cornerRadius');
239
+ if (ownFills || ownStrokes || (typeof ownCr === 'number' && ownCr > 0)) {
240
+ return { isPure: false, primarySolidColor: null };
241
+ }
242
+ }
243
+ }
244
+
245
+ function check(curr, isRoot) {
246
+ if (++visited > maxNodes) return false;
247
+ const t = safeGet(curr, 'type', '');
248
+ if (t === 'TEXT' || t === 'SLICE' || t === 'STICKY' || t === 'TABLE') return false;
249
+ if (!isRoot && (t === 'INSTANCE' || t === 'COMPONENT' || t === 'COMPONENT_SET')) return false;
250
+ if (nodeHasImageFill(curr)) return false;
251
+
252
+ if (!primarySolidColor) {
253
+ const fills = simplifyFills(safeGet(curr, 'fills'));
254
+ const solidFill = fills && fills.find((f) => f.type === 'SOLID' && f.color);
255
+ if (solidFill) {
256
+ primarySolidColor = solidFill.color;
257
+ } else {
258
+ const strokes = simplifyStrokes(safeGet(curr, 'strokes'), safeGet(curr, 'strokeWeight'), safeGet(curr, 'strokeAlign'));
259
+ if (strokes && strokes[0] && strokes[0].color) {
260
+ primarySolidColor = strokes[0].color;
261
+ }
262
+ }
263
+ }
264
+
265
+ if (VECTOR_PRIMITIVE_TYPES.has(t)) {
266
+ vectorPrimitiveCount++;
267
+ return true;
268
+ }
269
+
270
+ if (t === 'ELLIPSE') {
271
+ shapeCount++;
272
+ return true;
273
+ }
274
+
275
+ if (t === 'RECTANGLE') {
276
+ const isMask = safeGet(curr, 'isMask') === true;
277
+ const rName = String(safeGet(curr, 'name', ''));
278
+ if (isMask || /clip|mask|vector|path|rect|bound|bg/i.test(rName)) {
279
+ shapeCount++;
280
+ } else {
281
+ plainRectCount++;
282
+ }
283
+ return true;
284
+ }
285
+
286
+ if (t === 'GROUP' || t === 'FRAME' || (isRoot && (t === 'INSTANCE' || t === 'COMPONENT'))) {
287
+ if (!isRoot && t === 'FRAME') {
288
+ const lm = safeGet(curr, 'layoutMode');
289
+ if (lm && lm !== 'NONE') return false;
290
+ }
291
+ const kids = getVisibleChildren(curr);
292
+ if (kids.length === 0) return !isRoot;
293
+ for (const k of kids) {
294
+ if (!check(k, false)) return false;
295
+ }
296
+ return true;
297
+ }
298
+
299
+ return false;
300
+ }
301
+
302
+ const treeOk = check(node, true);
303
+ if (!treeOk) {
304
+ // Explicit SVG export setting on a self-contained graphic/logo (even if it contains logo <text>)
305
+ const w = safeGet(node, 'width', 0);
306
+ const h = safeGet(node, 'height', 0);
307
+ if (
308
+ hasSvgExport &&
309
+ typeof w === 'number' &&
310
+ typeof h === 'number' &&
311
+ w <= 800 &&
312
+ h <= 800 &&
313
+ !nodeHasImageFill(node) &&
314
+ subtreeHasNoInstances(node)
315
+ ) {
316
+ return { isPure: true, primarySolidColor };
317
+ }
318
+ return { isPure: false, primarySolidColor: null };
319
+ }
320
+
321
+ const w = safeGet(node, 'width', 9999);
322
+ const h = safeGet(node, 'height', 9999);
323
+ const isSmallGraphic = typeof w === 'number' && typeof h === 'number' && w <= 160 && h <= 160;
324
+ const nodeName = String(safeGet(node, 'name', ''));
325
+ const hasGraphicName = GRAPHIC_NAME_RE.test(nodeName);
326
+
327
+ const hasEnoughPrimitives =
328
+ vectorPrimitiveCount > 0 ||
329
+ (shapeCount + plainRectCount >= 2 && (hasSvgExport || (isSmallGraphic && hasGraphicName)));
330
+
331
+ const rectGuardOk =
332
+ plainRectCount === 0 ||
333
+ vectorPrimitiveCount >= 2 ||
334
+ isSmallGraphic ||
335
+ hasGraphicName ||
336
+ hasSvgExport;
337
+
338
+ const isPure = hasEnoughPrimitives && rectGuardOk;
339
+ return { isPure, primarySolidColor };
340
+ }
341
+
342
+ // Detects masked image groups (e.g., Mask group with photo + mask shape and zero UI text/components)
343
+ function analyzeMaskedImageSubtree(node) {
344
+ const t = safeGet(node, 'type', '');
345
+ if (t !== 'GROUP' && t !== 'FRAME') return null;
346
+ const lm = safeGet(node, 'layoutMode');
347
+ if (lm && lm !== 'NONE') return null;
348
+
349
+ const kids = getVisibleChildren(node);
350
+ if (kids.length === 0 || kids.length > 20) return null;
351
+
352
+ let foundImageScaleMode = null;
353
+ let hasMaskOrClip = false;
354
+
355
+ function scan(curr) {
356
+ const ct = safeGet(curr, 'type', '');
357
+ if (ct === 'TEXT' || ct === 'INSTANCE' || ct === 'COMPONENT') return false;
358
+ if (safeGet(curr, 'isMask') === true) hasMaskOrClip = true;
359
+
360
+ const fills = safeGet(curr, 'fills');
361
+ if (Array.isArray(fills)) {
362
+ for (const f of fills) {
363
+ if (f && f.visible !== false && f.type === 'IMAGE') {
364
+ foundImageScaleMode = f.scaleMode || 'FILL';
365
+ }
366
+ }
367
+ }
368
+
369
+ const sub = getVisibleChildren(curr);
370
+ for (const s of sub) {
371
+ if (!scan(s)) return false;
372
+ }
373
+ return true;
374
+ }
375
+
376
+ if (!scan(node) || !foundImageScaleMode) return null;
377
+ if (!hasMaskOrClip && !/mask|image|img|photo|avatar|banner|thumb/i.test(String(safeGet(node, 'name', '')))) {
378
+ return null;
379
+ }
380
+ return { scaleMode: foundImageScaleMode };
381
+ }
382
+
383
+ function assignCleanCornerRadius(node, pruned) {
384
+ const cornerRadius = safeGet(node, 'cornerRadius');
385
+ if (typeof cornerRadius === 'number' && cornerRadius > 0) {
386
+ pruned.cornerRadius = Math.round(cornerRadius * 100) / 100;
387
+ return;
388
+ }
389
+ const tl = safeGet(node, 'topLeftRadius');
390
+ const tr = safeGet(node, 'topRightRadius');
391
+ const br = safeGet(node, 'bottomRightRadius');
392
+ const bl = safeGet(node, 'bottomLeftRadius');
393
+ const r0 = typeof tl === 'number' && tl > 0 ? Math.round(tl * 100) / 100 : 0;
394
+ const r1 = typeof tr === 'number' && tr > 0 ? Math.round(tr * 100) / 100 : 0;
395
+ const r2 = typeof br === 'number' && br > 0 ? Math.round(br * 100) / 100 : 0;
396
+ const r3 = typeof bl === 'number' && bl > 0 ? Math.round(bl * 100) / 100 : 0;
397
+ if (r0 > 0 || r1 > 0 || r2 > 0 || r3 > 0) {
398
+ if (r0 === r1 && r1 === r2 && r2 === r3) {
399
+ pruned.cornerRadius = r0;
400
+ } else {
401
+ pruned.cornerRadii = [r0, r1, r2, r3];
402
+ }
403
+ }
404
+ }
405
+
406
+ async function serializeNode(node, depth = 0) {
115
407
  if (!node) return null;
116
408
  if (depth > 25) {
117
- return { name: safeGet(node, 'name', 'Node'), type: safeGet(node, 'type', 'FRAME') };
409
+ return { name: cleanLayerName(safeGet(node, 'name'), 'Node'), type: safeGet(node, 'type', 'FRAME') };
118
410
  }
119
411
 
120
412
  try {
121
413
  const nodeType = safeGet(node, 'type', 'FRAME');
122
- const pruned = {
123
- name: safeGet(node, 'name', 'Node'),
124
- type: nodeType,
125
- };
126
-
127
- if (safeGet(node, 'visible') === false) pruned.visible = false;
414
+ const cleanName = cleanLayerName(safeGet(node, 'name'), 'Node');
128
415
 
129
416
  // Dimensions & Coordinates
130
417
  const width = safeGet(node, 'width');
131
418
  const height = safeGet(node, 'height');
132
- if (typeof width === 'number' && typeof height === 'number') {
133
- pruned.bounds = {
134
- width: Math.round(width),
135
- height: Math.round(height),
136
- };
419
+ const bounds =
420
+ typeof width === 'number' && typeof height === 'number'
421
+ ? { width: Math.round(width), height: Math.round(height) }
422
+ : undefined;
423
+
424
+ // 1. Leaf Vector Primitives (VECTOR, BOOLEAN_OPERATION, STAR, LINE, POLYGON) — never dump sub-paths
425
+ if (VECTOR_PRIMITIVE_TYPES.has(nodeType)) {
426
+ const vecNode = { name: cleanName, type: 'VECTOR' };
427
+ if (bounds) vecNode.bounds = bounds;
428
+ const fills = simplifyFills(safeGet(node, 'fills'));
429
+ const solidFill = fills && fills.find((f) => f.type === 'SOLID' && f.color);
430
+ if (solidFill) {
431
+ vecNode.fills = [{ type: 'SOLID', color: solidFill.color }];
432
+ } else {
433
+ const strokes = simplifyStrokes(safeGet(node, 'strokes'), safeGet(node, 'strokeWeight'), safeGet(node, 'strokeAlign'));
434
+ if (strokes && strokes[0] && strokes[0].color) {
435
+ vecNode.fills = [{ type: 'SOLID', color: strokes[0].color }];
436
+ }
437
+ }
438
+ return vecNode;
137
439
  }
138
440
 
139
- // Component Instances & Variants
441
+ const pruned = {
442
+ name: cleanName,
443
+ type: nodeType,
444
+ };
445
+
446
+ if (safeGet(node, 'visible') === false) pruned.visible = false;
447
+ if (bounds) pruned.bounds = bounds;
448
+
449
+ // Component Instances & Variants (compatible with dynamic-page getMainComponentAsync)
140
450
  if (nodeType === 'INSTANCE') {
141
451
  try {
142
- const main = safeGet(node, 'mainComponent');
452
+ let main = null;
453
+ if (typeof node.getMainComponentAsync === 'function') {
454
+ try {
455
+ main = await node.getMainComponentAsync();
456
+ } catch (_) {}
457
+ }
458
+ if (!main) {
459
+ main = safeGet(node, 'mainComponent');
460
+ }
143
461
  if (main) {
144
462
  let setName = null;
145
463
  try {
@@ -150,8 +468,8 @@ function serializeNode(node, depth = 0) {
150
468
  } catch (_) {}
151
469
 
152
470
  pruned.component = {
153
- name: setName || safeGet(main, 'name', pruned.name),
154
- variant: safeGet(main, 'name', pruned.name),
471
+ name: cleanLayerName(setName || safeGet(main, 'name'), pruned.name),
472
+ variant: cleanLayerName(safeGet(main, 'name'), pruned.name),
155
473
  };
156
474
  }
157
475
  } catch (_) {
@@ -171,35 +489,64 @@ function serializeNode(node, depth = 0) {
171
489
  } catch (_) {}
172
490
  }
173
491
 
492
+ // 2. Collapse Pure SVG / Icon / Vector Graphic Containers (eliminates hundreds of internal Vector nodes)
493
+ if (depth > 0 || nodeType === 'GROUP' || nodeType === 'INSTANCE') {
494
+ const vecCheck = analyzeVectorSubtree(node);
495
+ if (vecCheck.isPure) {
496
+ if (nodeType !== 'INSTANCE' && nodeType !== 'COMPONENT') {
497
+ pruned.type = 'VECTOR';
498
+ pruned.isSvg = true;
499
+ } else {
500
+ assignCleanCornerRadius(node, pruned);
501
+ }
502
+ const ownFills = simplifyFills(safeGet(node, 'fills'));
503
+ if (ownFills) {
504
+ pruned.fills = ownFills;
505
+ } else if (vecCheck.primarySolidColor) {
506
+ pruned.fills = [{ type: 'SOLID', color: vecCheck.primarySolidColor }];
507
+ }
508
+ return pruned;
509
+ }
510
+ }
511
+
512
+ // 3. Collapse Masked Image Groups (photo + mask shape with zero UI text/components)
513
+ if (depth > 0) {
514
+ const imgGroup = analyzeMaskedImageSubtree(node);
515
+ if (imgGroup) {
516
+ pruned.fills = [{ type: 'IMAGE', scaleMode: imgGroup.scaleMode }];
517
+ assignCleanCornerRadius(node, pruned);
518
+ return pruned;
519
+ }
520
+ }
521
+
174
522
  // Auto-Layout (Flexbox)
175
523
  const layoutMode = safeGet(node, 'layoutMode');
176
524
  if (layoutMode && layoutMode !== 'NONE') {
177
525
  pruned.layoutMode = layoutMode;
178
526
  const primaryAlign = safeGet(node, 'primaryAxisAlignItems');
179
527
  const counterAlign = safeGet(node, 'counterAxisAlignItems');
180
- if (primaryAlign) pruned.primaryAxisAlignItems = primaryAlign;
181
- if (counterAlign) pruned.counterAxisAlignItems = counterAlign;
528
+ if (primaryAlign && primaryAlign !== 'MIN') pruned.primaryAxisAlignItems = primaryAlign;
529
+ if (counterAlign && counterAlign !== 'MIN') pruned.counterAxisAlignItems = counterAlign;
182
530
 
183
531
  const itemSpacing = safeGet(node, 'itemSpacing');
184
532
  if (typeof itemSpacing === 'number' && itemSpacing !== 0) {
185
- pruned.itemSpacing = itemSpacing;
533
+ pruned.itemSpacing = Math.round(itemSpacing * 100) / 100;
186
534
  }
187
535
 
188
536
  const pTop = safeGet(node, 'paddingTop');
189
537
  const pRight = safeGet(node, 'paddingRight');
190
538
  const pBottom = safeGet(node, 'paddingBottom');
191
539
  const pLeft = safeGet(node, 'paddingLeft');
192
- if (pTop !== undefined || pRight !== undefined || pBottom !== undefined || pLeft !== undefined) {
193
- pruned.padding = {
194
- top: typeof pTop === 'number' ? pTop : 0,
195
- right: typeof pRight === 'number' ? pRight : 0,
196
- bottom: typeof pBottom === 'number' ? pBottom : 0,
197
- left: typeof pLeft === 'number' ? pLeft : 0,
198
- };
540
+ const top = typeof pTop === 'number' ? pTop : 0;
541
+ const right = typeof pRight === 'number' ? pRight : 0;
542
+ const bottom = typeof pBottom === 'number' ? pBottom : 0;
543
+ const left = typeof pLeft === 'number' ? pLeft : 0;
544
+ if (top > 0 || right > 0 || bottom > 0 || left > 0) {
545
+ pruned.padding = { top, right, bottom, left };
199
546
  }
200
547
 
201
548
  const layoutWrap = safeGet(node, 'layoutWrap');
202
- if (layoutWrap) pruned.layoutWrap = layoutWrap;
549
+ if (layoutWrap && layoutWrap !== 'NO_WRAP') pruned.layoutWrap = layoutWrap;
203
550
  }
204
551
 
205
552
  const layoutGrow = safeGet(node, 'layoutGrow');
@@ -223,27 +570,11 @@ function serializeNode(node, depth = 0) {
223
570
  );
224
571
  if (strokes) pruned.strokes = strokes;
225
572
 
226
- // Corner Radius
227
- const cornerRadius = safeGet(node, 'cornerRadius');
228
- if (typeof cornerRadius === 'number' && cornerRadius > 0) {
229
- pruned.cornerRadius = cornerRadius;
230
- } else {
231
- const tl = safeGet(node, 'topLeftRadius');
232
- const tr = safeGet(node, 'topRightRadius');
233
- const br = safeGet(node, 'bottomRightRadius');
234
- const bl = safeGet(node, 'bottomLeftRadius');
235
- if (typeof tl === 'number' || typeof tr === 'number' || typeof br === 'number' || typeof bl === 'number') {
236
- pruned.cornerRadii = [
237
- typeof tl === 'number' ? tl : 0,
238
- typeof tr === 'number' ? tr : 0,
239
- typeof br === 'number' ? br : 0,
240
- typeof bl === 'number' ? bl : 0,
241
- ];
242
- }
243
- }
573
+ // Corner Radius (only when > 0)
574
+ assignCleanCornerRadius(node, pruned);
244
575
 
245
576
  const opacity = safeGet(node, 'opacity');
246
- if (typeof opacity === 'number' && opacity !== 1) {
577
+ if (typeof opacity === 'number' && opacity < 0.999) {
247
578
  pruned.opacity = Number(opacity.toFixed(2));
248
579
  }
249
580
 
@@ -252,58 +583,101 @@ function serializeNode(node, depth = 0) {
252
583
  if (Array.isArray(effects) && effects.length > 0) {
253
584
  const visibleEffects = effects.filter((e) => e && safeGet(e, 'visible', true) !== false);
254
585
  if (visibleEffects.length > 0) {
255
- pruned.effects = visibleEffects.map((e) => ({
256
- type: safeGet(e, 'type'),
257
- radius: safeGet(e, 'radius'),
258
- color: safeGet(e, 'color') ? formatRgba(safeGet(e, 'color')) : undefined,
259
- offset: safeGet(e, 'offset'),
260
- }));
586
+ pruned.effects = visibleEffects.map((e) => {
587
+ const eff = { type: safeGet(e, 'type') };
588
+ const rad = safeGet(e, 'radius');
589
+ if (typeof rad === 'number' && rad > 0) eff.radius = rad;
590
+ const col = safeGet(e, 'color') ? formatRgba(safeGet(e, 'color')) : undefined;
591
+ if (col) eff.color = col;
592
+ const off = safeGet(e, 'offset');
593
+ if (off && (off.x || off.y)) eff.offset = off;
594
+ return eff;
595
+ });
261
596
  }
262
597
  }
263
598
 
264
599
  // Typography (TEXT)
265
600
  if (nodeType === 'TEXT') {
266
- pruned.characters = safeGet(node, 'characters', '');
267
- const font = safeGet(node, 'fontName');
268
- const lineHeight = safeGet(node, 'lineHeight');
269
- const letterSpacing = safeGet(node, 'letterSpacing');
270
- const fontSize = safeGet(node, 'fontSize');
271
-
272
- pruned.typography = {
273
- fontFamily: font && typeof font === 'object' ? safeGet(font, 'family') : undefined,
274
- fontStyle: font && typeof font === 'object' ? safeGet(font, 'style') : undefined,
275
- fontSize: typeof fontSize === 'number' ? fontSize : undefined,
276
- lineHeightPx:
277
- lineHeight && typeof lineHeight === 'object' && safeGet(lineHeight, 'unit') === 'PIXELS'
278
- ? Math.round(safeGet(lineHeight, 'value', 0))
279
- : undefined,
280
- letterSpacing:
281
- letterSpacing && typeof letterSpacing === 'object' ? safeGet(letterSpacing, 'value') : undefined,
282
- textAlignHorizontal: safeGet(node, 'textAlignHorizontal'),
283
- textAlignVertical: safeGet(node, 'textAlignVertical'),
284
- };
601
+ const rawChars = safeGet(node, 'characters', '');
602
+ const hasChars = typeof rawChars === 'string' && rawChars.length > 0;
603
+ pruned.characters = isRawAssetString(rawChars) ? '[raw-asset-omitted]' : rawChars;
604
+
605
+ // Fallback fills when TextNode has mixed character colors (figma.mixed)
606
+ if (!pruned.fills) {
607
+ const rangeFills = simplifyFills(safeGetTextProp(node, 'fills', 'getRangeFills', hasChars));
608
+ if (rangeFills) pruned.fills = rangeFills;
609
+ }
610
+
611
+ const font = safeGetTextProp(node, 'fontName', 'getRangeFontName', hasChars);
612
+ const lineHeight = safeGetTextProp(node, 'lineHeight', 'getRangeLineHeight', hasChars);
613
+ const letterSpacing = safeGetTextProp(node, 'letterSpacing', 'getRangeLetterSpacing', hasChars);
614
+ const fontSize = safeGetTextProp(node, 'fontSize', 'getRangeFontSize', hasChars);
615
+ const rawWeight = safeGetTextProp(node, 'fontWeight', 'getRangeFontWeight', hasChars);
616
+
617
+ const typo = {};
618
+ const family = font && typeof font === 'object' ? safeGet(font, 'family') : undefined;
619
+ const fontStyle = font && typeof font === 'object' ? safeGet(font, 'style') : undefined;
620
+ if (family) typo.fontFamily = family;
621
+ if (fontStyle) typo.fontStyle = fontStyle;
622
+
623
+ const resolvedWeight = resolveFontWeight(rawWeight, fontStyle);
624
+ if (resolvedWeight) typo.fontWeight = resolvedWeight;
625
+
626
+ if (typeof fontSize === 'number') {
627
+ typo.fontSize = Math.round(fontSize * 100) / 100;
628
+ }
629
+ if (lineHeight && typeof lineHeight === 'object') {
630
+ const lhUnit = safeGet(lineHeight, 'unit');
631
+ const lhRawVal = safeGet(lineHeight, 'value', 0);
632
+ if (lhUnit === 'PIXELS' && typeof lhRawVal === 'number') {
633
+ const lhVal = Math.round(lhRawVal);
634
+ if (lhVal > 0) typo.lineHeightPx = lhVal;
635
+ } else if (lhUnit === 'PERCENT' && typeof lhRawVal === 'number' && typeof fontSize === 'number' && fontSize > 0) {
636
+ const lhVal = Math.round((fontSize * lhRawVal) / 100);
637
+ if (lhVal > 0) typo.lineHeightPx = lhVal;
638
+ }
639
+ }
640
+ if (letterSpacing && typeof letterSpacing === 'object') {
641
+ const lsUnit = safeGet(letterSpacing, 'unit');
642
+ const lsRawVal = safeGet(letterSpacing, 'value');
643
+ if (typeof lsRawVal === 'number' && Math.abs(lsRawVal) > 0.01) {
644
+ const lsPx =
645
+ lsUnit === 'PERCENT' && typeof fontSize === 'number' && fontSize > 0
646
+ ? (fontSize * lsRawVal) / 100
647
+ : lsRawVal;
648
+ if (Math.abs(lsPx) > 0.01) {
649
+ typo.letterSpacing = Number(lsPx.toFixed(2));
650
+ }
651
+ }
652
+ }
653
+ const hAlign = safeGet(node, 'textAlignHorizontal');
654
+ if (hAlign && hAlign !== 'LEFT') typo.textAlignHorizontal = hAlign;
655
+ const vAlign = safeGet(node, 'textAlignVertical');
656
+ if (vAlign && vAlign !== 'TOP') typo.textAlignVertical = vAlign;
657
+
658
+ if (Object.keys(typo).length > 0) {
659
+ pruned.typography = typo;
660
+ }
285
661
  }
286
662
 
287
663
  // Recursively process children
288
- const children = safeGet(node, 'children');
289
- if (Array.isArray(children) && children.length > 0) {
290
- pruned.children = children
291
- .filter((c) => {
292
- try {
293
- return c && safeGet(c, 'visible', true) !== false;
294
- } catch (_) {
295
- return true;
296
- }
297
- })
298
- .map((child) => serializeNode(child, depth + 1))
299
- .filter(Boolean);
664
+ const visibleKids = getVisibleChildren(node);
665
+ if (visibleKids.length > 0) {
666
+ const serializedKids = [];
667
+ for (const child of visibleKids) {
668
+ const serializedChild = await serializeNode(child, depth + 1);
669
+ if (serializedChild) serializedKids.push(serializedChild);
670
+ }
671
+ if (serializedKids.length > 0) {
672
+ pruned.children = serializedKids;
673
+ }
300
674
  }
301
675
 
302
676
  return pruned;
303
677
  } catch (err) {
304
678
  console.warn('Fallback serialization for node:', err);
305
679
  return {
306
- name: safeGet(node, 'name', 'Node'),
680
+ name: cleanLayerName(safeGet(node, 'name'), 'Node'),
307
681
  type: safeGet(node, 'type', 'FRAME'),
308
682
  };
309
683
  }
@@ -342,7 +716,7 @@ async function exportCurrentSelection() {
342
716
  // 1. Serialize AST Node safely
343
717
  let ast = null;
344
718
  try {
345
- ast = serializeNode(node);
719
+ ast = await serializeNode(node);
346
720
  } catch (err) {
347
721
  console.error('Failed to serialize AST:', err);
348
722
  ast = { name: safeGet(node, 'name', 'spec'), type: safeGet(node, 'type', 'FRAME') };
@@ -402,6 +776,6 @@ figma.ui.onmessage = async (msg) => {
402
776
  if (msg.type === 'REFRESH') {
403
777
  exportCurrentSelection();
404
778
  } else if (msg.type === 'NOTIFY') {
405
- figma.notify(msg.message);
779
+ figma.notify(msg.message, msg.error ? { error: true } : undefined);
406
780
  }
407
781
  };