figma-coder-mcp 0.2.2 → 0.3.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 (4) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +164 -152
  3. package/dist/bin.js +863 -89
  4. package/package.json +3 -1
package/dist/bin.js CHANGED
@@ -295,7 +295,8 @@ var require_client = __commonJS({
295
295
  async renderImages(fileKey, nodeIds, format = "svg", requestAuth, scale = 1) {
296
296
  const auth = this.resolveAuth(requestAuth);
297
297
  const ids = nodeIds.map(url_1.normalizeNodeId).join(",");
298
- const data = await this.get(`/images/${fileKey}?ids=${encodeURIComponent(ids)}&format=${format}&scale=${scale}`, auth);
298
+ const svgOpts = format === "svg" ? "&svg_outline_text=true&svg_simplify_stroke=true" : "";
299
+ const data = await this.get(`/images/${fileKey}?ids=${encodeURIComponent(ids)}&format=${format}&scale=${scale}${svgOpts}`, auth);
299
300
  if (data.err) {
300
301
  throw new errors_1.FigmaApiError(`Figma image render error: ${data.err}`, 502);
301
302
  }
@@ -361,8 +362,9 @@ var require_style_ir = __commonJS({
361
362
  "../packages/figma-core/dist/converter/style-ir.js"(exports) {
362
363
  "use strict";
363
364
  Object.defineProperty(exports, "__esModule", { value: true });
364
- exports.StyleIr = void 0;
365
+ exports.StyleIr = exports.IMAGE_FILL_TOKEN = void 0;
365
366
  var color_1 = require_color();
367
+ exports.IMAGE_FILL_TOKEN = "__FIGMA_IMAGE__";
366
368
  var ALIGN_MAIN = {
367
369
  MIN: "flex-start",
368
370
  CENTER: "center",
@@ -375,7 +377,7 @@ var require_style_ir = __commonJS({
375
377
  MAX: "flex-end",
376
378
  BASELINE: "baseline"
377
379
  };
378
- var StyleIr = class {
380
+ var StyleIr = class _StyleIr {
379
381
  /** Convert a Figma node subtree into the Style IR tree. */
380
382
  convert(root) {
381
383
  return this.walk(root, void 0);
@@ -392,10 +394,22 @@ var require_style_ir = __commonJS({
392
394
  this.applyEffects(node, css);
393
395
  }
394
396
  this.applyOpacity(node, css);
397
+ this.applyBlend(node, css);
395
398
  const isText = node.type === "TEXT";
396
399
  if (isText)
397
400
  this.applyText(node, css);
398
401
  const tag = isText ? "p" : "div";
402
+ if (asset?.kind === "vector") {
403
+ return {
404
+ id: node.id,
405
+ name: node.name,
406
+ figmaType: node.type,
407
+ tag,
408
+ css,
409
+ asset,
410
+ children: []
411
+ };
412
+ }
399
413
  const visibleChildren = (node.children ?? []).filter((c) => c.visible !== false);
400
414
  const maskChild = visibleChildren.find((c) => c.isMask);
401
415
  if (maskChild) {
@@ -405,17 +419,79 @@ var require_style_ir = __commonJS({
405
419
  this.applyRadius(maskChild, css);
406
420
  }
407
421
  const children = visibleChildren.map((c) => this.walk(c, node));
422
+ const runs = isText ? this.textRuns(node) : void 0;
408
423
  return {
409
424
  id: node.id,
410
425
  name: node.name,
411
426
  figmaType: node.type,
412
427
  tag,
413
428
  ...isText ? { text: node.characters ?? "" } : {},
429
+ ...runs ? { runs } : {},
414
430
  css,
415
431
  ...asset ? { asset } : {},
416
432
  children
417
433
  };
418
434
  }
435
+ /**
436
+ * Split a text node into styled runs when it has per-character overrides
437
+ * (a bold word, a colored span). Returns undefined for plain single-style
438
+ * text (the common case) so the renderer emits one string. `css` per run holds
439
+ * ONLY the overridden props; the <p> base style supplies the rest.
440
+ */
441
+ textRuns(node) {
442
+ const chars = node.characters ?? "";
443
+ const overrides = node.characterStyleOverrides;
444
+ const table = node.styleOverrideTable;
445
+ if (!chars || !overrides?.length || !table)
446
+ return void 0;
447
+ if (!overrides.some((id) => id && table[String(id)]))
448
+ return void 0;
449
+ const raw = [];
450
+ let curId = -1;
451
+ for (let i = 0; i < chars.length; i++) {
452
+ const id = overrides[i] ?? 0;
453
+ if (id !== curId || raw.length === 0) {
454
+ raw.push({ id, text: "" });
455
+ curId = id;
456
+ }
457
+ raw[raw.length - 1].text += chars[i];
458
+ }
459
+ if (raw.length <= 1)
460
+ return void 0;
461
+ return raw.map((r) => ({
462
+ text: r.text,
463
+ css: r.id && table[String(r.id)] ? this.overrideCss(table[String(r.id)]) : {}
464
+ }));
465
+ }
466
+ /** Map an override style entry (partial TypeStyle + optional fills) to CSS. */
467
+ overrideCss(o) {
468
+ const css = {};
469
+ if (o.fontWeight)
470
+ css["font-weight"] = String(o.fontWeight);
471
+ if (o.fontSize)
472
+ css["font-size"] = (0, color_1.px)(o.fontSize);
473
+ if (o.fontFamily)
474
+ css["font-family"] = `'${o.fontFamily}', sans-serif`;
475
+ if (o.italic)
476
+ css["font-style"] = "italic";
477
+ if (o.letterSpacing)
478
+ css["letter-spacing"] = (0, color_1.px)(o.letterSpacing);
479
+ if (o.lineHeightPx)
480
+ css["line-height"] = (0, color_1.px)(o.lineHeightPx);
481
+ if (o.textDecoration === "UNDERLINE")
482
+ css["text-decoration"] = "underline";
483
+ if (o.textDecoration === "STRIKETHROUGH")
484
+ css["text-decoration"] = "line-through";
485
+ if (o.textCase === "UPPER")
486
+ css["text-transform"] = "uppercase";
487
+ if (o.textCase === "LOWER")
488
+ css["text-transform"] = "lowercase";
489
+ const fills = this.visiblePaints(o.fills);
490
+ const p = fills[fills.length - 1];
491
+ if (p?.type === "SOLID" && p.color)
492
+ css.color = (0, color_1.figmaColorToCss)(p.color, p.opacity ?? 1);
493
+ return css;
494
+ }
419
495
  // ---- layout -------------------------------------------------------------
420
496
  applyLayout(node, parent, css) {
421
497
  const auto = node.layoutMode === "HORIZONTAL" || node.layoutMode === "VERTICAL";
@@ -457,8 +533,60 @@ var require_style_ir = __commonJS({
457
533
  const box = node.absoluteBoundingBox;
458
534
  if (!box)
459
535
  return;
460
- css.width = (0, color_1.px)(box.width);
461
- css.height = (0, color_1.px)(box.height);
536
+ this.applyAxisSize(node, parent, css, "H", box.width);
537
+ this.applyAxisSize(node, parent, css, "V", box.height);
538
+ if (node.minWidth != null)
539
+ css["min-width"] = (0, color_1.px)(node.minWidth);
540
+ if (node.maxWidth != null)
541
+ css["max-width"] = (0, color_1.px)(node.maxWidth);
542
+ if (node.minHeight != null)
543
+ css["min-height"] = (0, color_1.px)(node.minHeight);
544
+ if (node.maxHeight != null)
545
+ css["max-height"] = (0, color_1.px)(node.maxHeight);
546
+ }
547
+ /**
548
+ * Size one axis. Inside an auto-layout parent we honor Figma's sizing intent —
549
+ * FILL → grow (flex-1) or stretch the cross axis, HUG → let content size it,
550
+ * FIXED → px — instead of freezing every box to its pixel dimensions. Outside
551
+ * auto-layout (absolute children, root) we keep the exact px.
552
+ */
553
+ applyAxisSize(node, parent, css, axis, boxDim) {
554
+ const dim = axis === "H" ? "width" : "height";
555
+ const parentMainHorizontal = parent?.layoutMode === "HORIZONTAL";
556
+ const parentAuto = parentMainHorizontal || parent?.layoutMode === "VERTICAL";
557
+ const sizing = this.axisSizing(node, axis, parentMainHorizontal);
558
+ if (!parentAuto || !sizing) {
559
+ css[dim] = (0, color_1.px)(boxDim);
560
+ return;
561
+ }
562
+ const isMainAxis = axis === "H" === parentMainHorizontal;
563
+ if (sizing === "FILL") {
564
+ if (isMainAxis)
565
+ css.flex = "1 1 0%";
566
+ else
567
+ css["align-self"] = "stretch";
568
+ return;
569
+ }
570
+ if (sizing === "HUG") {
571
+ const hasContent = (node.children?.length ?? 0) > 0 || node.type === "TEXT";
572
+ if (!hasContent)
573
+ css[dim] = (0, color_1.px)(boxDim);
574
+ return;
575
+ }
576
+ css[dim] = (0, color_1.px)(boxDim);
577
+ }
578
+ /** Resolve a node's sizing mode on an axis, falling back from the modern
579
+ * layoutSizing* fields to the legacy layoutGrow/layoutAlign. */
580
+ axisSizing(node, axis, parentMainHorizontal) {
581
+ const explicit = axis === "H" ? node.layoutSizingHorizontal : node.layoutSizingVertical;
582
+ if (explicit)
583
+ return explicit;
584
+ const isMainAxis = axis === "H" === parentMainHorizontal;
585
+ if (isMainAxis && node.layoutGrow === 1)
586
+ return "FILL";
587
+ if (!isMainAxis && node.layoutAlign === "STRETCH")
588
+ return "FILL";
589
+ return void 0;
462
590
  }
463
591
  // ---- paints -------------------------------------------------------------
464
592
  visiblePaints(paints) {
@@ -472,22 +600,109 @@ var require_style_ir = __commonJS({
472
600
  const fills = this.visiblePaints(node.fills);
473
601
  if (!fills.length)
474
602
  return;
475
- const paint = fills[fills.length - 1];
476
- if (paint.type === "SOLID" && paint.color) {
477
- css["background-color"] = (0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1);
478
- } else if (paint.type === "GRADIENT_LINEAR") {
479
- const grad = this.linearGradient(paint);
480
- if (grad)
481
- css["background-image"] = grad;
482
- } else if (paint.type === "IMAGE") {
603
+ if (fills.length === 1) {
604
+ const only = fills[0];
605
+ if (only.type === "SOLID" && only.color) {
606
+ css["background-color"] = (0, color_1.figmaColorToCss)(only.color, only.opacity ?? 1);
607
+ return;
608
+ }
609
+ if (only.type === "IMAGE" && asset?.kind === "image") {
610
+ css["background-image"] = `url(${exports.IMAGE_FILL_TOKEN})`;
611
+ this.applyImageSizing(only, css);
612
+ return;
613
+ }
614
+ }
615
+ const layers = [];
616
+ let hasImage = false;
617
+ for (let i = fills.length - 1; i >= 0; i--) {
618
+ const paint = fills[i];
619
+ if (paint.type === "IMAGE") {
620
+ if (asset?.kind !== "image")
621
+ continue;
622
+ layers.push(`url(${exports.IMAGE_FILL_TOKEN})`);
623
+ hasImage = true;
624
+ continue;
625
+ }
626
+ const layer = this.paintToLayer(paint);
627
+ if (layer)
628
+ layers.push(layer);
629
+ }
630
+ if (!layers.length)
631
+ return;
632
+ css["background-image"] = layers.join(", ");
633
+ if (hasImage) {
483
634
  css["background-size"] = "cover";
484
635
  css["background-position"] = "center";
485
636
  }
486
637
  }
487
- linearGradient(paint) {
638
+ /**
639
+ * Translate a Figma IMAGE paint's fit (scaleMode + crop transform) into CSS
640
+ * background sizing. Previously we always emitted cover/center, which only
641
+ * matches FILL-without-crop; FIT/TILE/STRETCH and cropped fills rendered the
642
+ * wrong region/scale.
643
+ */
644
+ applyImageSizing(paint, css) {
645
+ const pct = (n) => `${Number(n.toFixed(3))}%`;
646
+ const m = paint.imageTransform;
647
+ if (m && m.length >= 2 && m[0].length >= 3) {
648
+ const sx = m[0][0];
649
+ const sy = m[1][1];
650
+ const tx = m[0][2];
651
+ const ty = m[1][2];
652
+ if (sx > 0 && sy > 0 && sx < 1 && sy < 1) {
653
+ css["background-size"] = `${pct(100 / sx)} ${pct(100 / sy)}`;
654
+ css["background-position"] = `${pct(tx / (1 - sx) * 100)} ${pct(ty / (1 - sy) * 100)}`;
655
+ css["background-repeat"] = "no-repeat";
656
+ return;
657
+ }
658
+ }
659
+ switch (paint.scaleMode) {
660
+ case "FIT":
661
+ css["background-size"] = "contain";
662
+ css["background-position"] = "center";
663
+ css["background-repeat"] = "no-repeat";
664
+ break;
665
+ case "STRETCH":
666
+ css["background-size"] = "100% 100%";
667
+ break;
668
+ case "TILE":
669
+ css["background-repeat"] = "repeat";
670
+ break;
671
+ case "FILL":
672
+ default:
673
+ css["background-size"] = "cover";
674
+ css["background-position"] = "center";
675
+ break;
676
+ }
677
+ }
678
+ /** A single Figma paint as one CSS `background-image` layer (or null if it
679
+ * carries no renderable color, e.g. an image handled separately). */
680
+ paintToLayer(paint) {
681
+ if (paint.type === "SOLID" && paint.color) {
682
+ const c = (0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1);
683
+ return `linear-gradient(${c}, ${c})`;
684
+ }
685
+ if (paint.type === "GRADIENT_LINEAR")
686
+ return this.linearGradient(paint);
687
+ if (paint.type === "GRADIENT_RADIAL")
688
+ return this.radialGradient(paint);
689
+ if (paint.type === "GRADIENT_ANGULAR")
690
+ return this.angularGradient(paint);
691
+ if (paint.type === "GRADIENT_DIAMOND")
692
+ return this.radialGradient(paint);
693
+ return null;
694
+ }
695
+ /** Comma-joined `color pos%` stops shared by every gradient kind. */
696
+ gradientStops(paint) {
488
697
  const stops = paint.gradientStops ?? [];
489
698
  if (stops.length < 2)
490
699
  return null;
700
+ return stops.map((s) => `${(0, color_1.figmaColorToCss)(s.color, paint.opacity ?? 1)} ${Math.round(s.position * 100)}%`).join(", ");
701
+ }
702
+ linearGradient(paint) {
703
+ const stopStr = this.gradientStops(paint);
704
+ if (!stopStr)
705
+ return null;
491
706
  let angle = 180;
492
707
  const handles = paint.gradientHandlePositions;
493
708
  if (handles && handles.length >= 2) {
@@ -495,19 +710,101 @@ var require_style_ir = __commonJS({
495
710
  const dy = handles[1].y - handles[0].y;
496
711
  angle = Math.round(Math.atan2(dy, dx) * 180 / Math.PI + 90);
497
712
  }
498
- const stopStr = stops.map((s) => `${(0, color_1.figmaColorToCss)(s.color, paint.opacity ?? 1)} ${Math.round(s.position * 100)}%`).join(", ");
499
713
  return `linear-gradient(${angle}deg, ${stopStr})`;
500
714
  }
715
+ /**
716
+ * Radial gradient. Figma's handles are normalized box coordinates: [0] is the
717
+ * center, [1] the end of one radius, [2] the end of the perpendicular radius.
718
+ * We map the center to a percentage position and each radius to a percentage
719
+ * of the box (an axis-aligned ellipse — CSS can't rotate a radial gradient, so
720
+ * a rotated Figma radial is approximated). Falls back to a centered circle.
721
+ */
722
+ radialGradient(paint) {
723
+ const stopStr = this.gradientStops(paint);
724
+ if (!stopStr)
725
+ return null;
726
+ const h = paint.gradientHandlePositions;
727
+ if (!h || h.length < 3) {
728
+ return `radial-gradient(circle at center, ${stopStr})`;
729
+ }
730
+ const pct = (n) => `${Number((n * 100).toFixed(2))}%`;
731
+ const cx = h[0].x;
732
+ const cy = h[0].y;
733
+ const rx = Math.hypot(h[1].x - cx, h[1].y - cy);
734
+ const ry = Math.hypot(h[2].x - cx, h[2].y - cy);
735
+ return `radial-gradient(ellipse ${pct(rx)} ${pct(ry)} at ${pct(cx)} ${pct(cy)}, ${stopStr})`;
736
+ }
737
+ /**
738
+ * Angular (conic) gradient. Handle [0] is the center and [0]->[1] sets the
739
+ * start angle. CSS conic accepts percentage stops, matching Figma's 0..1.
740
+ */
741
+ angularGradient(paint) {
742
+ const stopStr = this.gradientStops(paint);
743
+ if (!stopStr)
744
+ return null;
745
+ let angle = 0;
746
+ let cx = 0.5;
747
+ let cy = 0.5;
748
+ const h = paint.gradientHandlePositions;
749
+ if (h && h.length >= 2) {
750
+ cx = h[0].x;
751
+ cy = h[0].y;
752
+ angle = Math.round(Math.atan2(h[1].y - cy, h[1].x - cx) * 180 / Math.PI + 90);
753
+ }
754
+ const pct = (n) => `${Number((n * 100).toFixed(2))}%`;
755
+ return `conic-gradient(from ${angle}deg at ${pct(cx)} ${pct(cy)}, ${stopStr})`;
756
+ }
501
757
  applyStroke(node, css) {
502
758
  const strokes = this.visiblePaints(node.strokes);
503
- if (!strokes.length || !node.strokeWeight)
759
+ if (!strokes.length)
504
760
  return;
505
761
  const paint = strokes[strokes.length - 1];
506
- if (paint.type === "SOLID" && paint.color) {
507
- css.border = `${(0, color_1.px)(node.strokeWeight)} solid ${(0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1)}`;
762
+ if (paint.type !== "SOLID" || !paint.color)
763
+ return;
764
+ const color = (0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1);
765
+ if (node.type === "LINE") {
766
+ if (!node.strokeWeight)
767
+ return;
768
+ const box = node.absoluteBoundingBox;
769
+ const vertical = !!box && box.height > box.width;
770
+ css[vertical ? "border-left" : "border-top"] = `${(0, color_1.px)(node.strokeWeight)} solid ${color}`;
771
+ return;
772
+ }
773
+ const ind = node.individualStrokeWeights;
774
+ if (ind && (ind.top !== ind.right || ind.right !== ind.bottom || ind.bottom !== ind.left)) {
775
+ const side = (w2, name) => {
776
+ if (w2 > 0)
777
+ css[`border-${name}`] = `${(0, color_1.px)(w2)} solid ${color}`;
778
+ };
779
+ side(ind.top, "top");
780
+ side(ind.right, "right");
781
+ side(ind.bottom, "bottom");
782
+ side(ind.left, "left");
783
+ return;
784
+ }
785
+ if (!node.strokeWeight)
786
+ return;
787
+ const w = (0, color_1.px)(node.strokeWeight);
788
+ switch (node.strokeAlign) {
789
+ case "OUTSIDE":
790
+ css.outline = `${w} solid ${color}`;
791
+ css["outline-offset"] = "0px";
792
+ break;
793
+ case "CENTER":
794
+ css.outline = `${w} solid ${color}`;
795
+ css["outline-offset"] = `${(0, color_1.px)(-(node.strokeWeight / 2))}`;
796
+ break;
797
+ case "INSIDE":
798
+ default:
799
+ css.border = `${w} solid ${color}`;
800
+ break;
508
801
  }
509
802
  }
510
803
  applyRadius(node, css) {
804
+ if (node.type === "ELLIPSE") {
805
+ css["border-radius"] = "50%";
806
+ return;
807
+ }
511
808
  if (node.rectangleCornerRadii) {
512
809
  const [tl, tr, br, bl] = node.rectangleCornerRadii;
513
810
  if (tl || tr || br || bl) {
@@ -581,25 +878,163 @@ var require_style_ir = __commonJS({
581
878
  css["white-space"] = "pre";
582
879
  else if (s.textAutoResize === "HEIGHT")
583
880
  css["white-space"] = "pre-wrap";
881
+ if (s.textAlignVertical === "CENTER" || s.textAlignVertical === "BOTTOM") {
882
+ css.display = "flex";
883
+ css["flex-direction"] = "column";
884
+ css["justify-content"] = s.textAlignVertical === "CENTER" ? "center" : "flex-end";
885
+ }
584
886
  }
585
887
  const fills = this.visiblePaints(node.fills);
586
888
  const paint = fills[fills.length - 1];
587
889
  if (paint?.type === "SOLID" && paint.color) {
588
890
  css.color = (0, color_1.figmaColorToCss)(paint.color, paint.opacity ?? 1);
891
+ } else if (paint) {
892
+ const layer = this.paintToLayer(paint);
893
+ if (layer) {
894
+ css["background-image"] = layer;
895
+ css["-webkit-background-clip"] = "text";
896
+ css["background-clip"] = "text";
897
+ css.color = "transparent";
898
+ }
589
899
  }
590
900
  }
901
+ /** Map a Figma layer blend mode to CSS `mix-blend-mode` (no-op for NORMAL). */
902
+ applyBlend(node, css) {
903
+ const bm = node.blendMode;
904
+ if (!bm || bm === "NORMAL" || bm === "PASS_THROUGH")
905
+ return;
906
+ const css_ = bm.toLowerCase().replace(/_/g, "-");
907
+ const allowed = /* @__PURE__ */ new Set([
908
+ "multiply",
909
+ "screen",
910
+ "overlay",
911
+ "darken",
912
+ "lighten",
913
+ "color-dodge",
914
+ "color-burn",
915
+ "hard-light",
916
+ "soft-light",
917
+ "difference",
918
+ "exclusion",
919
+ "hue",
920
+ "saturation",
921
+ "color",
922
+ "luminosity"
923
+ ]);
924
+ if (allowed.has(css_))
925
+ css["mix-blend-mode"] = css_;
926
+ }
591
927
  // ---- assets -------------------------------------------------------------
592
928
  detectAsset(node) {
929
+ const imagePaint = [...this.visiblePaints(node.fills)].reverse().find((p) => p.type === "IMAGE" && p.imageRef);
593
930
  const vectorTypes = ["VECTOR", "STAR", "LINE", "ELLIPSE", "REGULAR_POLYGON", "BOOLEAN_OPERATION"];
594
- if (vectorTypes.includes(node.type))
931
+ if (vectorTypes.includes(node.type)) {
932
+ if (node.type === "ELLIPSE" && imagePaint) {
933
+ return { kind: "image", imageRef: imagePaint.imageRef };
934
+ }
935
+ if (this.isCssRenderableVector(node))
936
+ return void 0;
595
937
  return { kind: "vector" };
596
- const imagePaint = [...this.visiblePaints(node.fills)].reverse().find((p) => p.type === "IMAGE" && p.imageRef);
938
+ }
597
939
  if (imagePaint)
598
940
  return { kind: "image", imageRef: imagePaint.imageRef };
941
+ if (this.isVectorOnlyContainer(node))
942
+ return { kind: "vector" };
599
943
  return void 0;
600
944
  }
945
+ /**
946
+ * Whether a vector-typed node is faithfully renderable with CSS, so it can
947
+ * skip server-side rasterization.
948
+ *
949
+ * - ELLIPSE / LINE encode their geometry in the node type, so CSS always
950
+ * reproduces them (circle/oval via border-radius:50%, line via a border).
951
+ * - VECTOR / BOOLEAN_OPERATION have arbitrary geometry the IR can't see, so
952
+ * they only qualify with a positive "rounded rectangle" signal (corner
953
+ * radius or a background blur — i.e. a panel) plus at least one paint we can
954
+ * render (a solid/gradient fill or a stroke-only outline).
955
+ *
956
+ * A rotated node never qualifies: the IR emits no transform, so the baked
957
+ * raster (which already carries the rotation) is the faithful fallback.
958
+ */
959
+ isCssRenderableVector(node) {
960
+ if (this.hasRotation(node))
961
+ return false;
962
+ const fills = this.visiblePaints(node.fills);
963
+ if (fills.some((p) => p.type === "IMAGE"))
964
+ return false;
965
+ if (node.type === "ELLIPSE" || node.type === "LINE")
966
+ return true;
967
+ if (node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION") {
968
+ const hasPaintableFill = fills.some((p) => p.type === "SOLID" || p.type === "GRADIENT_LINEAR");
969
+ if (!hasPaintableFill)
970
+ return false;
971
+ const hasRadius = (node.cornerRadius ?? 0) > 0 || (node.rectangleCornerRadii?.some((r) => r > 0) ?? false);
972
+ const hasBackgroundBlur = (node.effects ?? []).some((e) => e.type === "BACKGROUND_BLUR" && e.visible !== false);
973
+ return hasRadius || hasBackgroundBlur;
974
+ }
975
+ return false;
976
+ }
977
+ /**
978
+ * Whether a container should collapse into a single rendered SVG: its entire
979
+ * visible subtree is vector paths (no text, no image fills, no plain boxes)
980
+ * AND at least one of those paths actually needs rasterizing (a subtree of
981
+ * only CSS-renderable shapes is cheaper kept as elements).
982
+ */
983
+ isVectorOnlyContainer(node) {
984
+ if (!_StyleIr.CONTAINER_TYPES.includes(node.type))
985
+ return false;
986
+ if (!node.absoluteBoundingBox)
987
+ return false;
988
+ const isAutoLayout = node.layoutMode === "HORIZONTAL" || node.layoutMode === "VERTICAL";
989
+ const childCount = (node.children ?? []).filter((c) => c.visible !== false).length;
990
+ if (isAutoLayout && childCount < _StyleIr.SVG_COLLAPSE_AUTOLAYOUT_THRESHOLD)
991
+ return false;
992
+ const scan = this.collapseScan(node);
993
+ return scan.pure && scan.hasRaster;
994
+ }
995
+ /**
996
+ * Classify a subtree for the collapse decision. `pure` is true only when every
997
+ * visible node is a vector shape; `hasRaster` is true when at least one of
998
+ * them would hit /v1/images (i.e. isn't reproducible as a CSS box).
999
+ */
1000
+ collapseScan(node) {
1001
+ const NOT_PURE = { pure: false, hasRaster: false };
1002
+ if (node.visible === false)
1003
+ return { pure: true, hasRaster: false };
1004
+ if (node.type === "TEXT")
1005
+ return NOT_PURE;
1006
+ const hasImageFill = this.visiblePaints(node.fills).some((p) => p.type === "IMAGE" && p.imageRef);
1007
+ if (hasImageFill)
1008
+ return NOT_PURE;
1009
+ const vectorTypes = ["VECTOR", "STAR", "LINE", "ELLIPSE", "REGULAR_POLYGON", "BOOLEAN_OPERATION"];
1010
+ if (vectorTypes.includes(node.type)) {
1011
+ return { pure: true, hasRaster: !this.isCssRenderableVector(node) };
1012
+ }
1013
+ if (_StyleIr.CONTAINER_TYPES.includes(node.type)) {
1014
+ const kids = (node.children ?? []).filter((c) => c.visible !== false);
1015
+ if (!kids.length)
1016
+ return NOT_PURE;
1017
+ let hasRaster = false;
1018
+ for (const kid of kids) {
1019
+ const r = this.collapseScan(kid);
1020
+ if (!r.pure)
1021
+ return NOT_PURE;
1022
+ hasRaster = hasRaster || r.hasRaster;
1023
+ }
1024
+ return { pure: true, hasRaster };
1025
+ }
1026
+ return NOT_PURE;
1027
+ }
1028
+ /** True when the node is rotated beyond a tiny floating-point tolerance
1029
+ * (~0.5°). Figma reports rotation in radians; axis-aligned nodes carry
1030
+ * values like 8.7e-8 that must read as "not rotated". */
1031
+ hasRotation(node) {
1032
+ return typeof node.rotation === "number" && Math.abs(node.rotation) > 0.01;
1033
+ }
601
1034
  };
602
1035
  exports.StyleIr = StyleIr;
1036
+ StyleIr.CONTAINER_TYPES = ["GROUP", "FRAME", "INSTANCE", "COMPONENT"];
1037
+ StyleIr.SVG_COLLAPSE_AUTOLAYOUT_THRESHOLD = 10;
603
1038
  }
604
1039
  });
605
1040
 
@@ -612,6 +1047,111 @@ var require_tailwind_mapper = __commonJS({
612
1047
  function arb(value) {
613
1048
  return value.replace(/\s+/g, "_");
614
1049
  }
1050
+ var SPACING = [
1051
+ ["0", 0],
1052
+ ["px", 1],
1053
+ ["0.5", 2],
1054
+ ["1", 4],
1055
+ ["1.5", 6],
1056
+ ["2", 8],
1057
+ ["2.5", 10],
1058
+ ["3", 12],
1059
+ ["3.5", 14],
1060
+ ["4", 16],
1061
+ ["5", 20],
1062
+ ["6", 24],
1063
+ ["7", 28],
1064
+ ["8", 32],
1065
+ ["9", 36],
1066
+ ["10", 40],
1067
+ ["11", 44],
1068
+ ["12", 48],
1069
+ ["14", 56],
1070
+ ["16", 64],
1071
+ ["20", 80],
1072
+ ["24", 96],
1073
+ ["28", 112],
1074
+ ["32", 128],
1075
+ ["36", 144],
1076
+ ["40", 160],
1077
+ ["44", 176],
1078
+ ["48", 192],
1079
+ ["52", 208],
1080
+ ["56", 224],
1081
+ ["60", 240],
1082
+ ["64", 256],
1083
+ ["72", 288],
1084
+ ["80", 320],
1085
+ ["96", 384]
1086
+ ];
1087
+ var RADIUS = [
1088
+ ["-none", 0],
1089
+ ["-sm", 2],
1090
+ ["", 4],
1091
+ ["-md", 6],
1092
+ ["-lg", 8],
1093
+ ["-xl", 12],
1094
+ ["-2xl", 16],
1095
+ ["-3xl", 24],
1096
+ ["-full", 9999]
1097
+ ];
1098
+ var FONT_SIZE = [
1099
+ ["xs", 12],
1100
+ ["sm", 14],
1101
+ ["base", 16],
1102
+ ["lg", 18],
1103
+ ["xl", 20],
1104
+ ["2xl", 24],
1105
+ ["3xl", 30],
1106
+ ["4xl", 36],
1107
+ ["5xl", 48],
1108
+ ["6xl", 60],
1109
+ ["7xl", 72],
1110
+ ["8xl", 96],
1111
+ ["9xl", 128]
1112
+ ];
1113
+ var FONT_WEIGHT = {
1114
+ "100": "thin",
1115
+ "200": "extralight",
1116
+ "300": "light",
1117
+ "400": "normal",
1118
+ "500": "medium",
1119
+ "600": "semibold",
1120
+ "700": "bold",
1121
+ "800": "extrabold",
1122
+ "900": "black"
1123
+ };
1124
+ function pxNum(value) {
1125
+ const m = /^(-?\d*\.?\d+)px$/.exec(value.trim());
1126
+ return m ? parseFloat(m[1]) : null;
1127
+ }
1128
+ function nearest(n, scale) {
1129
+ let best = null;
1130
+ let bestPx = 0;
1131
+ let bestD = Infinity;
1132
+ for (const [tok, px] of scale) {
1133
+ const d = Math.abs(px - n);
1134
+ if (d < bestD) {
1135
+ bestD = d;
1136
+ best = tok;
1137
+ bestPx = px;
1138
+ }
1139
+ }
1140
+ if (best === null)
1141
+ return null;
1142
+ return bestD <= 0.5 ? best : null;
1143
+ }
1144
+ function spacingClass(prefix, value, round) {
1145
+ if (round) {
1146
+ const n = pxNum(value);
1147
+ if (n !== null && n >= 0) {
1148
+ const tok = nearest(n, SPACING);
1149
+ if (tok !== null)
1150
+ return `${prefix}-${tok}`;
1151
+ }
1152
+ }
1153
+ return `${prefix}-[${arb(value)}]`;
1154
+ }
615
1155
  var JUSTIFY = {
616
1156
  "flex-start": "justify-start",
617
1157
  center: "justify-center",
@@ -630,7 +1170,8 @@ var require_tailwind_mapper = __commonJS({
630
1170
  right: "text-right",
631
1171
  justify: "text-justify"
632
1172
  };
633
- function cssToTailwind(css) {
1173
+ function cssToTailwind(css, opts = {}) {
1174
+ const round = opts.round ?? false;
634
1175
  const classes = [];
635
1176
  const leftover = {};
636
1177
  const push = (c) => {
@@ -663,16 +1204,16 @@ var require_tailwind_mapper = __commonJS({
663
1204
  ITEMS[value] ? push(ITEMS[value]) : leftover[prop] = value;
664
1205
  break;
665
1206
  case "gap":
666
- push(`gap-[${arb(value)}]`);
1207
+ push(spacingClass("gap", value, round));
667
1208
  break;
668
1209
  case "padding":
669
- applyPadding(value, push, leftover);
1210
+ applyPadding(value, push, leftover, round);
670
1211
  break;
671
1212
  case "width":
672
- push(`w-[${arb(value)}]`);
1213
+ push(spacingClass("w", value, round));
673
1214
  break;
674
1215
  case "height":
675
- push(`h-[${arb(value)}]`);
1216
+ push(spacingClass("h", value, round));
676
1217
  break;
677
1218
  case "position":
678
1219
  if (value === "absolute")
@@ -683,10 +1224,10 @@ var require_tailwind_mapper = __commonJS({
683
1224
  leftover[prop] = value;
684
1225
  break;
685
1226
  case "left":
686
- push(`left-[${arb(value)}]`);
1227
+ push(spacingClass("left", value, round));
687
1228
  break;
688
1229
  case "top":
689
- push(`top-[${arb(value)}]`);
1230
+ push(spacingClass("top", value, round));
690
1231
  break;
691
1232
  case "overflow":
692
1233
  if (value === "hidden")
@@ -713,7 +1254,14 @@ var require_tailwind_mapper = __commonJS({
713
1254
  applyBorder(value, push, leftover);
714
1255
  break;
715
1256
  case "border-radius":
716
- value.includes(" ") ? leftover[prop] = value : push(`rounded-[${arb(value)}]`);
1257
+ if (value.includes(" ")) {
1258
+ leftover[prop] = value;
1259
+ } else if (round && value === "50%") {
1260
+ push("rounded-full");
1261
+ } else {
1262
+ const tok = round ? snapRadius(value) : null;
1263
+ push(tok !== null ? `rounded${tok}` : `rounded-[${arb(value)}]`);
1264
+ }
717
1265
  break;
718
1266
  case "box-shadow":
719
1267
  push(`shadow-[${arb(value)}]`);
@@ -731,11 +1279,13 @@ var require_tailwind_mapper = __commonJS({
731
1279
  case "color":
732
1280
  push(`text-[${arb(value)}]`);
733
1281
  break;
734
- case "font-size":
735
- push(`text-[${arb(value)}]`);
1282
+ case "font-size": {
1283
+ const tok = round ? nearest(pxNum(value) ?? -1, FONT_SIZE) : null;
1284
+ push(tok ? `text-${tok}` : `text-[${arb(value)}]`);
736
1285
  break;
1286
+ }
737
1287
  case "font-weight":
738
- push(`font-[${arb(value)}]`);
1288
+ push(round && FONT_WEIGHT[value] ? `font-${FONT_WEIGHT[value]}` : `font-[${arb(value)}]`);
739
1289
  break;
740
1290
  case "line-height":
741
1291
  push(`leading-[${arb(value)}]`);
@@ -768,16 +1318,29 @@ var require_tailwind_mapper = __commonJS({
768
1318
  }
769
1319
  return { classes, leftover };
770
1320
  }
771
- function applyPadding(value, push, leftover) {
1321
+ function snapRadius(value) {
1322
+ const n = pxNum(value);
1323
+ if (n === null || n < 0)
1324
+ return null;
1325
+ return nearest(n, RADIUS);
1326
+ }
1327
+ function applyPadding(value, push, leftover, round) {
772
1328
  const parts = value.split(/\s+/);
773
1329
  if (parts.length === 4) {
774
1330
  const [t, r, b, l] = parts;
775
- push(`pt-[${arb(t)}]`);
776
- push(`pr-[${arb(r)}]`);
777
- push(`pb-[${arb(b)}]`);
778
- push(`pl-[${arb(l)}]`);
1331
+ if (round && t === r && r === b && b === l) {
1332
+ push(spacingClass("p", t, round));
1333
+ } else if (round && t === b && l === r) {
1334
+ push(spacingClass("py", t, round));
1335
+ push(spacingClass("px", r, round));
1336
+ } else {
1337
+ push(spacingClass("pt", t, round));
1338
+ push(spacingClass("pr", r, round));
1339
+ push(spacingClass("pb", b, round));
1340
+ push(spacingClass("pl", l, round));
1341
+ }
779
1342
  } else if (parts.length === 1) {
780
- push(`p-[${arb(parts[0])}]`);
1343
+ push(spacingClass("p", parts[0], round));
781
1344
  } else {
782
1345
  leftover.padding = value;
783
1346
  }
@@ -808,6 +1371,7 @@ var require_html_renderer = __commonJS({
808
1371
  "use strict";
809
1372
  Object.defineProperty(exports, "__esModule", { value: true });
810
1373
  exports.HtmlRenderer = void 0;
1374
+ var style_ir_1 = require_style_ir();
811
1375
  var tailwind_mapper_1 = require_tailwind_mapper();
812
1376
  function escapeHtml(s) {
813
1377
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
@@ -815,14 +1379,19 @@ var require_html_renderer = __commonJS({
815
1379
  function styleString(css) {
816
1380
  return Object.entries(css).map(([k, v]) => `${k}: ${v}`).join("; ");
817
1381
  }
1382
+ function svgPlaceholder(markup) {
1383
+ return `data:image/svg+xml,${encodeURIComponent(markup)}`;
1384
+ }
1385
+ var IMAGE_PLACEHOLDER = svgPlaceholder("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100' preserveAspectRatio='xMidYMid slice'><rect width='100' height='100' fill='#e5e7eb'/><circle cx='35' cy='34' r='9' fill='#c4cad3'/><path d='M16 78l22-26 15 15 16-16 17 27z' fill='#c4cad3'/></svg>");
1386
+ var VECTOR_PLACEHOLDER = svgPlaceholder("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' preserveAspectRatio='xMidYMid meet'><rect width='24' height='24' rx='4' fill='#eef0f3'/><path d='M7 14l3.5-4 2.5 3 2-2.5L18 14z' fill='#b9c0cb'/><circle cx='9' cy='8.5' r='1.6' fill='#b9c0cb'/></svg>");
818
1387
  var HtmlRenderer = class _HtmlRenderer {
819
1388
  /** Render the Style IR tree into an HTML fragment string. */
820
1389
  renderFragment(node, opts = {}) {
821
- return this.render(node, opts.mode ?? "tailwind", 0);
1390
+ return this.render(node, opts.mode ?? "tailwind", 0, opts.round ?? true);
822
1391
  }
823
1392
  /** Render a standalone, previewable HTML document (Tailwind Play CDN). */
824
1393
  renderDocument(node, opts = {}) {
825
- const body = this.render(node, opts.mode ?? "tailwind", 2);
1394
+ const body = this.render(node, opts.mode ?? "tailwind", 2, opts.round ?? true);
826
1395
  return this.wrapDocument(body, node.name || "Figma Export", this.fontLinks(node));
827
1396
  }
828
1397
  /** Wrap an arbitrary HTML fragment (e.g. LLM output) into a previewable document. */
@@ -867,21 +1436,30 @@ ${bodyHtml}
867
1436
  }
868
1437
  return links.join("\n");
869
1438
  }
870
- render(node, mode, depth) {
1439
+ render(node, mode, depth, round) {
871
1440
  const indent = " ".repeat(depth);
872
- const isVectorAsset = node.asset?.kind === "vector" && !!node.assetSrc;
873
- const isImageAsset = node.asset?.kind === "image" && !!node.assetSrc;
1441
+ const isVectorAsset = node.asset?.kind === "vector";
1442
+ const isImageAsset = node.asset?.kind === "image";
874
1443
  const extraStyle = {};
1444
+ let baseCss = node.css;
875
1445
  if (isImageAsset) {
876
- extraStyle["background-image"] = `url(${node.assetSrc})`;
1446
+ const imageUrl = node.assetSrc ? `url(${node.assetSrc})` : `url(${IMAGE_PLACEHOLDER})`;
1447
+ const stack = node.css["background-image"];
1448
+ extraStyle["background-image"] = stack ? stack.split(`url(${style_ir_1.IMAGE_FILL_TOKEN})`).join(imageUrl) : imageUrl;
1449
+ if (!node.assetSrc) {
1450
+ extraStyle["background-size"] = "cover";
1451
+ extraStyle["background-position"] = "center";
1452
+ }
1453
+ const { ["background-image"]: _omit, ...rest } = node.css;
1454
+ baseCss = rest;
877
1455
  }
878
1456
  const attrs = [];
879
1457
  if (mode === "inline") {
880
- const css = { ...node.css, ...extraStyle };
1458
+ const css = { ...baseCss, ...extraStyle };
881
1459
  if (Object.keys(css).length)
882
1460
  attrs.push(`style="${escapeHtml(styleString(css))}"`);
883
1461
  } else {
884
- const { classes, leftover } = (0, tailwind_mapper_1.cssToTailwind)(node.css);
1462
+ const { classes, leftover } = (0, tailwind_mapper_1.cssToTailwind)(baseCss, { round });
885
1463
  const style = { ...leftover, ...extraStyle };
886
1464
  if (classes.length)
887
1465
  attrs.push(`class="${classes.join(" ")}"`);
@@ -891,7 +1469,15 @@ ${bodyHtml}
891
1469
  attrs.push(`data-figma-name="${escapeHtml(node.name)}"`);
892
1470
  const attrStr = attrs.length ? " " + attrs.join(" ") : "";
893
1471
  if (isVectorAsset) {
894
- return `${indent}<img${attrStr} src="${node.assetSrc}" alt="${escapeHtml(node.name)}" />`;
1472
+ const src = node.assetSrc ?? VECTOR_PLACEHOLDER;
1473
+ return `${indent}<img${attrStr} src="${src}" alt="${escapeHtml(node.name)}" />`;
1474
+ }
1475
+ if (node.runs && node.runs.length) {
1476
+ const spans = node.runs.map((r) => {
1477
+ const s = Object.keys(r.css).length ? ` style="${escapeHtml(styleString(r.css))}"` : "";
1478
+ return `<span${s}>${escapeHtml(r.text)}</span>`;
1479
+ }).join("");
1480
+ return `${indent}<${node.tag}${attrStr}>${spans}</${node.tag}>`;
895
1481
  }
896
1482
  if (node.text !== void 0) {
897
1483
  return `${indent}<${node.tag}${attrStr}>${escapeHtml(node.text)}</${node.tag}>`;
@@ -899,7 +1485,7 @@ ${bodyHtml}
899
1485
  if (!node.children.length) {
900
1486
  return `${indent}<${node.tag}${attrStr}></${node.tag}>`;
901
1487
  }
902
- const childHtml = node.children.map((c) => this.render(c, mode, depth + 1)).join("\n");
1488
+ const childHtml = node.children.map((c) => this.render(c, mode, depth + 1, round)).join("\n");
903
1489
  return `${indent}<${node.tag}${attrStr}>
904
1490
  ${childHtml}
905
1491
  ${indent}</${node.tag}>`;
@@ -931,45 +1517,213 @@ ${indent}</${node.tag}>`;
931
1517
  }
932
1518
  });
933
1519
 
1520
+ // ../packages/figma-core/dist/converter/compact-design.js
1521
+ var require_compact_design = __commonJS({
1522
+ "../packages/figma-core/dist/converter/compact-design.js"(exports) {
1523
+ "use strict";
1524
+ Object.defineProperty(exports, "__esModule", { value: true });
1525
+ exports.toCompactDesign = toCompactDesign2;
1526
+ exports.stableStringify = stableStringify;
1527
+ var MIN_TEMPLATE_NODES = 3;
1528
+ var POSITIONAL_KEYS = /* @__PURE__ */ new Set(["left", "top", "width", "height"]);
1529
+ function toCompactDesign2(root) {
1530
+ const styles = {};
1531
+ const styleIds = /* @__PURE__ */ new Map();
1532
+ let styleSeq = 0;
1533
+ const styleRef = (css) => {
1534
+ if (!css || Object.keys(css).length === 0)
1535
+ return void 0;
1536
+ const key = stableStringify(css);
1537
+ let id = styleIds.get(key);
1538
+ if (!id) {
1539
+ id = `s${++styleSeq}`;
1540
+ styleIds.set(key, id);
1541
+ styles[id] = css;
1542
+ }
1543
+ return id;
1544
+ };
1545
+ const hashCount = /* @__PURE__ */ new Map();
1546
+ const build = (node) => {
1547
+ const children = node.children.map(build);
1548
+ const compact = { tag: node.tag };
1549
+ if (node.name)
1550
+ compact.name = node.name;
1551
+ if (node.figmaType && node.figmaType !== "FRAME")
1552
+ compact.type = node.figmaType;
1553
+ const box = {};
1554
+ const rest = {};
1555
+ for (const [k, v] of Object.entries(node.css)) {
1556
+ if (POSITIONAL_KEYS.has(k))
1557
+ box[k] = v;
1558
+ else
1559
+ rest[k] = v;
1560
+ }
1561
+ if (Object.keys(box).length)
1562
+ compact.box = box;
1563
+ const sref = styleRef(rest);
1564
+ if (sref)
1565
+ compact.style = sref;
1566
+ if (node.text !== void 0)
1567
+ compact.text = node.text;
1568
+ if (node.asset)
1569
+ compact.asset = { kind: node.asset.kind };
1570
+ if (children.length)
1571
+ compact.children = children.map((c) => c.compact);
1572
+ const hash = stableStringify({
1573
+ t: compact.tag,
1574
+ ty: compact.type,
1575
+ b: compact.box,
1576
+ s: compact.style,
1577
+ x: compact.text,
1578
+ a: compact.asset?.kind,
1579
+ c: children.map((c) => c.hash)
1580
+ });
1581
+ hashCount.set(hash, (hashCount.get(hash) ?? 0) + 1);
1582
+ const size = 1 + children.reduce((n, c) => n + c.size, 0);
1583
+ return { compact, children, hash, size };
1584
+ };
1585
+ const built = build(root);
1586
+ const elements = {};
1587
+ const elementIds = /* @__PURE__ */ new Map();
1588
+ let elemSeq = 0;
1589
+ const templatize = (b) => {
1590
+ if (b.compact.children) {
1591
+ b.compact.children = b.children.map(templatize);
1592
+ }
1593
+ if (b.size >= MIN_TEMPLATE_NODES && (hashCount.get(b.hash) ?? 0) >= 2) {
1594
+ let id = elementIds.get(b.hash);
1595
+ if (!id) {
1596
+ id = `e${++elemSeq}`;
1597
+ elementIds.set(b.hash, id);
1598
+ elements[id] = b.compact;
1599
+ }
1600
+ return { ref: id };
1601
+ }
1602
+ return b.compact;
1603
+ };
1604
+ const rootCompact = templatize(built);
1605
+ return { name: root.name, globalVars: { styles }, elements, root: rootCompact };
1606
+ }
1607
+ function stableStringify(value) {
1608
+ return JSON.stringify(value, (_k, v) => v && typeof v === "object" && !Array.isArray(v) ? Object.keys(v).sort().reduce((acc, k) => {
1609
+ acc[k] = v[k];
1610
+ return acc;
1611
+ }, {}) : v);
1612
+ }
1613
+ }
1614
+ });
1615
+
934
1616
  // ../packages/figma-core/dist/assets/asset-resolver.js
935
1617
  var require_asset_resolver = __commonJS({
936
1618
  "../packages/figma-core/dist/assets/asset-resolver.js"(exports) {
937
1619
  "use strict";
938
1620
  Object.defineProperty(exports, "__esModule", { value: true });
939
1621
  exports.AssetResolver = void 0;
1622
+ var errors_1 = require_errors();
940
1623
  var AssetResolver = class {
941
1624
  constructor(figma, cache, logger) {
942
1625
  this.figma = figma;
943
1626
  this.cache = cache;
944
1627
  this.logger = logger;
945
1628
  this.fetchConcurrency = 8;
1629
+ this.renderBatchSize = 20;
1630
+ this.renderMaxAttempts = 4;
1631
+ this.renderMaxBackoffMs = 4e3;
946
1632
  }
947
1633
  async resolve(root, fileKey, auth, scale = 2) {
948
1634
  const vectors = [];
949
1635
  const images = [];
950
1636
  this.collect(root, vectors, images);
951
1637
  if (!vectors.length && !images.length) {
952
- return { vectors: 0, images: 0, embedded: 0, fromCache: 0 };
1638
+ return {
1639
+ vectors: 0,
1640
+ images: 0,
1641
+ embedded: 0,
1642
+ fromCache: 0,
1643
+ unresolvedVectors: 0,
1644
+ unresolvedImages: 0,
1645
+ rateLimited: false
1646
+ };
953
1647
  }
954
1648
  let fromCache = 0;
955
1649
  fromCache += await this.fillFromCache(vectors, fileKey, "svg", 1);
956
1650
  fromCache += await this.fillImageFillsFromCache(images, fileKey);
957
1651
  const vMissing = vectors.filter((n) => !n.assetSrc);
958
- let svgUrls = {};
959
- if (vMissing.length) {
960
- try {
961
- svgUrls = await this.figma.renderImages(fileKey, vMissing.map((n) => n.id), "svg", auth);
962
- await this.retryMissing(vMissing, svgUrls, fileKey, "svg", auth, 1);
963
- } catch (err) {
964
- this.logger.warn(`Vector render failed (keeping cached vectors + image fills): ${err.message}`);
965
- }
966
- }
1652
+ const { urls: svgUrls, rateLimited } = vMissing.length ? await this.renderVectors(vMissing.map((n) => n.id), fileKey, auth) : { urls: {}, rateLimited: false };
967
1653
  const iMissing = images.filter((n) => !n.assetSrc && n.asset?.imageRef);
968
1654
  let embedded = 0;
969
1655
  embedded += await this.embed(vMissing, svgUrls, "image/svg+xml", fileKey, "svg", 1);
970
1656
  embedded += await this.embedImageFills(iMissing, fileKey, auth);
1657
+ const unresolvedVectors = vectors.filter((n) => !n.assetSrc).length;
1658
+ const unresolvedImages = images.filter((n) => !n.assetSrc).length;
971
1659
  this.logger.log(`Assets: ${vectors.length} vectors, ${images.length} image fills, ${fromCache} from cache, ${embedded} newly embedded`);
972
- return { vectors: vectors.length, images: images.length, embedded, fromCache };
1660
+ if (unresolvedVectors || unresolvedImages) {
1661
+ this.logger.warn(`Assets unresolved: ${unresolvedVectors}/${vectors.length} vectors, ${unresolvedImages}/${images.length} image fills` + (rateLimited ? " (Figma render endpoint rate-limited; they fall back to placeholders)" : ""));
1662
+ }
1663
+ return {
1664
+ vectors: vectors.length,
1665
+ images: images.length,
1666
+ embedded,
1667
+ fromCache,
1668
+ unresolvedVectors,
1669
+ unresolvedImages,
1670
+ rateLimited
1671
+ };
1672
+ }
1673
+ /**
1674
+ * Render vector node ids via /v1/images in small batches. Each batch is
1675
+ * retried with capped exponential backoff on a 429 and isolated so an
1676
+ * exhausted batch leaves only its own ids unresolved. Returns the merged
1677
+ * id -> url map and whether any batch was rate-limited.
1678
+ */
1679
+ async renderVectors(ids, fileKey, auth) {
1680
+ const urls = {};
1681
+ let rateLimited = false;
1682
+ for (let i = 0; i < ids.length; i += this.renderBatchSize) {
1683
+ const batch = ids.slice(i, i + this.renderBatchSize);
1684
+ try {
1685
+ const rendered = await this.withRenderRetry(() => this.figma.renderImages(fileKey, batch, "svg", auth), (was429) => {
1686
+ if (was429)
1687
+ rateLimited = true;
1688
+ });
1689
+ for (const [id, url] of Object.entries(rendered)) {
1690
+ if (url)
1691
+ urls[id] = url;
1692
+ }
1693
+ } catch (err) {
1694
+ if (err instanceof errors_1.FigmaApiError && err.status === 429)
1695
+ rateLimited = true;
1696
+ this.logger.warn(`Vector render batch [${i}, ${i + batch.length}) failed after retries: ${err.message}`);
1697
+ }
1698
+ }
1699
+ return { urls, rateLimited };
1700
+ }
1701
+ /**
1702
+ * Run a render call, retrying on a Figma 429 with capped exponential backoff.
1703
+ * The backoff is deliberately capped (`renderMaxBackoffMs`) so a multi-hour
1704
+ * `Retry-After` can never block the request — we exhaust a few quick attempts
1705
+ * and then surface the failure instead of waiting.
1706
+ */
1707
+ async withRenderRetry(fn, onRateLimit) {
1708
+ let lastErr;
1709
+ for (let attempt = 0; attempt < this.renderMaxAttempts; attempt++) {
1710
+ try {
1711
+ return await fn();
1712
+ } catch (err) {
1713
+ lastErr = err;
1714
+ const is429 = err instanceof errors_1.FigmaApiError && err.status === 429;
1715
+ if (!is429 || attempt === this.renderMaxAttempts - 1)
1716
+ throw err;
1717
+ onRateLimit(true);
1718
+ const backoff = Math.min(this.renderMaxBackoffMs, 500 * 2 ** attempt);
1719
+ this.logger.warn(`Figma render rate-limited; retrying in ${backoff}ms (attempt ${attempt + 1}/${this.renderMaxAttempts})`);
1720
+ await this.sleep(backoff);
1721
+ }
1722
+ }
1723
+ throw lastErr;
1724
+ }
1725
+ sleep(ms) {
1726
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
973
1727
  }
974
1728
  assetKey(fileKey, id, format, scale) {
975
1729
  return `asset:${fileKey}:${id}:${format}:${scale}`;
@@ -1057,22 +1811,6 @@ var require_asset_resolver = __commonJS({
1057
1811
  for (const child of node.children)
1058
1812
  this.collect(child, vectors, images);
1059
1813
  }
1060
- async retryMissing(nodes, urlMap, fileKey, format, auth, scale) {
1061
- const missing = nodes.filter((n) => !urlMap[n.id]).map((n) => n.id);
1062
- if (!missing.length)
1063
- return;
1064
- try {
1065
- const retry = await this.figma.renderImages(fileKey, missing, format, auth, scale);
1066
- for (const [id, url] of Object.entries(retry)) {
1067
- if (url)
1068
- urlMap[id] = url;
1069
- }
1070
- const stillMissing = missing.filter((id) => !urlMap[id]).length;
1071
- this.logger.log(`Retried ${missing.length} missing ${format} assets; ${stillMissing} still unrendered`);
1072
- } catch (err) {
1073
- this.logger.warn(`Asset retry (${format}) failed: ${err.message}`);
1074
- }
1075
- }
1076
1814
  async embed(nodes, urlMap, mime, fileKey, format, scale) {
1077
1815
  let count = 0;
1078
1816
  for (let i = 0; i < nodes.length; i += this.fetchConcurrency) {
@@ -1580,16 +2318,22 @@ var require_convert = __commonJS({
1580
2318
  const node = await this.client.fetchNode(target2.fileKey, target2.nodeId, auth);
1581
2319
  const ir = this.styleIr.convert(node);
1582
2320
  let assetWarning;
1583
- if (options.assets !== false) {
2321
+ if (options.assets === true) {
1584
2322
  try {
1585
- await this.assets.resolve(ir, target2.fileKey, auth, options.assetScale ?? 2);
2323
+ const r = await this.assets.resolve(ir, target2.fileKey, auth, options.assetScale ?? 2);
2324
+ const unresolved = r.unresolvedVectors + r.unresolvedImages;
2325
+ if (unresolved > 0) {
2326
+ assetWarning = `${unresolved} asset(s) could not be rendered (${r.unresolvedVectors} vectors, ${r.unresolvedImages} image fills) and fall back to placeholders` + (r.rateLimited ? ". Figma\u2019s render endpoint is rate-limited \u2014 retry later for full fidelity." : ".");
2327
+ this.logger.warn(assetWarning);
2328
+ }
1586
2329
  } catch (err) {
1587
2330
  assetWarning = `Assets not fully resolved: ${err.message}`;
1588
2331
  this.logger.warn(assetWarning);
1589
2332
  }
1590
2333
  }
1591
2334
  const mode = options.mode ?? "tailwind";
1592
- let fragment = this.renderer.renderFragment(ir, { mode });
2335
+ const round = options.round ?? true;
2336
+ let fragment = this.renderer.renderFragment(ir, { mode, round });
1593
2337
  let usedLlm = false;
1594
2338
  if (options.llm && this.restructure?.enabled) {
1595
2339
  fragment = await this.restructure.restructure(fragment);
@@ -1695,7 +2439,7 @@ var require_dist = __commonJS({
1695
2439
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports2, p)) __createBinding(exports2, m, p);
1696
2440
  };
1697
2441
  Object.defineProperty(exports, "__esModule", { value: true });
1698
- exports.createFigmaCore = exports.countNodes = exports.FigmaConverter = exports.stripHeavyAssets = exports.stripCodeFence = exports.runAgent = exports.RefineAgent = exports.LlmRestructure = exports.ollamaConfigFromEnv = exports.OllamaClient = exports.AssetResolver = exports.px = exports.figmaColorToCss = exports.cssToTailwind = exports.HtmlRenderer = exports.StyleIr = exports.resolveFigmaTarget = exports.normalizeNodeId = exports.parseFigmaUrl = exports.FigmaClient = exports.NoopLogger = exports.ConsoleLogger = exports.DiskCache = exports.MemoryCache = exports.FigmaInputError = exports.FigmaApiError = void 0;
2442
+ exports.createFigmaCore = exports.countNodes = exports.FigmaConverter = exports.stripHeavyAssets = exports.stripCodeFence = exports.runAgent = exports.RefineAgent = exports.LlmRestructure = exports.ollamaConfigFromEnv = exports.OllamaClient = exports.AssetResolver = exports.stableStringify = exports.toCompactDesign = exports.px = exports.figmaColorToCss = exports.cssToTailwind = exports.HtmlRenderer = exports.IMAGE_FILL_TOKEN = exports.StyleIr = exports.resolveFigmaTarget = exports.normalizeNodeId = exports.parseFigmaUrl = exports.FigmaClient = exports.NoopLogger = exports.ConsoleLogger = exports.DiskCache = exports.MemoryCache = exports.FigmaInputError = exports.FigmaApiError = void 0;
1699
2443
  var errors_1 = require_errors();
1700
2444
  Object.defineProperty(exports, "FigmaApiError", { enumerable: true, get: function() {
1701
2445
  return errors_1.FigmaApiError;
@@ -1735,6 +2479,9 @@ var require_dist = __commonJS({
1735
2479
  Object.defineProperty(exports, "StyleIr", { enumerable: true, get: function() {
1736
2480
  return style_ir_1.StyleIr;
1737
2481
  } });
2482
+ Object.defineProperty(exports, "IMAGE_FILL_TOKEN", { enumerable: true, get: function() {
2483
+ return style_ir_1.IMAGE_FILL_TOKEN;
2484
+ } });
1738
2485
  var html_renderer_1 = require_html_renderer();
1739
2486
  Object.defineProperty(exports, "HtmlRenderer", { enumerable: true, get: function() {
1740
2487
  return html_renderer_1.HtmlRenderer;
@@ -1750,6 +2497,13 @@ var require_dist = __commonJS({
1750
2497
  Object.defineProperty(exports, "px", { enumerable: true, get: function() {
1751
2498
  return color_1.px;
1752
2499
  } });
2500
+ var compact_design_1 = require_compact_design();
2501
+ Object.defineProperty(exports, "toCompactDesign", { enumerable: true, get: function() {
2502
+ return compact_design_1.toCompactDesign;
2503
+ } });
2504
+ Object.defineProperty(exports, "stableStringify", { enumerable: true, get: function() {
2505
+ return compact_design_1.stableStringify;
2506
+ } });
1753
2507
  var asset_resolver_1 = require_asset_resolver();
1754
2508
  Object.defineProperty(exports, "AssetResolver", { enumerable: true, get: function() {
1755
2509
  return asset_resolver_1.AssetResolver;
@@ -1946,7 +2700,9 @@ function remoteExtractStyles(cfg, body) {
1946
2700
  var INLINE_LIMIT = 1e5;
1947
2701
  var core;
1948
2702
  var corePat;
2703
+ var coreOverride;
1949
2704
  function getCore(pat) {
2705
+ if (coreOverride) return coreOverride;
1950
2706
  if (!core || corePat !== pat) {
1951
2707
  core = (0, import_core.createFigmaCore)({ figmaToken: pat });
1952
2708
  corePat = pat;
@@ -1976,6 +2732,7 @@ async function convertFigmaToHtml(args, overrides = {}) {
1976
2732
  document: args.document ?? true,
1977
2733
  assets: args.assets,
1978
2734
  assetScale: args.assetScale,
2735
+ round: args.round,
1979
2736
  llm: args.llm
1980
2737
  });
1981
2738
  result = { name: r.name, nodeCount: r.nodeCount, mode: r.mode, llm: r.llm, html: r.html };
@@ -1986,6 +2743,7 @@ async function convertFigmaToHtml(args, overrides = {}) {
1986
2743
  document: args.document ?? true,
1987
2744
  assets: args.assets,
1988
2745
  assetScale: args.assetScale,
2746
+ round: args.round,
1989
2747
  llm: args.llm
1990
2748
  });
1991
2749
  result = { name: r.name, nodeCount: r.nodeCount, mode: r.mode, llm: r.llm, html: r.html, previewUrl: r.previewUrl };
@@ -2013,23 +2771,33 @@ async function getFigmaData(args, overrides = {}) {
2013
2771
  } else {
2014
2772
  ir = await remoteExtractStyles(cfg, target(args));
2015
2773
  }
2016
- const json = JSON.stringify(ir, null, 2);
2017
2774
  const nodeCount = countIr(ir);
2775
+ const compact = (0, import_core.toCompactDesign)(ir);
2776
+ const json = JSON.stringify(compact);
2777
+ const legend = [
2778
+ "Compact design format (token-optimized):",
2779
+ "- `globalVars.styles`: id -> CSS declaration map. A node's full CSS = { ...globalVars.styles[node.style], ...node.box }.",
2780
+ "- `node.box`: per-node left/top/width/height (kept inline because unique).",
2781
+ '- `elements`: id -> a subtree that repeats; a node `{ref:"e1"}` is an instance of elements.e1.',
2782
+ "- `root`: the node tree (each node: tag, optional type/name/text/asset/style/box/children)."
2783
+ ].join("\n");
2018
2784
  if (json.length <= INLINE_LIMIT) {
2019
- return [`Style IR for "${ir.name}" (${nodeCount} nodes, ${describeConfig(cfg)}):`, "", json].join("\n");
2785
+ return [
2786
+ `Compact design for "${compact.name}" (${nodeCount} nodes, ${describeConfig(cfg)}):`,
2787
+ legend,
2788
+ "",
2789
+ json
2790
+ ].join("\n");
2020
2791
  }
2021
- const filePath = await writeOut(cfg.outDir, `${sanitize(ir.name)}.ir.json`, json);
2792
+ const filePath = await writeOut(cfg.outDir, `${sanitize(compact.name)}.design.json`, json);
2022
2793
  return [
2023
- `Style IR for "${ir.name}" is large (${nodeCount} nodes, ${(json.length / 1024).toFixed(1)} KB) \u2014 written to a file.`,
2794
+ `Compact design for "${compact.name}" is large (${nodeCount} nodes, ${(json.length / 1024).toFixed(1)} KB) \u2014 written to a file.`,
2024
2795
  `- file: ${filePath}`,
2025
- `- root: <${ir.tag}> figmaType=${ir.figmaType}, css keys=${Object.keys(ir.css || {}).length}`,
2796
+ `- ${Object.keys(compact.globalVars.styles).length} shared styles, ${Object.keys(compact.elements).length} repeated-subtree templates`,
2026
2797
  "",
2027
- "Top-level children:",
2028
- ...(ir.children || []).map(
2029
- (c, i) => ` ${i}. "${c.name}" <${c.tag}> ${c.figmaType} (${(c.children || []).length} children)`
2030
- ),
2798
+ legend,
2031
2799
  "",
2032
- "Read the file for the full tree (each node has exact `css`)."
2800
+ "Read the file for the full tree."
2033
2801
  ].join("\n");
2034
2802
  }
2035
2803
  function countIr(node) {
@@ -2059,7 +2827,7 @@ function buildServer() {
2059
2827
  "get_figma_data",
2060
2828
  {
2061
2829
  title: "Get Figma design data",
2062
- description: "Fetch the deterministic Style IR (exact CSS per node) for a Figma file or node, so an agent can reason about the design and generate code itself. Small trees are returned inline; large trees are written to a JSON file and summarised.",
2830
+ description: "Fetch a Figma file/node as a compact, token-efficient design tree for an agent to reason about and generate code from. Styles are deduped into globalVars.styles (nodes carry short `style` refs), per-node position/size is inline as `box`, and repeated subtrees are hoisted into `elements` ({ref}). Small results are returned inline; large ones are written to a JSON file and summarised.",
2063
2831
  inputSchema: targetShape
2064
2832
  },
2065
2833
  safe((a) => getFigmaData(a))
@@ -2075,6 +2843,9 @@ function buildServer() {
2075
2843
  document: z.boolean().optional().describe("Emit a full HTML document (default true) vs a fragment."),
2076
2844
  assets: z.boolean().optional().describe("Export & inline vectors/images as data URIs. Default false (placeholders shown instead)."),
2077
2845
  assetScale: z.number().min(1).max(4).optional().describe("Raster export scale (1-4). Default 2."),
2846
+ round: z.boolean().optional().describe(
2847
+ "Snap on-grid values to idiomatic Tailwind scale tokens (p-4, gap-6, rounded-lg, text-2xl); off-grid values stay exact. Default true. Set false for exact arbitrary values everywhere."
2848
+ ),
2078
2849
  llm: z.boolean().optional().describe("Run the LLM restructure pass (needs OLLAMA_* config). Default false."),
2079
2850
  outFile: z.string().optional().describe("Override the output file name (within the output dir).")
2080
2851
  }
@@ -2218,6 +2989,7 @@ Common flags (convert/data):
2218
2989
  --node <id> Node id ("1-23" or "1:23")
2219
2990
  --mode <m> tailwind | inline (convert)
2220
2991
  --assets Render & inline assets (convert; off by default, placeholders shown)
2992
+ --no-round Keep exact arbitrary values (default snaps on-grid values to Tailwind tokens)
2221
2993
  --llm Run LLM restructure (convert; needs OLLAMA_* env)
2222
2994
  --out <file> Output file name (convert)
2223
2995
 
@@ -2239,6 +3011,8 @@ async function main() {
2239
3011
  ...targetFrom(positionals[1], flags),
2240
3012
  mode: flags.mode === "inline" ? "inline" : flags.mode === "tailwind" ? "tailwind" : void 0,
2241
3013
  assets: flags.assets ? true : flags["no-assets"] ? false : void 0,
3014
+ round: flags["no-round"] ? false : void 0,
3015
+ // default true (in core)
2242
3016
  llm: flags.llm === true,
2243
3017
  outFile: typeof flags.out === "string" ? flags.out : void 0
2244
3018
  });