@clovis500c/figma-bridge 1.13.0 → 1.14.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.
package/dist/server.js CHANGED
@@ -214,7 +214,7 @@ var init_setup = __esm(() => {
214
214
  });
215
215
 
216
216
  // package.json
217
- var version = "1.13.0";
217
+ var version = "1.14.0";
218
218
  var init_package = () => {};
219
219
 
220
220
  // node_modules/zod/v3/helpers/util.js
@@ -27388,13 +27388,13 @@ Turn ${opt(a.target, "the selected frame")} into code for the project at ${opt(a
27388
27388
  name: "figma-to-roblox",
27389
27389
  title: "Figma to Roblox",
27390
27390
  description: "Turn a frame into Roblox UI and place it in Studio with a Roblox Studio MCP.",
27391
- args: { target: "Layer name or id (default: the selection)", mode: "scale, offset or hybrid (default hybrid)" },
27391
+ args: { target: "Layer name or id (default: the selection)", mode: "scale (default), offset or hybrid" },
27392
27392
  render: (a) => `
27393
27393
  Turn ${opt(a.target, "the selected frame")} into Roblox UI that looks the same, and put it in the open Roblox Studio place.
27394
- 1. export_roblox {${a.target ? `nodeId:"<id of ${a.target}>", ` : ""}mode:"${opt(a.mode, "hybrid")}"}. Read the warnings (approximations, font substitutions).
27394
+ 1. export_roblox {${a.target ? `nodeId:"<id of ${a.target}>", ` : ""}mode:"${opt(a.mode, "scale")}"}. Read the warnings (approximations, font substitutions).
27395
27395
  2. If assets are not uploaded (placeholders rbxassetid://PENDING_n): upload each file in assets[] with the Roblox Studio MCP (e.g. upload_image), and replace each placeholder in the Luau with the returned id.
27396
27396
  3. Run the Luau with execute_luau: it creates the ScreenGui under StarterGui (replacing an earlier copy) and returns it.
27397
- 4. Take a Studio screenshot and compare it with screenshot {returnImage:true} of the Figma frame. Fix differences in the Luau (or in Figma, then export again).
27397
+ 4. Take a Studio screenshot and compare it with screenshot {returnImage:true} of the Figma frame. Fix differences in the Luau (or in Figma, then export again), keeping every size, position, padding and gap in scale (no offsets) and the PascalCase names.
27398
27398
  5. Report the result and anything Roblox could not reproduce.`
27399
27399
  },
27400
27400
  {
@@ -27413,6 +27413,218 @@ Audit and improve this file${a.target ? ` (fixes limited to ${a.target})` : ""}.
27413
27413
  ];
27414
27414
  });
27415
27415
 
27416
+ // plugin/lib/naming.ts
27417
+ function isGenericName(n) {
27418
+ const name = n.name.trim();
27419
+ if (!name || GENERIC_NAME.test(name))
27420
+ return true;
27421
+ if (n.kind === "text" && n.text !== undefined) {
27422
+ const t = n.text.trim();
27423
+ const a = name.toLowerCase();
27424
+ const b = t.toLowerCase();
27425
+ return a === b || a.length >= 12 && b.indexOf(a) === 0;
27426
+ }
27427
+ return false;
27428
+ }
27429
+ function visibleKids(n) {
27430
+ return n.children.filter(function(c) {
27431
+ return !c.hidden;
27432
+ });
27433
+ }
27434
+ function maxFont(n) {
27435
+ let m = n.kind === "text" && n.fontSize ? n.fontSize : 0;
27436
+ for (let i = 0;i < n.children.length; i++) {
27437
+ const c = n.children[i];
27438
+ if (c && !c.hidden)
27439
+ m = Math.max(m, maxFont(c));
27440
+ }
27441
+ return m;
27442
+ }
27443
+ function onlyKinds(list, kinds) {
27444
+ for (let i = 0;i < list.length; i++) {
27445
+ const c = list[i];
27446
+ if (c && kinds.indexOf(c.kind) === -1)
27447
+ return false;
27448
+ }
27449
+ return list.length > 0;
27450
+ }
27451
+ function buttonLike(n) {
27452
+ const kids = visibleKids(n);
27453
+ if (!n.background || n.h > 72 || kids.length === 0 || kids.length > 3)
27454
+ return false;
27455
+ let texts = 0;
27456
+ for (let i = 0;i < kids.length; i++) {
27457
+ const c = kids[i];
27458
+ if (!c)
27459
+ continue;
27460
+ if (c.kind === "text") {
27461
+ texts++;
27462
+ if ((c.text || "").length > 32)
27463
+ return false;
27464
+ } else if (c.kind !== "vector" && c.kind !== "image")
27465
+ return false;
27466
+ }
27467
+ return texts === 1;
27468
+ }
27469
+ function isScreen(n) {
27470
+ return SCREEN_W.indexOf(Math.round(n.w)) !== -1 && n.h >= 640 || n.w >= 1024 && n.h >= 600;
27471
+ }
27472
+ function largestText(n, parent) {
27473
+ const others = parent ? visibleKids(parent).filter(function(c) {
27474
+ return c !== n && c.kind === "text";
27475
+ }) : [];
27476
+ const size = n.fontSize || 14;
27477
+ return others.length > 0 && others.every(function(c) {
27478
+ return (c.fontSize || 14) < size;
27479
+ });
27480
+ }
27481
+ function covers(n, p) {
27482
+ return Math.abs(n.x) <= 1 && Math.abs(n.y) <= 1 && n.w >= p.w - 1 && n.h >= p.h - 1;
27483
+ }
27484
+ function roleOf(n, parent, root, top, inButton) {
27485
+ const kids = visibleKids(n);
27486
+ if (n.kind === "text") {
27487
+ const t = (n.text || "").trim();
27488
+ const size = n.fontSize || 14;
27489
+ if (inButton)
27490
+ return "Label";
27491
+ if (NUMERIC.test(t))
27492
+ return "Value";
27493
+ if (size >= top - 0.5 && size >= 18)
27494
+ return "Title";
27495
+ if (size >= 16 && largestText(n, parent))
27496
+ return "Title";
27497
+ if (size >= 20)
27498
+ return "Heading";
27499
+ if (t.length > 60)
27500
+ return "Description";
27501
+ if (size <= 12)
27502
+ return "Caption";
27503
+ if (t.length > 30)
27504
+ return "Description";
27505
+ return "Label";
27506
+ }
27507
+ if (n.kind === "vector")
27508
+ return "Icon";
27509
+ if (n.kind === "image" && kids.length === 0) {
27510
+ const round = n.ellipse || n.radius !== undefined && Math.abs(n.w - n.h) <= 1 && n.radius >= n.w / 2 - 1;
27511
+ return round ? "Avatar" : "Image";
27512
+ }
27513
+ if (kids.length === 0) {
27514
+ if (Math.min(n.w, n.h) <= 2 && Math.max(n.w, n.h) > 8)
27515
+ return "Divider";
27516
+ if (parent && covers(n, parent))
27517
+ return "Background";
27518
+ if (n.clickable)
27519
+ return "Button";
27520
+ if (n.ellipse)
27521
+ return n.w <= 16 ? "Dot" : "Circle";
27522
+ return "Shape";
27523
+ }
27524
+ if (n.clickable)
27525
+ return "Button";
27526
+ if (buttonLike(n)) {
27527
+ const label = kids.filter(function(c) {
27528
+ return c.kind === "text";
27529
+ })[0];
27530
+ return n.h <= 24 || label && (label.fontSize || 14) <= 12 ? "Badge" : "Button";
27531
+ }
27532
+ if (!parent) {
27533
+ if (isScreen(n))
27534
+ return "Screen";
27535
+ } else if (parent === root && isScreen(root)) {
27536
+ if (n.w >= root.w * 0.9 && n.y <= root.h * 0.1 && n.h <= root.h * 0.25)
27537
+ return "Header";
27538
+ if (n.w >= root.w * 0.9 && n.y + n.h >= root.h * 0.9 && n.h <= root.h * 0.25)
27539
+ return "Footer";
27540
+ if (n.h >= root.h * 0.9 && n.w <= root.w * 0.35 && n.x <= 1)
27541
+ return "Sidebar";
27542
+ }
27543
+ const flow = kids.filter(function(c) {
27544
+ return !c.absolute;
27545
+ });
27546
+ if (flow.length >= 2) {
27547
+ const r = baseName(flow[0]);
27548
+ const same = flow.every(function(c) {
27549
+ return baseName(c) === r;
27550
+ });
27551
+ if (same && r && r !== "Label" && r !== "Value" && r !== "Divider") {
27552
+ if (r === "Button")
27553
+ return "Actions";
27554
+ return r + (n.layout === "GRID" || n.wrap ? "Grid" : "List");
27555
+ }
27556
+ }
27557
+ if (n.background)
27558
+ return n.radius ? "Card" : "Panel";
27559
+ if (onlyKinds(kids, ["text"]))
27560
+ return "TextGroup";
27561
+ if (n.layout === "HORIZONTAL")
27562
+ return "Row";
27563
+ if (n.layout === "GRID")
27564
+ return "Grid";
27565
+ return "Container";
27566
+ }
27567
+ function baseName(n) {
27568
+ return pascalName(n.role || n.name).replace(/\d+$/, "");
27569
+ }
27570
+ function inferRoles(root) {
27571
+ const top = maxFont(root);
27572
+ const walk = function(n, parent, inButton) {
27573
+ const button = n.kind !== "text" && (n.clickable || buttonLike(n) || /button|btn/i.test(n.name));
27574
+ for (let i = 0;i < n.children.length; i++) {
27575
+ const c = n.children[i];
27576
+ if (c)
27577
+ walk(c, n, inButton || button);
27578
+ }
27579
+ if (isGenericName(n))
27580
+ n.role = roleOf(n, parent, root, top, inButton);
27581
+ };
27582
+ walk(root, null, false);
27583
+ }
27584
+ function pascalName(name) {
27585
+ let segs = name.split("/").map(function(s) {
27586
+ return s.replace(/^[\w-]+:/, "").trim();
27587
+ }).filter(function(s) {
27588
+ return s && !/^default$/i.test(s);
27589
+ });
27590
+ if (segs.length > 1)
27591
+ segs = segs.slice(1).concat([segs[0]]);
27592
+ const words = segs.join(" ").normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean);
27593
+ const out = words.map(function(w) {
27594
+ return w === w.toUpperCase() && w.length <= 4 ? w : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
27595
+ }).join("");
27596
+ return out.slice(0, 48);
27597
+ }
27598
+ function endsWithWord(name, words) {
27599
+ for (let i = 0;i < words.length; i++) {
27600
+ const w = words[i];
27601
+ if (name.length >= w.length && name.slice(-w.length) === w)
27602
+ return true;
27603
+ }
27604
+ return false;
27605
+ }
27606
+ function uniqueSiblingNames(names) {
27607
+ const counts = {};
27608
+ for (let i = 0;i < names.length; i++) {
27609
+ const k = names[i];
27610
+ counts[k] = (counts[k] || 0) + 1;
27611
+ }
27612
+ const seen = {};
27613
+ return names.map(function(k) {
27614
+ if ((counts[k] || 0) < 2)
27615
+ return k;
27616
+ seen[k] = (seen[k] || 0) + 1;
27617
+ return k + seen[k];
27618
+ });
27619
+ }
27620
+ var GENERIC_NAME, NUMERIC, SCREEN_W, ROLE_WORDS;
27621
+ var init_naming = __esm(() => {
27622
+ GENERIC_NAME = /^(frame|rectangle|ellipse|group|vector|text|line|polygon|star|component|instance|section|image|auto ?layout|union|subtract|intersect|exclude|boolean|layer|shape|slice|mask|container|wrapper|div|span|box)(\s*\d+)?(\s+copy(\s*\d+)?)?$/i;
27623
+ NUMERIC = /^[\s$€£¥+\-−]*[\d][\d\s.,:%/×x+\-−]*[\s$€£¥%kKmMbB]*$/;
27624
+ SCREEN_W = [360, 375, 390, 393, 402, 412, 414, 428, 430, 440];
27625
+ ROLE_WORDS = ("Actions Avatar Background Badge Banner Bar Body Button Card Column Container Content Controls Details Dialog Divider Footer Frame Grid Group Gui " + "Header Hero Hud HUD Info Item List Menu Modal Nav Navbar Navigation Overlay Panel Popup Row Screen Section Sidebar Slot Stack Stats Tab Tabs Tile " + "Toolbar Tooltip Window Wrapper").split(" ");
27626
+ });
27627
+
27416
27628
  // src/roblox/fonts.ts
27417
27629
  function robloxFamily(family, overrides = {}) {
27418
27630
  const key = family.trim().toLowerCase();
@@ -27517,26 +27729,24 @@ function visible(list) {
27517
27729
  function decide(n, opts) {
27518
27730
  const approximations = [];
27519
27731
  if (n.text)
27520
- return { kind: "text", shadow: false, approximations };
27732
+ return { kind: "text", approximations };
27521
27733
  const raster = opts.rasterize;
27522
27734
  const fills = visible(n.fills);
27523
27735
  const strokes = visible(n.strokes);
27524
- const effects = n.effects ?? [];
27525
- const drop = effects.filter((e) => e.type === "DROP_SHADOW");
27526
- const other = effects.filter((e) => e.type !== "DROP_SHADOW");
27736
+ const other = (n.effects ?? []).filter((e) => e.type !== "DROP_SHADOW");
27527
27737
  const hasChildren = !!n.children?.some((c) => !c.hidden);
27528
- if (n.vector || ["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON", "LINE"].includes(n.type)) {
27529
- return raster === "none" ? { kind: "frame", shadow: false, approximations: ["vector shape skipped (rasterize is none)"] } : { kind: "picture", shadow: false, approximations };
27738
+ if (n.vector || VECTOR_TYPES.includes(n.type)) {
27739
+ return raster === "none" ? { kind: "frame", approximations: ["vector shape skipped (rasterize is none)"] } : { kind: "picture", approximations };
27530
27740
  }
27531
27741
  const image = fills.length === 1 && fills[0].type === "IMAGE";
27532
- if (image && !hasChildren && !strokes.length && !effects.length && raster !== "all")
27533
- return { kind: "image", shadow: false, approximations };
27742
+ if (image && !hasChildren && !strokes.length && !other.length && raster !== "all")
27743
+ return { kind: "image", approximations };
27534
27744
  const reasons = [];
27535
27745
  if (fills.length > 1)
27536
27746
  reasons.push("several fills");
27537
27747
  if (fills.length === 1 && !["SOLID", "GRADIENT_LINEAR", "IMAGE"].includes(fills[0].type))
27538
27748
  reasons.push(`${fills[0].type.toLowerCase().replace("_", " ")} fill`);
27539
- if (image && (strokes.length || effects.length))
27749
+ if (image && (strokes.length || other.length))
27540
27750
  reasons.push("image with stroke or effects");
27541
27751
  if (strokes.length > 1 || strokes[0] && strokes[0].type !== "SOLID")
27542
27752
  reasons.push("complex stroke");
@@ -27546,18 +27756,16 @@ function decide(n, opts) {
27546
27756
  reasons.push(other.map((e) => e.type.toLowerCase().replace(/_/g, " ")).join(", "));
27547
27757
  if (n.type === "ELLIPSE" && Math.abs(n.w - n.h) > 0.5)
27548
27758
  reasons.push("oval");
27549
- const styled = fills.length + strokes.length + effects.length > 0;
27759
+ const styled = fills.length + strokes.length + (n.effects ?? []).length > 0;
27550
27760
  if ((reasons.length || raster === "all" && styled) && raster !== "none") {
27551
- return { kind: hasChildren ? "panel" : "picture", shadow: false, approximations };
27761
+ return { kind: hasChildren ? "panel" : "picture", approximations };
27552
27762
  }
27553
27763
  if (reasons.length)
27554
27764
  approximations.push(`${reasons.join(", ")}: approximated (rasterize is none)`);
27555
27765
  const r = n.radius ?? [];
27556
27766
  if (r.length && r.some((v) => Math.abs(v - r[0]) > 0.5))
27557
27767
  approximations.push("different corner radii: used the largest");
27558
- if (drop.length && raster === "none")
27559
- approximations.push("drop shadow skipped (rasterize is none)");
27560
- return { kind: "frame", shadow: drop.length > 0 && raster !== "none", approximations };
27768
+ return { kind: "frame", approximations };
27561
27769
  }
27562
27770
  function pictureRequests(root, opts) {
27563
27771
  const out = [];
@@ -27569,8 +27777,6 @@ function pictureRequests(root, opts) {
27569
27777
  out.push({ id: n.id, mode: "full" });
27570
27778
  if (d.kind === "panel")
27571
27779
  out.push({ id: n.id, mode: "panel" });
27572
- if (d.shadow)
27573
- out.push({ id: n.id, mode: "shadow" });
27574
27780
  if (d.kind !== "picture")
27575
27781
  for (const c of n.children ?? [])
27576
27782
  walk(c);
@@ -27590,6 +27796,56 @@ function imageHashes(root) {
27590
27796
  walk(root);
27591
27797
  return [...out];
27592
27798
  }
27799
+ function robloxName(base, flavor) {
27800
+ switch (flavor) {
27801
+ case "text":
27802
+ return /Label$/.test(base) ? base : `${base}Label`;
27803
+ case "button": {
27804
+ const b = base.replace(/Btn$/, "Button");
27805
+ return /Button$/.test(b) ? b : `${b}Button`;
27806
+ }
27807
+ case "icon":
27808
+ return endsWithWord(base, ICON_WORDS) ? base : `${base}Icon`;
27809
+ case "image":
27810
+ return endsWithWord(base, IMAGE_WORDS) ? base : `${base}Image`;
27811
+ default:
27812
+ return endsWithWord(base, ROLE_WORDS) ? base : `${base}${flavor === "frame" ? "Frame" : "Container"}`;
27813
+ }
27814
+ }
27815
+ function roleTree(n) {
27816
+ const fills = visible(n.fills);
27817
+ const kind = n.text ? "text" : n.vector || VECTOR_TYPES.includes(n.type) ? "vector" : fills.length === 1 && fills[0].type === "IMAGE" && !n.children?.length ? "image" : n.children?.length || n.layout ? "frame" : "shape";
27818
+ return {
27819
+ id: n.id,
27820
+ kind,
27821
+ name: n.name,
27822
+ x: n.x,
27823
+ y: n.y,
27824
+ w: n.w,
27825
+ h: n.h,
27826
+ hidden: n.hidden,
27827
+ text: n.text?.characters,
27828
+ fontSize: n.text ? Math.max(0, ...n.text.segments.map((s) => s.size)) : undefined,
27829
+ layout: n.layout?.mode,
27830
+ wrap: n.layout?.wrap,
27831
+ background: fills.length > 0 || visible(n.strokes).length > 0,
27832
+ radius: Math.max(0, ...n.radius ?? []),
27833
+ ellipse: n.type === "ELLIPSE",
27834
+ clickable: n.clickable,
27835
+ absolute: n.absolute,
27836
+ children: (n.children ?? []).map(roleTree)
27837
+ };
27838
+ }
27839
+ function dedupe(i) {
27840
+ const layers = i.children.filter((c) => !/^UI/.test(c.className));
27841
+ const names = uniqueSiblingNames(layers.map((c) => c.name));
27842
+ layers.forEach((c, k) => {
27843
+ c.name = names[k];
27844
+ c.props[0] = ["Name", S(c.name)];
27845
+ });
27846
+ for (const c of i.children)
27847
+ dedupe(c);
27848
+ }
27593
27849
 
27594
27850
  class Mapper {
27595
27851
  opts;
@@ -27598,48 +27854,82 @@ class Mapper {
27598
27854
  fontSubstitutions = new Map;
27599
27855
  counts = { instances: 0, pictures: 0, skippedHidden: 0 };
27600
27856
  textFactor = 1;
27857
+ names = new Map;
27858
+ roles = new Map;
27601
27859
  constructor(opts, assets) {
27602
27860
  this.opts = opts;
27603
27861
  this.assets = assets;
27604
27862
  }
27863
+ get scaleOnly() {
27864
+ return this.opts.mode === "scale";
27865
+ }
27605
27866
  warn(n, msg) {
27606
27867
  if (this.warnings.size < 60)
27607
27868
  this.warnings.add(n ? `${n.name}: ${msg}` : msg);
27608
27869
  }
27870
+ nameLayers(root) {
27871
+ const tree = roleTree(root);
27872
+ inferRoles(tree);
27873
+ const walk = (r) => {
27874
+ const base = pascalName(r.role ?? r.name) || pascalName(r.role ?? "") || "Layer";
27875
+ this.names.set(r.id, base);
27876
+ if (r.role)
27877
+ this.roles.set(r.id, r.role);
27878
+ for (const c of r.children)
27879
+ walk(c);
27880
+ };
27881
+ walk(tree);
27882
+ }
27883
+ label(n, flavor) {
27884
+ return robloxName(this.names.get(n.id) ?? (pascalName(n.name) || "Layer"), flavor);
27885
+ }
27886
+ isButton(n) {
27887
+ return !!n.clickable || BUTTON_NAME.test(n.name) || this.roles.get(n.id) === "Button";
27888
+ }
27609
27889
  map(root) {
27890
+ this.nameLayers(root);
27610
27891
  const [tw, th] = this.opts.targetResolution;
27611
27892
  const fullScreen = Math.abs(root.w / root.h - tw / th) < 0.1 * (tw / th) && root.w >= tw * 0.4;
27612
27893
  if (fullScreen && this.opts.mode !== "offset")
27613
27894
  this.textFactor = tw / root.w;
27614
27895
  const top = this.node(root, null, 0);
27615
27896
  const geo = this.opts.mode === "offset" ? [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(0, root.w, 0, root.h)]] : fullScreen ? [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(1, 0, 1, 0)]] : [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(root.w / tw, 0, root.h / th, 0)]];
27616
- top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex"].includes(k));
27897
+ top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex", "AutomaticSize"].includes(k));
27617
27898
  top.props.splice(1, 0, ...geo);
27618
- top.children.push(this.inst("UIAspectRatioConstraint", "AspectRatio", [["AspectRatio", F(root.w / root.h)]]));
27619
- if (this.opts.asRootFrame)
27620
- return top;
27621
- return this.inst("ScreenGui", root.name, [["ResetOnSpawn", B(false)], ["ZIndexBehavior", E("ZIndexBehavior", "Sibling")], ["IgnoreGuiInset", B(true)]], [top]);
27899
+ top.children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F(root.w / root.h)]]));
27900
+ const out = this.opts.asRootFrame ? top : this.inst("ScreenGui", `${(this.names.get(root.id) ?? "Ui").replace(/Gui$/, "")}Gui`, [["ResetOnSpawn", B(false)], ["ZIndexBehavior", E("ZIndexBehavior", "Sibling")], ["IgnoreGuiInset", B(true)]], [top]);
27901
+ dedupe(out);
27902
+ return out;
27622
27903
  }
27623
27904
  inst(className, name, props = [], children = [], source) {
27624
27905
  this.counts.instances++;
27625
27906
  return { className, name, props: [["Name", S(name)], ...props], children, ...source ? { source } : {} };
27626
27907
  }
27908
+ frameOf(n, parent) {
27909
+ const inFlow = !!parent.layout && !n.absolute;
27910
+ const scale = this.opts.mode === "scale" || this.opts.mode === "hybrid" && !inFlow;
27911
+ if (!inFlow)
27912
+ return { scale, w: Math.max(1, parent.w), h: Math.max(1, parent.h), inFlow };
27913
+ const p = parent.layout.padding;
27914
+ return { scale, w: Math.max(1, parent.w - (p[1] ?? 0) - (p[3] ?? 0)), h: Math.max(1, parent.h - (p[0] ?? 0) - (p[2] ?? 0)), inFlow };
27915
+ }
27916
+ hugs(n, d) {
27917
+ if (this.scaleOnly)
27918
+ return { w: false, h: false };
27919
+ const content = d.kind === "text" || !!n.layout;
27920
+ return { w: n.sizing?.h === "HUG" && content, h: n.sizing?.v === "HUG" && content };
27921
+ }
27627
27922
  geometry(n, parent, index, d) {
27628
27923
  const out = [];
27629
27924
  if (!parent)
27630
27925
  return out;
27631
- const inFlow = !!parent.layout && !n.absolute;
27632
- const useScale = this.opts.mode === "scale" || this.opts.mode === "hybrid" && !inFlow;
27633
- const hugW = n.sizing?.h === "HUG" && (d.kind === "text" || !!n.layout);
27634
- const hugH = n.sizing?.v === "HUG" && (d.kind === "text" || !!n.layout);
27635
- if (inFlow) {
27636
- const l = parent.layout;
27637
- const horizontal = l.mode === "HORIZONTAL";
27638
- const pw = parent.w - (l.padding[1] ?? 0) - (l.padding[3] ?? 0);
27639
- const ph = parent.h - (l.padding[0] ?? 0) - (l.padding[2] ?? 0);
27926
+ const f = this.frameOf(n, parent);
27927
+ const hug = this.hugs(n, d);
27928
+ if (f.inFlow) {
27929
+ const horizontal = parent.layout.mode === "HORIZONTAL";
27640
27930
  const crossFill = horizontal ? n.sizing?.v === "FILL" || n.stretch : n.sizing?.h === "FILL" || n.stretch;
27641
- const w = (horizontal ? false : crossFill) ? U(1, 0) : hugW ? U(0, 0) : useScale && l.mode !== "GRID" ? U(n.w / Math.max(1, pw), 0) : U(0, n.w);
27642
- const h = (horizontal ? crossFill : false) ? U(1, 0) : hugH ? U(0, 0) : useScale && l.mode !== "GRID" ? U(n.h / Math.max(1, ph), 0) : U(0, n.h);
27931
+ const w = !horizontal && crossFill ? U(1, 0) : hug.w ? U(0, 0) : f.scale ? U(n.w / f.w, 0) : U(0, n.w);
27932
+ const h = horizontal && crossFill ? U(1, 0) : hug.h ? U(0, 0) : f.scale ? U(n.h / f.h, 0) : U(0, n.h);
27643
27933
  out.push(["Size", { t: "UDim2", xs: w.s, xo: w.o, ys: h.s, yo: h.o }]);
27644
27934
  out.push(["LayoutOrder", I(index + 1)]);
27645
27935
  return out;
@@ -27647,7 +27937,7 @@ class Mapper {
27647
27937
  const c = n.constraints ?? { h: "MIN", v: "MIN" };
27648
27938
  const axis = (pos, size, total, k) => {
27649
27939
  const end = total - pos - size;
27650
- if (k === "SCALE" || useScale) {
27940
+ if (k === "SCALE" || f.scale) {
27651
27941
  if (k === "MAX")
27652
27942
  return { anchor: 1, p: [(pos + size) / total, 0], s: [size / total, 0] };
27653
27943
  if (k === "CENTER")
@@ -27662,39 +27952,63 @@ class Mapper {
27662
27952
  return { anchor: 0, p: [0, pos], s: [1, -(pos + end)] };
27663
27953
  return { anchor: 0, p: [0, pos], s: [0, size] };
27664
27954
  };
27665
- const x = axis(n.x, n.w, Math.max(1, parent.w), c.h);
27666
- const y = axis(n.y, n.h, Math.max(1, parent.h), c.v);
27955
+ const x = axis(n.x, n.w, f.w, c.h);
27956
+ const y = axis(n.y, n.h, f.h, c.v);
27667
27957
  if (x.anchor || y.anchor)
27668
27958
  out.push(["AnchorPoint", V2(x.anchor, y.anchor)]);
27669
27959
  out.push(["Position", U2(x.p[0], x.p[1], y.p[0], y.p[1])]);
27670
- out.push(["Size", U2(hugW ? 0 : x.s[0], hugW ? 0 : x.s[1], hugH ? 0 : y.s[0], hugH ? 0 : y.s[1])]);
27960
+ out.push(["Size", U2(hug.w ? 0 : x.s[0], hug.w ? 0 : x.s[1], hug.h ? 0 : y.s[0], hug.h ? 0 : y.s[1])]);
27671
27961
  out.push(["ZIndex", I(index + 1)]);
27672
27962
  return out;
27673
27963
  }
27674
27964
  automaticSize(n, d) {
27675
- const hugW = n.sizing?.h === "HUG" && (d.kind === "text" || !!n.layout);
27676
- const hugH = n.sizing?.v === "HUG" && (d.kind === "text" || !!n.layout);
27677
- if (!hugW && !hugH)
27965
+ const hug = this.hugs(n, d);
27966
+ if (!hug.w && !hug.h)
27678
27967
  return [];
27679
- return [["AutomaticSize", E("AutomaticSize", hugW && hugH ? "XY" : hugW ? "X" : "Y")]];
27968
+ return [["AutomaticSize", E("AutomaticSize", hug.w && hug.h ? "XY" : hug.w ? "X" : "Y")]];
27680
27969
  }
27681
27970
  corner(n) {
27682
27971
  const r = Math.max(0, ...n.radius ?? []);
27683
27972
  if (!r && !(n.type === "ELLIPSE"))
27684
27973
  return null;
27685
- const pill = n.type === "ELLIPSE" || r >= Math.min(n.w, n.h) / 2 - 0.5;
27686
- return this.inst("UICorner", "UICorner", [["CornerRadius", pill ? U(0.5, 0) : U(0, r)]]);
27974
+ const short = Math.max(1, Math.min(n.w, n.h));
27975
+ const pill = n.type === "ELLIPSE" || r >= short / 2 - 0.5;
27976
+ return this.inst("UICorner", "UICorner", [["CornerRadius", pill ? U(0.5, 0) : this.scaleOnly ? U(r / short, 0) : U(0, r)]]);
27687
27977
  }
27688
27978
  stroke(n) {
27689
27979
  const s = visible(n.strokes)[0];
27690
27980
  if (!s || s.type !== "SOLID" || !n.strokeWeight)
27691
27981
  return null;
27692
- return this.inst("UIStroke", "UIStroke", [
27982
+ const short = Math.max(1, Math.min(n.w, n.h));
27983
+ const props = [
27693
27984
  ["ApplyStrokeMode", E("ApplyStrokeMode", "Border")],
27694
27985
  ["Color", C3(s.color)],
27695
- ["Thickness", F(n.strokeWeight)],
27986
+ ["Thickness", F(this.scaleOnly ? n.strokeWeight / short : n.strokeWeight)],
27696
27987
  ["Transparency", F(1 - s.opacity)],
27697
- ["LineJoinMode", E("LineJoinMode", "Round")]
27988
+ ["LineJoinMode", E("LineJoinMode", "Round")],
27989
+ ["BorderStrokePosition", E("BorderStrokePosition", n.strokeAlign === "OUTSIDE" ? "Outer" : n.strokeAlign === "CENTER" ? "Center" : "Inner")]
27990
+ ];
27991
+ if (this.scaleOnly)
27992
+ props.push(["StrokeSizingMode", E("StrokeSizingMode", "ScaledSize")]);
27993
+ return this.inst("UIStroke", "UIStroke", props);
27994
+ }
27995
+ shadow(n) {
27996
+ const list = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
27997
+ if (!list.length)
27998
+ return null;
27999
+ if (list.length > 1)
28000
+ this.warn(n, "several drop shadows: kept the largest");
28001
+ const e = list.reduce((a, b) => b.blur + (b.spread ?? 0) > a.blur + (a.spread ?? 0) ? b : a);
28002
+ const w = Math.max(1, n.w);
28003
+ const h = Math.max(1, n.h);
28004
+ const spread = (e.spread ?? 0) * 2;
28005
+ const s = this.scaleOnly;
28006
+ return this.inst("UIShadow", "UIShadow", [
28007
+ ["Color", C3(e.color ?? "#000000")],
28008
+ ["Transparency", F(1 - (e.alpha ?? 0.25))],
28009
+ ["BlurRadius", s ? U(e.blur / Math.min(w, h), 0) : U(0, e.blur)],
28010
+ ["Offset", s ? U2((e.x ?? 0) / w, 0, (e.y ?? 0) / h, 0) : U2(0, e.x ?? 0, 0, e.y ?? 0)],
28011
+ ["Spread", s ? U2(spread / w, 0, spread / h, 0) : U2(0, spread, 0, spread)]
27698
28012
  ]);
27699
28013
  }
27700
28014
  gradient(p) {
@@ -27717,18 +28031,26 @@ class Mapper {
27717
28031
  }
27718
28032
  layoutChildren(n) {
27719
28033
  const l = n.layout;
28034
+ const s = this.scaleOnly;
27720
28035
  const out = [];
27721
- const [t, r, b, lft] = l.padding;
28036
+ const [t = 0, r = 0, b = 0, lft = 0] = l.padding;
28037
+ const w = Math.max(1, n.w);
28038
+ const h = Math.max(1, n.h);
28039
+ const cw = Math.max(1, w - r - lft);
28040
+ const ch = Math.max(1, h - t - b);
28041
+ const pad = (v, total) => s ? U(v / total, 0) : U(0, v);
27722
28042
  if (t || r || b || lft)
27723
- out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop", U(0, t)], ["PaddingRight", U(0, r)], ["PaddingBottom", U(0, b)], ["PaddingLeft", U(0, lft)]]));
28043
+ out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop", pad(t, h)], ["PaddingRight", pad(r, w)], ["PaddingBottom", pad(b, h)], ["PaddingLeft", pad(lft, w)]]));
27724
28044
  if (l.mode === "GRID") {
27725
28045
  const kids = (n.children ?? []).filter((c) => !c.hidden && !c.absolute);
27726
28046
  const first = kids[0];
27727
28047
  if (kids.some((k) => Math.abs(k.w - (first?.w ?? 0)) > 1 || Math.abs(k.h - (first?.h ?? 0)) > 1))
27728
28048
  this.warn(n, "grid cells of different sizes: UIGridLayout uses the first cell's size");
28049
+ const fw = first?.w ?? 100;
28050
+ const fh = first?.h ?? 100;
27729
28051
  out.push(this.inst("UIGridLayout", "UIGridLayout", [
27730
- ["CellSize", U2(0, first?.w ?? 100, 0, first?.h ?? 100)],
27731
- ["CellPadding", U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
28052
+ ["CellSize", s ? U2(fw / cw, 0, fh / ch, 0) : U2(0, fw, 0, fh)],
28053
+ ["CellPadding", s ? U2((l.columnGap ?? 0) / cw, 0, (l.rowGap ?? 0) / ch, 0) : U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
27732
28054
  ["FillDirectionMaxCells", I(l.columns ?? 0)],
27733
28055
  ["SortOrder", E("SortOrder", "LayoutOrder")]
27734
28056
  ]));
@@ -27737,10 +28059,11 @@ class Mapper {
27737
28059
  const horizontal = l.mode === "HORIZONTAL";
27738
28060
  const main = l.primary === "CENTER" ? "Center" : l.primary === "MAX" ? horizontal ? "Right" : "Bottom" : horizontal ? "Left" : "Top";
27739
28061
  const cross = l.counter === "CENTER" ? "Center" : l.counter === "MAX" ? horizontal ? "Bottom" : "Right" : horizontal ? "Top" : "Left";
28062
+ const gap = l.primary === "SPACE_BETWEEN" ? 0 : l.gap;
27740
28063
  const props = [
27741
28064
  ["FillDirection", E("FillDirection", horizontal ? "Horizontal" : "Vertical")],
27742
28065
  ["SortOrder", E("SortOrder", "LayoutOrder")],
27743
- ["Padding", U(0, l.primary === "SPACE_BETWEEN" ? 0 : l.gap)],
28066
+ ["Padding", pad(gap, horizontal ? cw : ch)],
27744
28067
  ["HorizontalAlignment", E("HorizontalAlignment", horizontal ? main === "Right" ? "Right" : main === "Center" ? "Center" : "Left" : cross === "Right" ? "Right" : cross === "Center" ? "Center" : "Left")],
27745
28068
  ["VerticalAlignment", E("VerticalAlignment", horizontal ? cross === "Bottom" ? "Bottom" : cross === "Center" ? "Center" : "Top" : main === "Bottom" ? "Bottom" : main === "Center" ? "Center" : "Top")]
27746
28069
  ];
@@ -27803,10 +28126,12 @@ class Mapper {
27803
28126
  props.push(["LineHeight", F(Math.max(1, Math.min(3, base.lineHeight / (base.size * 1.2))))]);
27804
28127
  if (t.maxLines)
27805
28128
  props.push(["TextTruncate", E("TextTruncate", "AtEnd")]);
27806
- if (this.opts.textScaled)
28129
+ if (this.opts.textScaled || this.scaleOnly)
27807
28130
  props.push(["TextScaled", B(true)]);
27808
28131
  if (segs.some((s) => s.letterSpacing))
27809
28132
  this.warn(n, "letter spacing has no Roblox equivalent: ignored");
28133
+ if (rich && this.scaleOnly && segs.some((s) => s.size !== base.size))
28134
+ this.warn(n, "several text sizes with TextScaled: the size tags may not scale with the label");
27810
28135
  return props;
27811
28136
  }
27812
28137
  richRun(s, base) {
@@ -27844,7 +28169,7 @@ class Mapper {
27844
28169
  const d = decide(n, this.opts);
27845
28170
  for (const a of d.approximations)
27846
28171
  this.warn(n, a);
27847
- const button = !!parent && (n.clickable || BUTTON_NAME.test(n.name)) && d.kind !== "text";
28172
+ const button = !!parent && this.isButton(n) && d.kind !== "text";
27848
28173
  const geo = this.geometry(n, parent, index, d);
27849
28174
  const common = [...geo, ...this.automaticSize(n, d)];
27850
28175
  if (n.rotation)
@@ -27853,26 +28178,32 @@ class Mapper {
27853
28178
  let className = "Frame";
27854
28179
  let props = [];
27855
28180
  if (d.kind === "text") {
27856
- className = n.clickable || BUTTON_NAME.test(n.name) ? "TextButton" : "TextLabel";
28181
+ const isButton = !!n.clickable || BUTTON_NAME.test(n.name);
28182
+ className = isButton ? "TextButton" : "TextLabel";
27857
28183
  props = this.textProps(n);
27858
- if (className === "TextButton")
28184
+ if (isButton)
27859
28185
  props.push(["AutoButtonColor", B(false)]);
28186
+ if ((n.effects ?? []).some((e) => e.type === "DROP_SHADOW"))
28187
+ this.warn(n, "text shadow has no Roblox equivalent: ignored");
27860
28188
  const size = props.find(([k]) => k === "TextSize")[1].v;
27861
- children.push(this.inst("UITextSizeConstraint", "UITextSizeConstraint", [["MaxTextSize", I(size)], ["MinTextSize", I(Math.max(1, Math.round(size * 0.5)))]]));
27862
- return this.inst(className, n.name, [...common, ...props], children, n.id);
28189
+ const tight = n.text.autoResize === "WIDTH_AND_HEIGHT" || n.text.autoResize === "HEIGHT";
28190
+ const max = this.scaleOnly && tight ? size * 2 : size;
28191
+ children.push(this.inst("UITextSizeConstraint", "UITextSizeConstraint", [["MaxTextSize", I(max)], ["MinTextSize", I(Math.max(1, Math.round(size * 0.5)))]]));
28192
+ return this.inst(className, this.label(n, isButton ? "button" : "text"), [...common, ...props], children, n.id);
27863
28193
  }
27864
28194
  if (d.kind === "picture") {
27865
28195
  this.counts.pictures++;
27866
28196
  const a = this.assetUrl(`${n.id}:full`, n);
27867
28197
  className = button ? "ImageButton" : "ImageLabel";
27868
- const pic = this.pictureGeometry(n, a, geo);
28198
+ const pic = parent ? this.pictureGeometry(n, a, geo, parent) : geo;
27869
28199
  props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", "Stretch")]];
27870
28200
  if (n.opacity !== undefined && n.opacity < 1)
27871
28201
  props.push(["ImageTransparency", F(1 - n.opacity)]);
27872
28202
  if (button)
27873
28203
  props.push(["AutoButtonColor", B(false)]);
27874
28204
  children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F((a.size?.w ?? n.w) / Math.max(1, a.size?.h ?? n.h))]]));
27875
- return this.inst(className, n.name, [...pic, ...props], children, n.id);
28205
+ const flavor = button ? "button" : n.vector || VECTOR_TYPES.includes(n.type) ? "icon" : "image";
28206
+ return this.inst(className, this.label(n, flavor), [...pic, ...props], children, n.id);
27876
28207
  }
27877
28208
  const fills = visible(n.fills);
27878
28209
  const opacity = n.opacity ?? 1;
@@ -27890,8 +28221,11 @@ class Mapper {
27890
28221
  const corner = this.corner(n);
27891
28222
  if (corner)
27892
28223
  children.push(corner);
28224
+ const shadow = this.shadow(n);
28225
+ if (shadow)
28226
+ children.push(shadow);
27893
28227
  children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F(n.w / Math.max(1, n.h))]]));
27894
- return this.inst(className, n.name, [...common, ...props], children, n.id);
28228
+ return this.inst(className, this.label(n, button ? "button" : "image"), [...common, ...props], children, n.id);
27895
28229
  }
27896
28230
  if (d.kind === "panel") {
27897
28231
  this.counts.pictures++;
@@ -27914,6 +28248,13 @@ class Mapper {
27914
28248
  props.push(["ImageTransparency", F(1 - opacity)]);
27915
28249
  if (button)
27916
28250
  props.push(["AutoButtonColor", B(false)]);
28251
+ const shadow = this.shadow(n);
28252
+ if (shadow) {
28253
+ const corner = this.corner(n);
28254
+ if (corner)
28255
+ children.push(corner);
28256
+ children.push(shadow);
28257
+ }
27917
28258
  } else {
27918
28259
  className = button ? "TextButton" : hasKids && opacity < 1 ? "CanvasGroup" : "Frame";
27919
28260
  const f = fills[0];
@@ -27930,7 +28271,7 @@ class Mapper {
27930
28271
  } else if (f.type === "IMAGE") {
27931
28272
  props.push(["BackgroundTransparency", F(1)]);
27932
28273
  const a = this.assetUrl(`image:${f.imageHash}`, n);
27933
- const bg = this.inst("ImageLabel", "Background", [["Size", U2(1, 0, 1, 0)], ["ZIndex", I(0)], ["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", f.scaleMode === "FIT" ? "Fit" : "Crop")]]);
28274
+ const bg = this.inst("ImageLabel", "BackgroundImage", [["Size", U2(1, 0, 1, 0)], ["ZIndex", I(0)], ["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", f.scaleMode === "FIT" ? "Fit" : "Crop")]]);
27934
28275
  const corner = this.corner(n);
27935
28276
  if (corner)
27936
28277
  bg.children.push(corner);
@@ -27946,6 +28287,9 @@ class Mapper {
27946
28287
  const stroke = this.stroke(n);
27947
28288
  if (stroke)
27948
28289
  children.push(stroke);
28290
+ const shadow = this.shadow(n);
28291
+ if (shadow)
28292
+ children.push(shadow);
27949
28293
  }
27950
28294
  props.push(["BorderSizePixel", I(0)]);
27951
28295
  if (n.clip)
@@ -27954,8 +28298,7 @@ class Mapper {
27954
28298
  const flow = n.layout ? kids.filter((k) => !k.absolute) : kids;
27955
28299
  const loose = n.layout ? kids.filter((k) => k.absolute) : [];
27956
28300
  const me = { w: n.w, h: n.h, layout: n.layout };
27957
- const mapped = (list, p) => list.map((k, i) => this.node(k, p, i)).filter((x) => !!x);
27958
- const flowMapped = mapped(flow, me);
28301
+ const flowMapped = flow.map((k, i) => this.node(k, me, i)).filter((x) => !!x);
27959
28302
  if (n.layout && n.layout.mode !== "GRID") {
27960
28303
  for (const k of flow) {
27961
28304
  const target = k.grow && !k.hidden ? flowMapped.find((c) => c.source === k.id) : undefined;
@@ -27973,44 +28316,24 @@ class Mapper {
27973
28316
  children.push(...this.layoutChildren(n));
27974
28317
  children.push(...flowMapped);
27975
28318
  }
27976
- const self = this.inst(className, n.name, [...common, ...props], children, n.id);
27977
- if (!d.shadow)
27978
- return self;
27979
- this.counts.pictures++;
27980
- const a = this.assetUrl(`${n.id}:shadow`, n);
27981
- const scale = this.opts.scale ?? 2;
27982
- const sh = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
27983
- const reach = Math.max(...sh.map((e) => e.blur + (e.spread ?? 0) + Math.max(Math.abs(e.x ?? 0), Math.abs(e.y ?? 0))));
27984
- const off = a.offset ?? { x: 0, y: 0 };
27985
- const size = a.size ?? { w: n.w, h: n.h };
27986
- const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + reach) * scale);
27987
- const pw = Math.round(size.w * scale);
27988
- const ph = Math.round(size.h * scale);
27989
- const ix = Math.min(inset, Math.floor(pw / 2) - 1);
27990
- const iy = Math.min(inset, Math.floor(ph / 2) - 1);
27991
- const shadow = this.inst("ImageLabel", "Shadow", [
27992
- ["Position", U2(0, off.x, 0, off.y)],
27993
- ["Size", U2(1, size.w - n.w, 1, size.h - n.h)],
27994
- ["ZIndex", I(1)],
27995
- ["BackgroundTransparency", F(1)],
27996
- ["Image", { t: "Content", url: a.url }],
27997
- ["ScaleType", E("ScaleType", "Slice")],
27998
- ["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
27999
- ["SliceScale", F(1 / scale)]
28000
- ]);
28001
- self.props = self.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "AutomaticSize"].includes(k));
28002
- self.props.splice(1, 0, ["Size", U2(1, 0, 1, 0)], ["ZIndex", I(2)]);
28003
- return this.inst("Frame", n.name, [...common.filter(([k]) => k !== "AutomaticSize"), ["BackgroundTransparency", F(1)], ["BorderSizePixel", I(0)]], [shadow, self], n.id);
28319
+ const flavor = button ? "button" : fills.length || visible(n.strokes).length || d.kind === "panel" ? "frame" : "container";
28320
+ return this.inst(className, this.label(n, flavor), [...common, ...props], children, n.id);
28004
28321
  }
28005
- pictureGeometry(n, a, geo) {
28322
+ pictureGeometry(n, a, geo, parent) {
28006
28323
  if (!a.offset || !a.size || Math.abs(a.offset.x) < 0.5 && Math.abs(a.offset.y) < 0.5 && Math.abs(a.size.w - n.w) < 0.5 && Math.abs(a.size.h - n.h) < 0.5)
28007
28324
  return geo;
28325
+ const f = this.frameOf(n, parent);
28326
+ const dw = a.size.w - n.w;
28327
+ const dh = a.size.h - n.h;
28328
+ const ox = a.offset.x;
28329
+ const oy = a.offset.y;
28008
28330
  return geo.map(([k, v]) => {
28009
- if (k === "Size" && v.t === "UDim2")
28010
- return [k, { ...v, xo: Math.round(v.xo + a.size.w - n.w), yo: Math.round(v.yo + a.size.h - n.h) }];
28011
- if (k === "Position" && v.t === "UDim2")
28012
- return [k, { ...v, xo: Math.round(v.xo + a.offset.x), yo: Math.round(v.yo + a.offset.y) }];
28013
- return [k, v];
28331
+ if (v.t !== "UDim2" || k !== "Size" && k !== "Position")
28332
+ return [k, v];
28333
+ if (f.scale) {
28334
+ return k === "Size" ? [k, { ...v, xs: num(v.xs + dw / f.w), ys: num(v.ys + dh / f.h) }] : [k, { ...v, xs: num(v.xs + ox / f.w), ys: num(v.ys + oy / f.h) }];
28335
+ }
28336
+ return k === "Size" ? [k, { ...v, xo: Math.round(v.xo + dw), yo: Math.round(v.yo + dh) }] : [k, { ...v, xo: Math.round(v.xo + ox), yo: Math.round(v.yo + oy) }];
28014
28337
  });
28015
28338
  }
28016
28339
  }
@@ -28021,10 +28344,14 @@ function mapToRoblox(root, opts, assets) {
28021
28344
  m.warn(null, `${m.counts.skippedHidden} hidden layer(s) skipped`);
28022
28345
  return { root: out, warnings: [...m.warnings], fontSubstitutions: Object.fromEntries(m.fontSubstitutions), counts: m.counts };
28023
28346
  }
28024
- var num = (v) => Math.round(v * 1000) / 1000, S = (v) => ({ t: "string", v }), B = (v) => ({ t: "bool", v }), I = (v) => ({ t: "int", v: Math.round(v) }), F = (v) => ({ t: "float", v: num(v) }), E = (e, v) => ({ t: "enum", e, v }), U = (s, o) => ({ t: "UDim", s: num(s), o: Math.round(o) }), U2 = (xs, xo, ys, yo) => ({ t: "UDim2", xs: num(xs), xo: Math.round(xo), ys: num(ys), yo: Math.round(yo) }), V2 = (x, y) => ({ t: "Vector2", x: num(x), y: num(y) }), C3 = (hex) => ({ t: "Color3", ...color3(hex) }), BUTTON_NAME;
28347
+ var num = (v) => Math.round(v * 1e4) / 1e4, S = (v) => ({ t: "string", v }), B = (v) => ({ t: "bool", v }), I = (v) => ({ t: "int", v: Math.round(v) }), F = (v) => ({ t: "float", v: num(v) }), E = (e, v) => ({ t: "enum", e, v }), U = (s, o) => ({ t: "UDim", s: num(s), o: Math.round(o) }), U2 = (xs, xo, ys, yo) => ({ t: "UDim2", xs: num(xs), xo: Math.round(xo), ys: num(ys), yo: Math.round(yo) }), V2 = (x, y) => ({ t: "Vector2", x: num(x), y: num(y) }), C3 = (hex) => ({ t: "Color3", ...color3(hex) }), BUTTON_NAME, VECTOR_TYPES, ICON_WORDS, IMAGE_WORDS;
28025
28348
  var init_map2 = __esm(() => {
28349
+ init_naming();
28026
28350
  init_fonts();
28027
- BUTTON_NAME = /\b(button|btn|cta)\b/i;
28351
+ BUTTON_NAME = /button|btn|\bcta\b/i;
28352
+ VECTOR_TYPES = ["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON", "LINE"];
28353
+ ICON_WORDS = ["Icon", "Logo", "Image", "Illustration", "Avatar", "Emoji"];
28354
+ IMAGE_WORDS = ["Image", "Icon", "Avatar", "Logo", "Thumbnail", "Picture", "Photo", "Banner", "Background", "Illustration", "Art", "Cover"];
28028
28355
  });
28029
28356
 
28030
28357
  // src/roblox/output.ts
@@ -28186,7 +28513,7 @@ return ${rootVar}
28186
28513
  function substituteAssets(text, ids) {
28187
28514
  return text.replace(/rbxassetid:\/\/PENDING_\d+/g, (m) => ids[m] ? `rbxassetid://${ids[m]}` : m);
28188
28515
  }
28189
- var ENUMS, n = (v) => String(Math.round(v * 1000) / 1000), xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"), KEYWORDS, rgb = (v) => Math.round(v * 255);
28516
+ var ENUMS, n = (v) => String(Math.round(v * 1e4) / 1e4), xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"), KEYWORDS, rgb = (v) => Math.round(v * 255);
28190
28517
  var init_output = __esm(() => {
28191
28518
  ENUMS = {
28192
28519
  ZIndexBehavior: { Global: 0, Sibling: 1 },
@@ -28202,7 +28529,9 @@ var init_output = __esm(() => {
28202
28529
  ScaleType: { Stretch: 0, Slice: 1, Tile: 2, Fit: 3, Crop: 4 },
28203
28530
  ApplyStrokeMode: { Contextual: 0, Border: 1 },
28204
28531
  LineJoinMode: { Round: 0, Bevel: 1, Miter: 2 },
28205
- TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 }
28532
+ TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 },
28533
+ StrokeSizingMode: { FixedSize: 0, ScaledSize: 1 },
28534
+ BorderStrokePosition: { Outer: 0, Center: 1, Inner: 2 }
28206
28535
  };
28207
28536
  KEYWORDS = new Set(["and", "break", "do", "else", "elseif", "end", "false", "for", "function", "if", "in", "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while", "continue", "export", "type", "game", "workspace", "script", "PARENT", "existing"]);
28208
28537
  });
@@ -28308,7 +28637,7 @@ import { join as join2 } from "node:path";
28308
28637
  async function exportRoblox(args, deps) {
28309
28638
  const env = deps.env ?? process.env;
28310
28639
  const opts = {
28311
- mode: args.mode ?? "hybrid",
28640
+ mode: args.mode ?? "scale",
28312
28641
  targetResolution: args.targetResolution ?? [1920, 1080],
28313
28642
  rasterize: args.rasterize ?? "auto",
28314
28643
  asRootFrame: args.asRootFrame,
@@ -32078,7 +32407,7 @@ async function layerNames(session, ids) {
32078
32407
  return bridge.request("node_info", { ids: [...new Set(ids)] }, 20000).catch(() => ({}));
32079
32408
  }
32080
32409
  var PORT, CHANNEL, OUT_DIR, LOG_FILE, MAX_OUTPUT_CHARS = 60000, MAX_IMAGE_BYTES, FIGMA_MAX_IMAGE_DIM = 4096, VIEW_MAX_DIM = 1568, bridge, ok = (value) => ({ content: [{ type: "text", text: json(value) }] }), plain = (text) => ({ content: [{ type: "text", text }] }), oneLine = (s, max = 120) => s.replace(/\s+/g, " ").trim().slice(0, max), safeName = (s) => s.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "node", INSTRUCTIONS, BUILD_DESCRIPTION = `Create a whole layout in ONE call from a declarative spec. Fonts load automatically, icons are fetched, images loaded. Much faster and safer than run_script for new UI.
32081
- Node: {type?, name?, ...props, children?: Node[]}. type is inferred (text/spans→text, icon→icon, src→image, component→instance, variants→componentSet, else frame). Types: frame, component, componentSet, text, rect, ellipse, line, icon, image, svg, instance.
32410
+ Node: {type?, name?, ...props, children?: Node[]}. Name layers by role (Header, Card, CardList, Title, Description, PrimaryButton, Icon); unnamed layers get a role name (Container, Row, Card, Title, Label, Button, Icon, Divider…). type is inferred (text/spans→text, icon→icon, src→image, component→instance, variants→componentSet, else frame). Types: frame, component, componentSet, text, rect, ellipse, line, icon, image, svg, instance.
32082
32411
  FRAME: layout "row"|"column"|"grid" (auto-layout; omit for free positioning), gap (number|"auto"), padding (n | [v,h] | [t,r,b,l]), align (cross axis: start|center|end|baseline), justify (main axis: start|center|end|between), wrap, rowGap, clip. Frames have no fill unless set.
32083
32412
  GRID: layout:"grid", columns (count | tracks like [200,"1fr","2fr","hug"]), rows (same; default: enough rows, hugging), columnGap, rowGap (or gap). Children: span:[rows,cols] or colSpan/rowSpan, cellAlign/cellValign. Give the grid a w for "fr" columns.
32084
32413
  SIZE: w / h: number (fixed) | "fill" (stretch inside an auto-layout parent) | "hug". Auto-layout frames hug by default. grow:true. absolute:true with x/y inside auto-layout; x/y for children of free frames.
@@ -32157,7 +32486,8 @@ var init_server3 = __esm(async () => {
32157
32486
  "Rules: load fonts before editing text; pages load on demand (await figma.setCurrentPageAsync / getNodeByIdAsync); keep results small.",
32158
32487
  "The user can cancel a command from the plugin (code CANCELLED): build stops, a script may still finish, so check the file before retrying.",
32159
32488
  "With FIGMA_TOKEN set: comments reads and posts file comments pinned to layers, versions lists the history and diffs a version against now.",
32160
- "Roblox: export_roblox turns a frame into a ScreenGui (.rbxmx + Luau for execute_luau + PNG assets); with a Roblox Studio MCP, upload the assets, run the Luau and check a Studio screenshot.",
32489
+ "Names: give every layer you create a professional name for its role, as a design system or a developer would: Screen, Header, Navbar, Sidebar, Section, Container, Card, CardList, Row, Grid, Title, Subtitle, Description, Label, Price, PrimaryButton, Icon, Avatar, Badge, Input, Divider, Background. Never leave Frame 12, Rectangle 3 or a text named after its content; build names unnamed layers by role and audit {fix:true} renames default names.",
32490
+ "Roblox: export_roblox turns a frame into a ScreenGui (.rbxmx + Luau for execute_luau + PNG assets), in scale only (no offsets) with PascalCase names (ShopGui, ItemCard, TitleLabel, BuyButton); with a Roblox Studio MCP, upload the assets, run the Luau and check a Studio screenshot. Any Roblox UI you write by hand follows the same rules: UDim2.fromScale, scale padding and gaps, UIStroke ScaledSize, TextScaled + UITextSizeConstraint, UIAspectRatioConstraint on the root.",
32161
32491
  "Prompts new-screen, apply-design-system, reproduce-screenshot, import-website, figma-to-code, audit-and-fix and figma-to-roblox are ready-made workflows; the resources figma-bridge://docs/build-spec and figma-bridge://docs/workflow are the full reference.",
32162
32492
  "If no file is connected, tell the user to run Plugins → Development → Figma Bridge in Figma (Ctrl+Alt+P re-runs it). If list_sessions reports versionMismatch, ask them to reopen or update the plugin."
32163
32493
  ];
@@ -32692,21 +33022,22 @@ ${f.content}`).join(`
32692
33022
  server.registerTool("export_roblox", {
32693
33023
  title: "Export a frame to Roblox UI",
32694
33024
  description: "Turn a Figma frame (default: selection) into Roblox UI that looks the same: a .rbxmx model (a ScreenGui, or a Frame with asRootFrame), an equivalent Luau builder " + `script for execute_luau (creates the UI under PARENT, default StarterGui, replaces a previous copy with the same name, returns the root), and the PNG assets.
32695
- ` + "Frames become Frames with UICorner, UIStroke, UIGradient, UIPadding; auto-layout becomes UIListLayout (UIFlexItem for fill, AutomaticSize for hug) or UIGridLayout; " + "constraints become AnchorPoint + UDim2; text becomes TextLabel with FontFace (Inter → Builder Sans…), RichText for mixed styles, UITextSizeConstraint; clickable or " + "*Button layers become TextButton/ImageButton. What Roblox can't draw (vectors, icons, blurs, several fills, radial gradients) is rasterized at 2×; panels whose background " + `can't be native become 9-slice images with their content on top; drop shadows become a 9-slice image behind the panel.
32696
- ` + "mode: scale (UDim2 scale everywhere), offset (pixels), hybrid (default: scale for free-positioned layers, pixels inside auto-layout). " + "Assets: with upload:true and ROBLOX_API_KEY + ROBLOX_CREATOR_ID set, pictures are uploaded through Open Cloud and their ids written in; otherwise each asset has a " + "placeholder (rbxassetid://PENDING_n): upload the files with your Roblox Studio tools (e.g. upload_image), replace the placeholders in the Luau, run it with execute_luau and check a Studio screenshot. " + 'Example: {"nodeId":"12:34","mode":"hybrid"}',
33025
+ ` + "Everything is in SCALE by default: sizes, positions, UIPadding, list gaps, grid cells, UICorner, UIStroke (StrokeSizingMode ScaledSize) and UIShadow are relative, " + "text uses TextScaled with a UITextSizeConstraint, and nothing is in offset. Instances get professional PascalCase names (ShopGui, ItemCard, TitleLabel, BuyButton, CoinIcon, " + `CardList, Header): the layer's own name, or its role when Figma named it (Frame 12 → Card, Container, Row…).
33026
+ ` + "Frames become Frames with UICorner, UIStroke, UIGradient, UIShadow, UIPadding; auto-layout becomes UIListLayout (UIFlexItem for fill) or UIGridLayout; " + "constraints become AnchorPoint + UDim2; text becomes TextLabel with FontFace (Inter → Builder Sans…), RichText for mixed styles; clickable or " + "*Button layers become TextButton/ImageButton. What Roblox can't draw (vectors, icons, blurs, several fills, radial gradients) is rasterized at 2×; panels whose background " + `can't be native become 9-slice images with their content on top.
33027
+ ` + "mode: scale (default, recommended); offset (pixels) or hybrid (pixels inside auto-layout) only when the user asks for them. " + "Assets: with upload:true and ROBLOX_API_KEY + ROBLOX_CREATOR_ID set, pictures are uploaded through Open Cloud and their ids written in; otherwise each asset has a " + "placeholder (rbxassetid://PENDING_n): upload the files with your Roblox Studio tools (e.g. upload_image), replace the placeholders in the Luau, run it with execute_luau and check a Studio screenshot. " + 'When writing Roblox UI yourself, follow the same rules: scale only, PascalCase role names. Example: {"nodeId":"12:34"}',
32697
33028
  inputSchema: {
32698
33029
  nodeId: string2().optional(),
32699
- mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default hybrid"),
33030
+ mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default scale (no offsets); offset or hybrid only if the user asks"),
32700
33031
  targetResolution: array(number2().int().min(100).max(8000)).length(2).optional().describe("Screen size the UI is designed for, default [1920, 1080]"),
32701
33032
  rasterize: _enum(["auto", "none", "all"]).optional().describe("auto (default): pictures only where Roblox can't draw it; none: approximate instead; all: every styled layer"),
32702
33033
  outDir: string2().optional().describe("Folder for the files (default: a new temp folder)"),
32703
33034
  upload: boolean2().optional().describe("Upload the pictures through Open Cloud (needs ROBLOX_API_KEY and ROBLOX_CREATOR_ID)"),
32704
33035
  asRootFrame: boolean2().optional().describe("Root is a Frame instead of a ScreenGui"),
32705
- textScaled: boolean2().optional().describe("TextScaled on labels (UITextSizeConstraint keeps the design size as the maximum)"),
33036
+ textScaled: boolean2().optional().describe("TextScaled on labels in offset or hybrid mode (always on in scale mode)"),
32706
33037
  fonts: object2({}).passthrough().optional().describe('Figma family → Roblox family or rbxasset URL, e.g. {"Inter":"GothamSSm"}'),
32707
33038
  parent: string2().optional().describe('Luau expression for the parent, default game:GetService("StarterGui")')
32708
33039
  }
32709
- }, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "hybrid"}`, async () => {
33040
+ }, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "scale"}`, async () => {
32710
33041
  const r = await exportRoblox({ ...args, targetResolution: args.targetResolution, fonts: args.fonts }, { request: (m, p, t) => bridge.request(m, p, t), outDir: OUT_DIR });
32711
33042
  let luau = r.luau;
32712
33043
  if (luau.length > MAX_OUTPUT_CHARS)