figma-coder-mcp 0.2.1 → 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 +124 -72
  3. package/dist/bin.js +920 -142
  4. package/package.json +5 -5
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;
@@ -1805,31 +2559,37 @@ var import_core = __toESM(require_dist(), 1);
1805
2559
  import { promises as fs2 } from "fs";
1806
2560
  import * as path2 from "path";
1807
2561
 
1808
- // src/credentials.ts
2562
+ // src/settings.ts
1809
2563
  import { promises as fs } from "fs";
1810
2564
  import * as os from "os";
1811
2565
  import * as path from "path";
1812
2566
  var DIR = path.join(os.homedir(), ".figma-mcp");
1813
- var FILE = path.join(DIR, "credentials.json");
1814
- function credentialsPath() {
2567
+ var FILE = path.join(DIR, "settings.json");
2568
+ function settingsPath() {
1815
2569
  return FILE;
1816
2570
  }
1817
- async function loadCredentials() {
2571
+ async function loadSettings() {
1818
2572
  try {
1819
2573
  const raw = await fs.readFile(FILE, "utf8");
1820
- return JSON.parse(raw);
2574
+ const parsed = JSON.parse(raw);
2575
+ if (!parsed.env || typeof parsed.env !== "object") parsed.env = {};
2576
+ return parsed;
1821
2577
  } catch {
1822
- return {};
2578
+ return { env: {} };
1823
2579
  }
1824
2580
  }
1825
- async function saveCredentials(patch) {
1826
- const current = await loadCredentials();
1827
- const next = { ...current, ...patch };
2581
+ async function saveSettings(patch) {
2582
+ const current = await loadSettings();
2583
+ const next = {
2584
+ ...current,
2585
+ ...patch,
2586
+ env: { ...current.env ?? {}, ...patch.env ?? {} }
2587
+ };
1828
2588
  await fs.mkdir(DIR, { recursive: true });
1829
2589
  await fs.writeFile(FILE, JSON.stringify(next, null, 2), { mode: 384 });
1830
2590
  return next;
1831
2591
  }
1832
- async function clearCredentials() {
2592
+ async function clearSettings() {
1833
2593
  try {
1834
2594
  await fs.unlink(FILE);
1835
2595
  } catch {
@@ -1839,24 +2599,25 @@ async function clearCredentials() {
1839
2599
  // src/refresh.ts
1840
2600
  var SKEW_MS = 6e4;
1841
2601
  async function ensureFreshApiToken(apiUrl, force = false) {
1842
- const creds = await loadCredentials();
1843
- if (!creds.apiToken || !creds.refreshToken) return;
2602
+ const settings = await loadSettings();
2603
+ const apiToken = settings.env?.FIGMA_MCP_TOKEN;
2604
+ if (!apiToken || !settings.refreshToken) return;
1844
2605
  if (!force) {
1845
- if (!creds.apiTokenExpiresAt) return;
1846
- if (Date.now() < creds.apiTokenExpiresAt - SKEW_MS) return;
2606
+ if (!settings.apiTokenExpiresAt) return;
2607
+ if (Date.now() < settings.apiTokenExpiresAt - SKEW_MS) return;
1847
2608
  }
1848
2609
  const base = apiUrl.replace(/\/+$/, "");
1849
2610
  try {
1850
2611
  const res = await fetch(`${base}/auth/figma/cli-refresh`, {
1851
2612
  method: "POST",
1852
2613
  headers: { "Content-Type": "application/json" },
1853
- body: JSON.stringify({ refresh_token: creds.refreshToken })
2614
+ body: JSON.stringify({ refresh_token: settings.refreshToken })
1854
2615
  });
1855
2616
  if (!res.ok) return;
1856
2617
  const t = await res.json();
1857
- await saveCredentials({
1858
- apiToken: t.access_token,
1859
- refreshToken: t.refresh_token ?? creds.refreshToken,
2618
+ await saveSettings({
2619
+ env: { FIGMA_MCP_TOKEN: t.access_token },
2620
+ refreshToken: t.refresh_token ?? settings.refreshToken,
1860
2621
  apiTokenExpiresAt: t.expires_in ? Date.now() + t.expires_in * 1e3 : void 0
1861
2622
  });
1862
2623
  } catch {
@@ -1864,13 +2625,15 @@ async function ensureFreshApiToken(apiUrl, force = false) {
1864
2625
  }
1865
2626
 
1866
2627
  // src/config.ts
2628
+ var DEFAULT_API_URL = "https://figmacoder-api.sitenow.cloud";
1867
2629
  async function resolveConfig(overrides = {}) {
1868
- let creds = await loadCredentials();
1869
- const mode = overrides.mode ?? process.env.FIGMA_MCP_MODE ?? "auto";
1870
- const pat = overrides.pat ?? process.env.FIGMA_PAT ?? process.env.FIGMA_TOKEN ?? creds.pat;
1871
- const apiUrl = overrides.apiUrl ?? process.env.FIGMA_MCP_API ?? creds.apiUrl;
2630
+ let settings = await loadSettings();
2631
+ let env = settings.env ?? {};
2632
+ const mode = overrides.mode ?? process.env.FIGMA_MCP_MODE ?? env.FIGMA_MCP_MODE ?? "auto";
2633
+ const pat = overrides.pat ?? process.env.FIGMA_PAT ?? process.env.FIGMA_TOKEN ?? env.FIGMA_PAT ?? env.FIGMA_TOKEN;
2634
+ const apiUrl = overrides.apiUrl ?? process.env.FIGMA_MCP_API ?? env.FIGMA_MCP_API ?? DEFAULT_API_URL;
1872
2635
  const apiTokenOverride = overrides.apiToken ?? process.env.FIGMA_MCP_TOKEN;
1873
- const outDir = overrides.outDir ?? process.env.FIGMA_MCP_OUT_DIR ?? `${process.cwd()}/figma-output`;
2636
+ const outDir = overrides.outDir ?? process.env.FIGMA_MCP_OUT_DIR ?? env.FIGMA_MCP_OUT_DIR ?? `${process.cwd()}/figma-output`;
1874
2637
  let effectiveMode;
1875
2638
  if (mode === "local") {
1876
2639
  effectiveMode = "local";
@@ -1881,9 +2644,10 @@ async function resolveConfig(overrides = {}) {
1881
2644
  }
1882
2645
  if (effectiveMode === "remote" && apiUrl && !apiTokenOverride) {
1883
2646
  await ensureFreshApiToken(apiUrl);
1884
- creds = await loadCredentials();
2647
+ settings = await loadSettings();
2648
+ env = settings.env ?? {};
1885
2649
  }
1886
- const apiToken = apiTokenOverride ?? creds.apiToken;
2650
+ const apiToken = apiTokenOverride ?? env.FIGMA_MCP_TOKEN;
1887
2651
  return { mode, effectiveMode, pat, apiUrl, apiToken, outDir };
1888
2652
  }
1889
2653
  function describeConfig(cfg) {
@@ -1907,7 +2671,7 @@ async function post(cfg, route, body) {
1907
2671
  let res = await fetch(url, { method: "POST", headers: authHeaders(cfg), body: payload });
1908
2672
  if (res.status === 401 && cfg.apiToken) {
1909
2673
  await ensureFreshApiToken(cfg.apiUrl, true);
1910
- const refreshed = (await loadCredentials()).apiToken;
2674
+ const refreshed = (await loadSettings()).env?.FIGMA_MCP_TOKEN;
1911
2675
  if (refreshed && refreshed !== cfg.apiToken) {
1912
2676
  res = await fetch(url, { method: "POST", headers: authHeaders(cfg, refreshed), body: payload });
1913
2677
  }
@@ -1936,7 +2700,9 @@ function remoteExtractStyles(cfg, body) {
1936
2700
  var INLINE_LIMIT = 1e5;
1937
2701
  var core;
1938
2702
  var corePat;
2703
+ var coreOverride;
1939
2704
  function getCore(pat) {
2705
+ if (coreOverride) return coreOverride;
1940
2706
  if (!core || corePat !== pat) {
1941
2707
  core = (0, import_core.createFigmaCore)({ figmaToken: pat });
1942
2708
  corePat = pat;
@@ -1966,6 +2732,7 @@ async function convertFigmaToHtml(args, overrides = {}) {
1966
2732
  document: args.document ?? true,
1967
2733
  assets: args.assets,
1968
2734
  assetScale: args.assetScale,
2735
+ round: args.round,
1969
2736
  llm: args.llm
1970
2737
  });
1971
2738
  result = { name: r.name, nodeCount: r.nodeCount, mode: r.mode, llm: r.llm, html: r.html };
@@ -1976,6 +2743,7 @@ async function convertFigmaToHtml(args, overrides = {}) {
1976
2743
  document: args.document ?? true,
1977
2744
  assets: args.assets,
1978
2745
  assetScale: args.assetScale,
2746
+ round: args.round,
1979
2747
  llm: args.llm
1980
2748
  });
1981
2749
  result = { name: r.name, nodeCount: r.nodeCount, mode: r.mode, llm: r.llm, html: r.html, previewUrl: r.previewUrl };
@@ -2003,23 +2771,33 @@ async function getFigmaData(args, overrides = {}) {
2003
2771
  } else {
2004
2772
  ir = await remoteExtractStyles(cfg, target(args));
2005
2773
  }
2006
- const json = JSON.stringify(ir, null, 2);
2007
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");
2008
2784
  if (json.length <= INLINE_LIMIT) {
2009
- 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");
2010
2791
  }
2011
- 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);
2012
2793
  return [
2013
- `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.`,
2014
2795
  `- file: ${filePath}`,
2015
- `- 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`,
2016
2797
  "",
2017
- "Top-level children:",
2018
- ...(ir.children || []).map(
2019
- (c, i) => ` ${i}. "${c.name}" <${c.tag}> ${c.figmaType} (${(c.children || []).length} children)`
2020
- ),
2798
+ legend,
2021
2799
  "",
2022
- "Read the file for the full tree (each node has exact `css`)."
2800
+ "Read the file for the full tree."
2023
2801
  ].join("\n");
2024
2802
  }
2025
2803
  function countIr(node) {
@@ -2049,7 +2827,7 @@ function buildServer() {
2049
2827
  "get_figma_data",
2050
2828
  {
2051
2829
  title: "Get Figma design data",
2052
- 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.",
2053
2831
  inputSchema: targetShape
2054
2832
  },
2055
2833
  safe((a) => getFigmaData(a))
@@ -2058,13 +2836,16 @@ function buildServer() {
2058
2836
  "convert_figma_to_html",
2059
2837
  {
2060
2838
  title: "Convert Figma to HTML + Tailwind",
2061
- description: "Convert a Figma file/node into finished, self-contained HTML + Tailwind (deterministic Style IR, assets inlined, optional LLM restructure). The HTML is written to a file; a compact summary + path is returned to keep context small.",
2839
+ description: "Convert a Figma file/node into finished, self-contained HTML + Tailwind (deterministic Style IR, optional LLM restructure). Asset inlining is off by default (placeholders shown for images/vectors); pass assets:true to render & inline them. The HTML is written to a file; a compact summary + path is returned to keep context small.",
2062
2840
  inputSchema: {
2063
2841
  ...targetShape,
2064
2842
  mode: z.enum(["tailwind", "inline"]).optional().describe("Output mode. Default 'tailwind'."),
2065
2843
  document: z.boolean().optional().describe("Emit a full HTML document (default true) vs a fragment."),
2066
- assets: z.boolean().optional().describe("Export & inline vectors/images as data URIs. Default true."),
2844
+ assets: z.boolean().optional().describe("Export & inline vectors/images as data URIs. Default false (placeholders shown instead)."),
2067
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
+ ),
2068
2849
  llm: z.boolean().optional().describe("Run the LLM restructure pass (needs OLLAMA_* config). Default false."),
2069
2850
  outFile: z.string().optional().describe("Override the output file name (within the output dir).")
2070
2851
  }
@@ -2100,9 +2881,7 @@ var page = (title, body) => `<!doctype html><meta charset="utf-8"><title>figma-c
2100
2881
  async function login(opts = {}) {
2101
2882
  const cfg = await resolveConfig({ apiUrl: opts.apiUrl });
2102
2883
  if (!cfg.apiUrl) {
2103
- throw new Error(
2104
- "OAuth login needs the backend URL. Set it with `figma-coder-mcp set-api <URL>` or FIGMA_MCP_API."
2105
- );
2884
+ throw new Error("OAuth login needs the backend URL. Set FIGMA_MCP_API or pass --api <URL>.");
2106
2885
  }
2107
2886
  const base = cfg.apiUrl.replace(/\/+$/, "");
2108
2887
  const tokens = await new Promise((resolve2, reject) => {
@@ -2155,13 +2934,15 @@ If it doesn't open automatically, visit:
2155
2934
  openBrowser(authorizeUrl);
2156
2935
  });
2157
2936
  });
2158
- await saveCredentials({
2159
- apiUrl: base,
2160
- apiToken: tokens.access_token,
2937
+ await saveSettings({
2938
+ env: {
2939
+ FIGMA_MCP_API: base,
2940
+ FIGMA_MCP_TOKEN: tokens.access_token
2941
+ },
2161
2942
  refreshToken: tokens.refresh_token,
2162
2943
  apiTokenExpiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1e3 : void 0
2163
2944
  });
2164
- console.error(`\u2713 Saved Figma OAuth session to ${credentialsPath()}`);
2945
+ console.error(`\u2713 Saved Figma OAuth session to ${settingsPath()}`);
2165
2946
  console.error("Run the MCP in remote mode (set FIGMA_MCP_MODE=remote) to use it.");
2166
2947
  }
2167
2948
 
@@ -2192,29 +2973,31 @@ function targetFrom(value, flags) {
2192
2973
  if (/figma\.com\//.test(value)) return { figmaUrl: value, nodeId };
2193
2974
  return { fileKey: value, nodeId };
2194
2975
  }
2195
- var HELP = `figma-coder-mcp \u2014 Figma -> HTML/Tailwind for AI agents (MCP server + CLI)
2976
+ var HELP = `figma-coder-mcp: Figma -> HTML/Tailwind for AI agents (MCP server + CLI)
2196
2977
 
2197
2978
  Usage:
2198
2979
  figma-coder-mcp [serve] Start the MCP server over stdio (default)
2199
2980
  figma-coder-mcp convert <url|key> [..] One-off convert to HTML (writes a file)
2200
2981
  figma-coder-mcp data <url|key> [..] One-off: print/save the Style IR
2201
2982
  figma-coder-mcp set-token <PAT> Store a Figma personal access token (local mode)
2202
- figma-coder-mcp set-api <URL> Store the backend converter URL (remote mode)
2203
2983
  figma-coder-mcp login [--api <URL>] Log in with Figma via OAuth (remote mode)
2204
2984
  figma-coder-mcp status Show how requests will be served (no secrets)
2205
- figma-coder-mcp logout Remove stored credentials
2985
+ figma-coder-mcp logout Remove stored settings
2206
2986
  figma-coder-mcp help Show this help
2207
2987
 
2208
2988
  Common flags (convert/data):
2209
2989
  --node <id> Node id ("1-23" or "1:23")
2210
2990
  --mode <m> tailwind | inline (convert)
2211
- --no-assets Skip asset inlining (convert)
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)
2212
2993
  --llm Run LLM restructure (convert; needs OLLAMA_* env)
2213
2994
  --out <file> Output file name (convert)
2214
2995
 
2215
2996
  Config (env or stored): FIGMA_PAT, FIGMA_MCP_API, FIGMA_MCP_TOKEN, FIGMA_MCP_MODE,
2216
2997
  FIGMA_MCP_OUT_DIR, FIGMA_MCP_NO_BROWSER (skip auto-opening the browser on login).
2217
- Auto mode prefers local (PAT) so it works even if the backend is down.`;
2998
+ Auto mode prefers local (PAT) so it works even if the backend is down; with no PAT
2999
+ it uses the hosted backend by default (override the URL with FIGMA_MCP_API).
3000
+ Stored settings live in ~/.figma-mcp/settings.json under "env" (edit it directly if you like).`;
2218
3001
  async function main() {
2219
3002
  const [positionals, flags] = parseArgs(process.argv.slice(2));
2220
3003
  const cmd = positionals[0] ?? "serve";
@@ -2227,7 +3010,9 @@ async function main() {
2227
3010
  const text = await convertFigmaToHtml({
2228
3011
  ...targetFrom(positionals[1], flags),
2229
3012
  mode: flags.mode === "inline" ? "inline" : flags.mode === "tailwind" ? "tailwind" : void 0,
2230
- assets: flags["no-assets"] ? false : void 0,
3013
+ assets: flags.assets ? true : flags["no-assets"] ? false : void 0,
3014
+ round: flags["no-round"] ? false : void 0,
3015
+ // default true (in core)
2231
3016
  llm: flags.llm === true,
2232
3017
  outFile: typeof flags.out === "string" ? flags.out : void 0
2233
3018
  });
@@ -2242,15 +3027,8 @@ async function main() {
2242
3027
  case "set-token": {
2243
3028
  const pat = positionals[1];
2244
3029
  if (!pat) throw new Error("Usage: figma-coder-mcp set-token <PAT>");
2245
- await saveCredentials({ pat });
2246
- console.log(`Stored Figma PAT in ${credentialsPath()}`);
2247
- return;
2248
- }
2249
- case "set-api": {
2250
- const url = positionals[1];
2251
- if (!url) throw new Error("Usage: figma-coder-mcp set-api <URL>");
2252
- await saveCredentials({ apiUrl: url });
2253
- console.log(`Stored backend URL (${url}) in ${credentialsPath()}`);
3030
+ await saveSettings({ env: { FIGMA_PAT: pat } });
3031
+ console.log(`Stored FIGMA_PAT in ${settingsPath()}`);
2254
3032
  return;
2255
3033
  }
2256
3034
  case "login": {
@@ -2260,12 +3038,12 @@ async function main() {
2260
3038
  case "status": {
2261
3039
  const cfg = await resolveConfig();
2262
3040
  console.log(describeConfig(cfg));
2263
- console.log(`credentials: ${credentialsPath()}`);
3041
+ console.log(`settings: ${settingsPath()}`);
2264
3042
  return;
2265
3043
  }
2266
3044
  case "logout":
2267
- await clearCredentials();
2268
- console.log("Cleared stored credentials.");
3045
+ await clearSettings();
3046
+ console.log("Cleared stored settings.");
2269
3047
  return;
2270
3048
  case "help":
2271
3049
  case "--help":