@clovis500c/figma-bridge 1.13.0 → 1.14.2

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.2";
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,26 @@ 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.rasterize && raster !== "none")
27739
+ return { kind: "picture", approximations: ["image file unreadable: pictured as it looks"] };
27740
+ if (n.vector || VECTOR_TYPES.includes(n.type)) {
27741
+ return raster === "none" ? { kind: "frame", approximations: ["vector shape skipped (rasterize is none)"] } : { kind: "picture", approximations };
27530
27742
  }
27531
27743
  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 };
27744
+ if (image && !hasChildren && !strokes.length && !other.length && raster !== "all")
27745
+ return { kind: "image", approximations };
27534
27746
  const reasons = [];
27535
27747
  if (fills.length > 1)
27536
27748
  reasons.push("several fills");
27537
27749
  if (fills.length === 1 && !["SOLID", "GRADIENT_LINEAR", "IMAGE"].includes(fills[0].type))
27538
27750
  reasons.push(`${fills[0].type.toLowerCase().replace("_", " ")} fill`);
27539
- if (image && (strokes.length || effects.length))
27751
+ if (image && (strokes.length || other.length))
27540
27752
  reasons.push("image with stroke or effects");
27541
27753
  if (strokes.length > 1 || strokes[0] && strokes[0].type !== "SOLID")
27542
27754
  reasons.push("complex stroke");
@@ -27546,18 +27758,16 @@ function decide(n, opts) {
27546
27758
  reasons.push(other.map((e) => e.type.toLowerCase().replace(/_/g, " ")).join(", "));
27547
27759
  if (n.type === "ELLIPSE" && Math.abs(n.w - n.h) > 0.5)
27548
27760
  reasons.push("oval");
27549
- const styled = fills.length + strokes.length + effects.length > 0;
27761
+ const styled = fills.length + strokes.length + (n.effects ?? []).length > 0;
27550
27762
  if ((reasons.length || raster === "all" && styled) && raster !== "none") {
27551
- return { kind: hasChildren ? "panel" : "picture", shadow: false, approximations };
27763
+ return { kind: hasChildren ? "panel" : "picture", approximations };
27552
27764
  }
27553
27765
  if (reasons.length)
27554
27766
  approximations.push(`${reasons.join(", ")}: approximated (rasterize is none)`);
27555
27767
  const r = n.radius ?? [];
27556
27768
  if (r.length && r.some((v) => Math.abs(v - r[0]) > 0.5))
27557
27769
  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 };
27770
+ return { kind: "frame", approximations };
27561
27771
  }
27562
27772
  function pictureRequests(root, opts) {
27563
27773
  const out = [];
@@ -27569,8 +27779,6 @@ function pictureRequests(root, opts) {
27569
27779
  out.push({ id: n.id, mode: "full" });
27570
27780
  if (d.kind === "panel")
27571
27781
  out.push({ id: n.id, mode: "panel" });
27572
- if (d.shadow)
27573
- out.push({ id: n.id, mode: "shadow" });
27574
27782
  if (d.kind !== "picture")
27575
27783
  for (const c of n.children ?? [])
27576
27784
  walk(c);
@@ -27590,6 +27798,56 @@ function imageHashes(root) {
27590
27798
  walk(root);
27591
27799
  return [...out];
27592
27800
  }
27801
+ function robloxName(base, flavor) {
27802
+ switch (flavor) {
27803
+ case "text":
27804
+ return /Label$/.test(base) ? base : `${base}Label`;
27805
+ case "button": {
27806
+ const b = base.replace(/Btn$/, "Button");
27807
+ return /Button$/.test(b) ? b : `${b}Button`;
27808
+ }
27809
+ case "icon":
27810
+ return endsWithWord(base, ICON_WORDS) ? base : `${base}Icon`;
27811
+ case "image":
27812
+ return endsWithWord(base, IMAGE_WORDS) ? base : `${base}Image`;
27813
+ default:
27814
+ return endsWithWord(base, ROLE_WORDS) ? base : `${base}${flavor === "frame" ? "Frame" : "Container"}`;
27815
+ }
27816
+ }
27817
+ function roleTree(n) {
27818
+ const fills = visible(n.fills);
27819
+ 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";
27820
+ return {
27821
+ id: n.id,
27822
+ kind,
27823
+ name: n.name,
27824
+ x: n.x,
27825
+ y: n.y,
27826
+ w: n.w,
27827
+ h: n.h,
27828
+ hidden: n.hidden,
27829
+ text: n.text?.characters,
27830
+ fontSize: n.text ? Math.max(0, ...n.text.segments.map((s) => s.size)) : undefined,
27831
+ layout: n.layout?.mode,
27832
+ wrap: n.layout?.wrap,
27833
+ background: fills.length > 0 || visible(n.strokes).length > 0,
27834
+ radius: Math.max(0, ...n.radius ?? []),
27835
+ ellipse: n.type === "ELLIPSE",
27836
+ clickable: n.clickable,
27837
+ absolute: n.absolute,
27838
+ children: (n.children ?? []).map(roleTree)
27839
+ };
27840
+ }
27841
+ function dedupe(i) {
27842
+ const layers = i.children.filter((c) => !/^UI/.test(c.className));
27843
+ const names = uniqueSiblingNames(layers.map((c) => c.name));
27844
+ layers.forEach((c, k) => {
27845
+ c.name = names[k];
27846
+ c.props[0] = ["Name", S(c.name)];
27847
+ });
27848
+ for (const c of i.children)
27849
+ dedupe(c);
27850
+ }
27593
27851
 
27594
27852
  class Mapper {
27595
27853
  opts;
@@ -27598,56 +27856,98 @@ class Mapper {
27598
27856
  fontSubstitutions = new Map;
27599
27857
  counts = { instances: 0, pictures: 0, skippedHidden: 0 };
27600
27858
  textFactor = 1;
27859
+ names = new Map;
27860
+ roles = new Map;
27601
27861
  constructor(opts, assets) {
27602
27862
  this.opts = opts;
27603
27863
  this.assets = assets;
27604
27864
  }
27865
+ get scaleOnly() {
27866
+ return this.opts.mode === "scale";
27867
+ }
27605
27868
  warn(n, msg) {
27606
27869
  if (this.warnings.size < 60)
27607
27870
  this.warnings.add(n ? `${n.name}: ${msg}` : msg);
27608
27871
  }
27872
+ nameLayers(root) {
27873
+ const tree = roleTree(root);
27874
+ inferRoles(tree);
27875
+ const walk = (r) => {
27876
+ const base = pascalName(r.role ?? r.name) || pascalName(r.role ?? "") || "Layer";
27877
+ this.names.set(r.id, base);
27878
+ if (r.role)
27879
+ this.roles.set(r.id, r.role);
27880
+ for (const c of r.children)
27881
+ walk(c);
27882
+ };
27883
+ walk(tree);
27884
+ }
27885
+ label(n, flavor) {
27886
+ return robloxName(this.names.get(n.id) ?? (pascalName(n.name) || "Layer"), flavor);
27887
+ }
27888
+ isButton(n) {
27889
+ return !!n.clickable || BUTTON_NAME.test(n.name) || this.roles.get(n.id) === "Button";
27890
+ }
27609
27891
  map(root) {
27892
+ this.nameLayers(root);
27610
27893
  const [tw, th] = this.opts.targetResolution;
27611
27894
  const fullScreen = Math.abs(root.w / root.h - tw / th) < 0.1 * (tw / th) && root.w >= tw * 0.4;
27612
27895
  if (fullScreen && this.opts.mode !== "offset")
27613
27896
  this.textFactor = tw / root.w;
27614
27897
  const top = this.node(root, null, 0);
27615
27898
  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));
27899
+ top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex", "AutomaticSize"].includes(k));
27617
27900
  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]);
27901
+ const ratio = [["AspectRatio", F(root.w / root.h)]];
27902
+ if (this.opts.mode !== "offset" && !fullScreen)
27903
+ ratio.push(["DominantAxis", E("DominantAxis", "Height")]);
27904
+ top.children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", ratio));
27905
+ 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]);
27906
+ dedupe(out);
27907
+ return out;
27622
27908
  }
27623
27909
  inst(className, name, props = [], children = [], source) {
27624
27910
  this.counts.instances++;
27625
27911
  return { className, name, props: [["Name", S(name)], ...props], children, ...source ? { source } : {} };
27626
27912
  }
27913
+ frameOf(n, parent) {
27914
+ const inFlow = !!parent.layout && !n.absolute;
27915
+ const scale = this.opts.mode === "scale" || this.opts.mode === "hybrid" && !inFlow;
27916
+ if (!inFlow)
27917
+ return { scale, w: Math.max(1, parent.w), h: Math.max(1, parent.h), inFlow };
27918
+ const p = parent.layout.padding;
27919
+ 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 };
27920
+ }
27921
+ hugs(n, d) {
27922
+ if (this.scaleOnly)
27923
+ return { w: false, h: false };
27924
+ const content = d.kind === "text" || !!n.layout;
27925
+ return { w: n.sizing?.h === "HUG" && content, h: n.sizing?.v === "HUG" && content };
27926
+ }
27627
27927
  geometry(n, parent, index, d) {
27628
27928
  const out = [];
27629
27929
  if (!parent)
27630
27930
  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);
27931
+ const f = this.frameOf(n, parent);
27932
+ const hug = this.hugs(n, d);
27933
+ if (f.inFlow) {
27934
+ const horizontal = parent.layout.mode === "HORIZONTAL";
27640
27935
  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);
27936
+ const w = !horizontal && crossFill ? U(1, 0) : hug.w ? U(0, 0) : f.scale ? U(n.w / f.w, 0) : U(0, n.w);
27937
+ 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
27938
  out.push(["Size", { t: "UDim2", xs: w.s, xo: w.o, ys: h.s, yo: h.o }]);
27644
27939
  out.push(["LayoutOrder", I(index + 1)]);
27645
27940
  return out;
27646
27941
  }
27647
- const c = n.constraints ?? { h: "MIN", v: "MIN" };
27942
+ const c = { ...n.constraints ?? { h: "MIN", v: "MIN" } };
27943
+ const centered = this.centered(n, parent);
27944
+ if (centered.x && c.h === "MIN")
27945
+ c.h = "CENTER";
27946
+ if (centered.y && c.v === "MIN")
27947
+ c.v = "CENTER";
27648
27948
  const axis = (pos, size, total, k) => {
27649
27949
  const end = total - pos - size;
27650
- if (k === "SCALE" || useScale) {
27950
+ if (k === "SCALE" || f.scale) {
27651
27951
  if (k === "MAX")
27652
27952
  return { anchor: 1, p: [(pos + size) / total, 0], s: [size / total, 0] };
27653
27953
  if (k === "CENTER")
@@ -27662,39 +27962,72 @@ class Mapper {
27662
27962
  return { anchor: 0, p: [0, pos], s: [1, -(pos + end)] };
27663
27963
  return { anchor: 0, p: [0, pos], s: [0, size] };
27664
27964
  };
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);
27965
+ const x = axis(n.x, n.w, f.w, c.h);
27966
+ const y = axis(n.y, n.h, f.h, c.v);
27667
27967
  if (x.anchor || y.anchor)
27668
27968
  out.push(["AnchorPoint", V2(x.anchor, y.anchor)]);
27669
27969
  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])]);
27970
+ 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
27971
  out.push(["ZIndex", I(index + 1)]);
27672
27972
  return out;
27673
27973
  }
27974
+ centered(n, parent) {
27975
+ if (!parent || parent.layout && !n.absolute)
27976
+ return { x: false, y: false };
27977
+ const t = n.text?.autoResize;
27978
+ const hugX = t === "WIDTH_AND_HEIGHT" || !n.text && n.sizing?.h === "HUG";
27979
+ const hugY = t === "WIDTH_AND_HEIGHT" || t === "HEIGHT" || !n.text && n.sizing?.v === "HUG";
27980
+ const near = (pos, size, total) => Math.abs(pos + size / 2 - total / 2) <= 1.5 && size < total;
27981
+ return { x: hugX && near(n.x, n.w, parent.w), y: hugY && near(n.y, n.h, parent.h) };
27982
+ }
27674
27983
  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)
27984
+ const hug = this.hugs(n, d);
27985
+ if (!hug.w && !hug.h)
27678
27986
  return [];
27679
- return [["AutomaticSize", E("AutomaticSize", hugW && hugH ? "XY" : hugW ? "X" : "Y")]];
27987
+ return [["AutomaticSize", E("AutomaticSize", hug.w && hug.h ? "XY" : hug.w ? "X" : "Y")]];
27680
27988
  }
27681
27989
  corner(n) {
27682
27990
  const r = Math.max(0, ...n.radius ?? []);
27683
27991
  if (!r && !(n.type === "ELLIPSE"))
27684
27992
  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)]]);
27993
+ const short = Math.max(1, Math.min(n.w, n.h));
27994
+ const pill = n.type === "ELLIPSE" || r >= short / 2 - 0.5;
27995
+ return this.inst("UICorner", "UICorner", [["CornerRadius", pill ? U(0.5, 0) : this.scaleOnly ? U(r / short, 0) : U(0, r)]]);
27687
27996
  }
27688
27997
  stroke(n) {
27689
27998
  const s = visible(n.strokes)[0];
27690
27999
  if (!s || s.type !== "SOLID" || !n.strokeWeight)
27691
28000
  return null;
27692
- return this.inst("UIStroke", "UIStroke", [
28001
+ const short = Math.max(1, Math.min(n.w, n.h));
28002
+ const props = [
27693
28003
  ["ApplyStrokeMode", E("ApplyStrokeMode", "Border")],
27694
28004
  ["Color", C3(s.color)],
27695
- ["Thickness", F(n.strokeWeight)],
28005
+ ["Thickness", F(this.scaleOnly ? n.strokeWeight / short : n.strokeWeight)],
27696
28006
  ["Transparency", F(1 - s.opacity)],
27697
- ["LineJoinMode", E("LineJoinMode", "Round")]
28007
+ ["LineJoinMode", E("LineJoinMode", "Round")],
28008
+ ["BorderStrokePosition", E("BorderStrokePosition", n.strokeAlign === "OUTSIDE" ? "Outer" : n.strokeAlign === "CENTER" ? "Center" : "Inner")]
28009
+ ];
28010
+ if (this.scaleOnly)
28011
+ props.push(["StrokeSizingMode", E("StrokeSizingMode", "ScaledSize")]);
28012
+ return this.inst("UIStroke", "UIStroke", props);
28013
+ }
28014
+ shadow(n) {
28015
+ const list = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
28016
+ if (!list.length)
28017
+ return null;
28018
+ if (list.length > 1)
28019
+ this.warn(n, "several drop shadows: kept the largest");
28020
+ const e = list.reduce((a, b) => b.blur + (b.spread ?? 0) > a.blur + (a.spread ?? 0) ? b : a);
28021
+ const w = Math.max(1, n.w);
28022
+ const h = Math.max(1, n.h);
28023
+ const spread = (e.spread ?? 0) * 2;
28024
+ const s = this.scaleOnly;
28025
+ return this.inst("UIShadow", "UIShadow", [
28026
+ ["Color", C3(e.color ?? "#000000")],
28027
+ ["Transparency", F(1 - (e.alpha ?? 0.25))],
28028
+ ["BlurRadius", s ? U(e.blur / Math.min(w, h), 0) : U(0, e.blur)],
28029
+ ["Offset", s ? U2((e.x ?? 0) / w, 0, (e.y ?? 0) / h, 0) : U2(0, e.x ?? 0, 0, e.y ?? 0)],
28030
+ ["Spread", s ? U2(spread / w, 0, spread / h, 0) : U2(0, spread, 0, spread)]
27698
28031
  ]);
27699
28032
  }
27700
28033
  gradient(p) {
@@ -27717,18 +28050,26 @@ class Mapper {
27717
28050
  }
27718
28051
  layoutChildren(n) {
27719
28052
  const l = n.layout;
28053
+ const s = this.scaleOnly;
27720
28054
  const out = [];
27721
- const [t, r, b, lft] = l.padding;
28055
+ const [t = 0, r = 0, b = 0, lft = 0] = l.padding;
28056
+ const w = Math.max(1, n.w);
28057
+ const h = Math.max(1, n.h);
28058
+ const cw = Math.max(1, w - r - lft);
28059
+ const ch = Math.max(1, h - t - b);
28060
+ const pad = (v, total) => s ? U(v / total, 0) : U(0, v);
27722
28061
  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)]]));
28062
+ out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop", pad(t, h)], ["PaddingRight", pad(r, w)], ["PaddingBottom", pad(b, h)], ["PaddingLeft", pad(lft, w)]]));
27724
28063
  if (l.mode === "GRID") {
27725
28064
  const kids = (n.children ?? []).filter((c) => !c.hidden && !c.absolute);
27726
28065
  const first = kids[0];
27727
28066
  if (kids.some((k) => Math.abs(k.w - (first?.w ?? 0)) > 1 || Math.abs(k.h - (first?.h ?? 0)) > 1))
27728
28067
  this.warn(n, "grid cells of different sizes: UIGridLayout uses the first cell's size");
28068
+ const fw = first?.w ?? 100;
28069
+ const fh = first?.h ?? 100;
27729
28070
  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)],
28071
+ ["CellSize", s ? U2(fw / cw, 0, fh / ch, 0) : U2(0, fw, 0, fh)],
28072
+ ["CellPadding", s ? U2((l.columnGap ?? 0) / cw, 0, (l.rowGap ?? 0) / ch, 0) : U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
27732
28073
  ["FillDirectionMaxCells", I(l.columns ?? 0)],
27733
28074
  ["SortOrder", E("SortOrder", "LayoutOrder")]
27734
28075
  ]));
@@ -27737,10 +28078,11 @@ class Mapper {
27737
28078
  const horizontal = l.mode === "HORIZONTAL";
27738
28079
  const main = l.primary === "CENTER" ? "Center" : l.primary === "MAX" ? horizontal ? "Right" : "Bottom" : horizontal ? "Left" : "Top";
27739
28080
  const cross = l.counter === "CENTER" ? "Center" : l.counter === "MAX" ? horizontal ? "Bottom" : "Right" : horizontal ? "Top" : "Left";
28081
+ const gap = l.primary === "SPACE_BETWEEN" ? 0 : l.gap;
27740
28082
  const props = [
27741
28083
  ["FillDirection", E("FillDirection", horizontal ? "Horizontal" : "Vertical")],
27742
28084
  ["SortOrder", E("SortOrder", "LayoutOrder")],
27743
- ["Padding", U(0, l.primary === "SPACE_BETWEEN" ? 0 : l.gap)],
28085
+ ["Padding", pad(gap, horizontal ? cw : ch)],
27744
28086
  ["HorizontalAlignment", E("HorizontalAlignment", horizontal ? main === "Right" ? "Right" : main === "Center" ? "Center" : "Left" : cross === "Right" ? "Right" : cross === "Center" ? "Center" : "Left")],
27745
28087
  ["VerticalAlignment", E("VerticalAlignment", horizontal ? cross === "Bottom" ? "Bottom" : cross === "Center" ? "Center" : "Top" : main === "Bottom" ? "Bottom" : main === "Center" ? "Center" : "Top")]
27746
28088
  ];
@@ -27803,10 +28145,12 @@ class Mapper {
27803
28145
  props.push(["LineHeight", F(Math.max(1, Math.min(3, base.lineHeight / (base.size * 1.2))))]);
27804
28146
  if (t.maxLines)
27805
28147
  props.push(["TextTruncate", E("TextTruncate", "AtEnd")]);
27806
- if (this.opts.textScaled)
28148
+ if (this.opts.textScaled || this.scaleOnly)
27807
28149
  props.push(["TextScaled", B(true)]);
27808
28150
  if (segs.some((s) => s.letterSpacing))
27809
28151
  this.warn(n, "letter spacing has no Roblox equivalent: ignored");
28152
+ if (rich && this.scaleOnly && segs.some((s) => s.size !== base.size))
28153
+ this.warn(n, "several text sizes with TextScaled: the size tags may not scale with the label");
27810
28154
  return props;
27811
28155
  }
27812
28156
  richRun(s, base) {
@@ -27844,7 +28188,7 @@ class Mapper {
27844
28188
  const d = decide(n, this.opts);
27845
28189
  for (const a of d.approximations)
27846
28190
  this.warn(n, a);
27847
- const button = !!parent && (n.clickable || BUTTON_NAME.test(n.name)) && d.kind !== "text";
28191
+ const button = !!parent && this.isButton(n) && d.kind !== "text";
27848
28192
  const geo = this.geometry(n, parent, index, d);
27849
28193
  const common = [...geo, ...this.automaticSize(n, d)];
27850
28194
  if (n.rotation)
@@ -27853,26 +28197,36 @@ class Mapper {
27853
28197
  let className = "Frame";
27854
28198
  let props = [];
27855
28199
  if (d.kind === "text") {
27856
- className = n.clickable || BUTTON_NAME.test(n.name) ? "TextButton" : "TextLabel";
28200
+ const isButton = !!n.clickable || BUTTON_NAME.test(n.name);
28201
+ className = isButton ? "TextButton" : "TextLabel";
27857
28202
  props = this.textProps(n);
27858
- if (className === "TextButton")
28203
+ const centered = this.centered(n, parent);
28204
+ const auto = n.text.autoResize === "WIDTH_AND_HEIGHT" || n.text.autoResize === "HEIGHT";
28205
+ props = props.map(([k, v]) => k === "TextXAlignment" && centered.x ? [k, E("TextXAlignment", "Center")] : k === "TextYAlignment" && auto && n.text.valign === "TOP" ? [k, E("TextYAlignment", "Center")] : [k, v]);
28206
+ if (isButton)
27859
28207
  props.push(["AutoButtonColor", B(false)]);
28208
+ if ((n.effects ?? []).some((e) => e.type === "DROP_SHADOW"))
28209
+ this.warn(n, "text shadow has no Roblox equivalent: ignored");
27860
28210
  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);
28211
+ const tight = n.text.autoResize === "WIDTH_AND_HEIGHT" || n.text.autoResize === "HEIGHT";
28212
+ const max = this.scaleOnly && tight ? size * 2 : size;
28213
+ const min = this.scaleOnly ? 1 : Math.max(1, Math.round(size * 0.5));
28214
+ children.push(this.inst("UITextSizeConstraint", "UITextSizeConstraint", [["MaxTextSize", I(max)], ["MinTextSize", I(min)]]));
28215
+ return this.inst(className, this.label(n, isButton ? "button" : "text"), [...common, ...props], children, n.id);
27863
28216
  }
27864
28217
  if (d.kind === "picture") {
27865
28218
  this.counts.pictures++;
27866
28219
  const a = this.assetUrl(`${n.id}:full`, n);
27867
28220
  className = button ? "ImageButton" : "ImageLabel";
27868
- const pic = this.pictureGeometry(n, a, geo);
28221
+ const pic = parent ? this.pictureGeometry(n, a, geo, parent) : geo;
27869
28222
  props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", "Stretch")]];
27870
28223
  if (n.opacity !== undefined && n.opacity < 1)
27871
28224
  props.push(["ImageTransparency", F(1 - n.opacity)]);
27872
28225
  if (button)
27873
28226
  props.push(["AutoButtonColor", B(false)]);
27874
28227
  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);
28228
+ const flavor = button ? "button" : n.vector || VECTOR_TYPES.includes(n.type) ? "icon" : "image";
28229
+ return this.inst(className, this.label(n, flavor), [...pic, ...props], children, n.id);
27876
28230
  }
27877
28231
  const fills = visible(n.fills);
27878
28232
  const opacity = n.opacity ?? 1;
@@ -27890,30 +28244,34 @@ class Mapper {
27890
28244
  const corner = this.corner(n);
27891
28245
  if (corner)
27892
28246
  children.push(corner);
28247
+ const shadow = this.shadow(n);
28248
+ if (shadow)
28249
+ children.push(shadow);
27893
28250
  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);
28251
+ return this.inst(className, this.label(n, button ? "button" : "image"), [...common, ...props], children, n.id);
27895
28252
  }
27896
28253
  if (d.kind === "panel") {
27897
28254
  this.counts.pictures++;
27898
28255
  const a = this.assetUrl(`${n.id}:panel`, n);
27899
- const scale = this.opts.scale ?? 2;
28256
+ const scale = a.scale ?? this.opts.scale ?? 2;
27900
28257
  const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + (n.strokeWeight ?? 0) + 2) * scale);
27901
- const pw = Math.round((a.size?.w ?? n.w) * scale);
27902
- const ph = Math.round((a.size?.h ?? n.h) * scale);
28258
+ const pw = a.px?.w ?? Math.round((a.size?.w ?? n.w) * scale);
28259
+ const ph = a.px?.h ?? Math.round((a.size?.h ?? n.h) * scale);
27903
28260
  const ix = Math.min(inset, Math.floor(pw / 2) - 1);
27904
28261
  const iy = Math.min(inset, Math.floor(ph / 2) - 1);
27905
28262
  className = button ? "ImageButton" : "ImageLabel";
27906
- props = [
27907
- ["BackgroundTransparency", F(1)],
27908
- ["Image", { t: "Content", url: a.url }],
27909
- ["ScaleType", E("ScaleType", "Slice")],
27910
- ["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
27911
- ["SliceScale", F(1 / scale)]
27912
- ];
28263
+ props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ...this.imageScaling(n, ix, iy, pw, ph, scale)];
27913
28264
  if (opacity < 1)
27914
28265
  props.push(["ImageTransparency", F(1 - opacity)]);
27915
28266
  if (button)
27916
28267
  props.push(["AutoButtonColor", B(false)]);
28268
+ const shadow = this.shadow(n);
28269
+ if (shadow) {
28270
+ const corner = this.corner(n);
28271
+ if (corner)
28272
+ children.push(corner);
28273
+ children.push(shadow);
28274
+ }
27917
28275
  } else {
27918
28276
  className = button ? "TextButton" : hasKids && opacity < 1 ? "CanvasGroup" : "Frame";
27919
28277
  const f = fills[0];
@@ -27930,7 +28288,7 @@ class Mapper {
27930
28288
  } else if (f.type === "IMAGE") {
27931
28289
  props.push(["BackgroundTransparency", F(1)]);
27932
28290
  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")]]);
28291
+ 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
28292
  const corner = this.corner(n);
27935
28293
  if (corner)
27936
28294
  bg.children.push(corner);
@@ -27946,6 +28304,9 @@ class Mapper {
27946
28304
  const stroke = this.stroke(n);
27947
28305
  if (stroke)
27948
28306
  children.push(stroke);
28307
+ const shadow = this.shadow(n);
28308
+ if (shadow)
28309
+ children.push(shadow);
27949
28310
  }
27950
28311
  props.push(["BorderSizePixel", I(0)]);
27951
28312
  if (n.clip)
@@ -27954,8 +28315,7 @@ class Mapper {
27954
28315
  const flow = n.layout ? kids.filter((k) => !k.absolute) : kids;
27955
28316
  const loose = n.layout ? kids.filter((k) => k.absolute) : [];
27956
28317
  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);
28318
+ const flowMapped = flow.map((k, i) => this.node(k, me, i)).filter((x) => !!x);
27959
28319
  if (n.layout && n.layout.mode !== "GRID") {
27960
28320
  for (const k of flow) {
27961
28321
  const target = k.grow && !k.hidden ? flowMapped.find((c) => c.source === k.id) : undefined;
@@ -27973,44 +28333,37 @@ class Mapper {
27973
28333
  children.push(...this.layoutChildren(n));
27974
28334
  children.push(...flowMapped);
27975
28335
  }
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 }],
28336
+ const flavor = button ? "button" : fills.length || visible(n.strokes).length || d.kind === "panel" ? "frame" : "container";
28337
+ return this.inst(className, this.label(n, flavor), [...common, ...props], children, n.id);
28338
+ }
28339
+ imageScaling(n, ix, iy, pw, ph, scale) {
28340
+ if (this.scaleOnly)
28341
+ return [["ScaleType", E("ScaleType", "Stretch")]];
28342
+ const resizes = n.grow || n.stretch || n.sizing?.h === "FILL" || n.sizing?.v === "FILL" || n.sizing?.h === "HUG" || n.sizing?.v === "HUG";
28343
+ if (this.opts.mode === "offset" && !resizes)
28344
+ return [["ScaleType", E("ScaleType", "Stretch")]];
28345
+ return [
27997
28346
  ["ScaleType", E("ScaleType", "Slice")],
27998
28347
  ["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
27999
28348
  ["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);
28349
+ ];
28004
28350
  }
28005
- pictureGeometry(n, a, geo) {
28351
+ pictureGeometry(n, a, geo, parent) {
28006
28352
  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
28353
  return geo;
28354
+ const f = this.frameOf(n, parent);
28355
+ const dw = a.size.w - n.w;
28356
+ const dh = a.size.h - n.h;
28357
+ const anchor = geo.find(([k]) => k === "AnchorPoint")?.[1];
28358
+ const ox = a.offset.x + (anchor?.x ?? 0) * dw;
28359
+ const oy = a.offset.y + (anchor?.y ?? 0) * dh;
28008
28360
  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];
28361
+ if (v.t !== "UDim2" || k !== "Size" && k !== "Position")
28362
+ return [k, v];
28363
+ if (f.scale) {
28364
+ 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) }];
28365
+ }
28366
+ 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
28367
  });
28015
28368
  }
28016
28369
  }
@@ -28021,10 +28374,14 @@ function mapToRoblox(root, opts, assets) {
28021
28374
  m.warn(null, `${m.counts.skippedHidden} hidden layer(s) skipped`);
28022
28375
  return { root: out, warnings: [...m.warnings], fontSubstitutions: Object.fromEntries(m.fontSubstitutions), counts: m.counts };
28023
28376
  }
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;
28377
+ 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
28378
  var init_map2 = __esm(() => {
28379
+ init_naming();
28026
28380
  init_fonts();
28027
- BUTTON_NAME = /\b(button|btn|cta)\b/i;
28381
+ BUTTON_NAME = /button|btn|\bcta\b|^(close|back|buy|confirm|cancel|next|previous|prev|exit|play|claim|equip|purchase)$/i;
28382
+ VECTOR_TYPES = ["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON", "LINE"];
28383
+ ICON_WORDS = ["Icon", "Logo", "Image", "Illustration", "Avatar", "Emoji"];
28384
+ IMAGE_WORDS = ["Image", "Icon", "Avatar", "Logo", "Thumbnail", "Picture", "Photo", "Banner", "Background", "Illustration", "Art", "Cover"];
28028
28385
  });
28029
28386
 
28030
28387
  // src/roblox/output.ts
@@ -28149,15 +28506,23 @@ function toLuau(root, opts = {}) {
28149
28506
  const lines = [];
28150
28507
  const emit = (i, parentVar, depth) => {
28151
28508
  const v = identifier(i.name, i.className, used);
28509
+ const outer = "\t".repeat(Math.max(0, depth - 1));
28510
+ const tab = "\t".repeat(depth);
28511
+ const add = (line) => lines.push(tab + line.replace(/\n/g, `
28512
+ ${tab}`));
28152
28513
  if (!/^UI/.test(i.className) && depth <= 2 && parentVar)
28153
- lines.push("", `-- ${i.name.replace(/\n/g, " ")}`);
28154
- lines.push(`local ${v} = Instance.new(${luauString(i.className)})`);
28514
+ lines.push("", `${outer}-- ${i.name.replace(/\n/g, " ")}`);
28515
+ if (parentVar)
28516
+ lines.push(`${outer}do`);
28517
+ add(`local ${v} = Instance.new(${luauString(i.className)})`);
28155
28518
  for (const [k, val] of i.props)
28156
- lines.push(`${v}.${k} = ${luauValue(val)}`);
28519
+ add(`${v}.${k} = ${luauValue(val)}`);
28157
28520
  for (const c of i.children)
28158
28521
  emit(c, v, depth + 1);
28159
- if (parentVar)
28160
- lines.push(`${v}.Parent = ${parentVar}`);
28522
+ if (parentVar) {
28523
+ add(`${v}.Parent = ${parentVar}`);
28524
+ lines.push(`${outer}end`);
28525
+ }
28161
28526
  return v;
28162
28527
  };
28163
28528
  const header = [
@@ -28186,9 +28551,10 @@ return ${rootVar}
28186
28551
  function substituteAssets(text, ids) {
28187
28552
  return text.replace(/rbxassetid:\/\/PENDING_\d+/g, (m) => ids[m] ? `rbxassetid://${ids[m]}` : m);
28188
28553
  }
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);
28554
+ 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
28555
  var init_output = __esm(() => {
28191
28556
  ENUMS = {
28557
+ DominantAxis: { Width: 0, Height: 1 },
28192
28558
  ZIndexBehavior: { Global: 0, Sibling: 1 },
28193
28559
  FillDirection: { Horizontal: 0, Vertical: 1 },
28194
28560
  HorizontalAlignment: { Center: 0, Left: 1, Right: 2 },
@@ -28202,7 +28568,9 @@ var init_output = __esm(() => {
28202
28568
  ScaleType: { Stretch: 0, Slice: 1, Tile: 2, Fit: 3, Crop: 4 },
28203
28569
  ApplyStrokeMode: { Contextual: 0, Border: 1 },
28204
28570
  LineJoinMode: { Round: 0, Bevel: 1, Miter: 2 },
28205
- TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 }
28571
+ TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 },
28572
+ StrokeSizingMode: { FixedSize: 0, ScaledSize: 1 },
28573
+ BorderStrokePosition: { Outer: 0, Center: 1, Inner: 2 }
28206
28574
  };
28207
28575
  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
28576
  });
@@ -28308,7 +28676,7 @@ import { join as join2 } from "node:path";
28308
28676
  async function exportRoblox(args, deps) {
28309
28677
  const env = deps.env ?? process.env;
28310
28678
  const opts = {
28311
- mode: args.mode ?? "hybrid",
28679
+ mode: args.mode ?? "scale",
28312
28680
  targetResolution: args.targetResolution ?? [1920, 1080],
28313
28681
  rasterize: args.rasterize ?? "auto",
28314
28682
  asRootFrame: args.asRootFrame,
@@ -28326,7 +28694,7 @@ async function exportRoblox(args, deps) {
28326
28694
  const r = await deps.request("roblox_images", { items: wanted.slice(i, i + 20).map((w) => ({ ...w, scale: opts.scale })) }, 120000);
28327
28695
  for (const img of r.images) {
28328
28696
  if (img.b64)
28329
- pictures.set(`${img.id}:${img.mode}`, { b64: img.b64, offset: img.offset, size: img.size });
28697
+ pictures.set(`${img.id}:${img.mode}`, { b64: img.b64, offset: img.offset, size: img.size, scale: img.scale });
28330
28698
  else
28331
28699
  warnings.push(`Picture of ${img.id} failed: ${img.error}`);
28332
28700
  }
@@ -28352,7 +28720,14 @@ async function exportRoblox(args, deps) {
28352
28720
  const p = pictures.get(`${w.id}:${w.mode}`);
28353
28721
  if (!p)
28354
28722
  continue;
28355
- add(`${w.id}:${w.mode}`, new Uint8Array(Buffer.from(p.b64, "base64")), { nodeId: w.id, layer: names.get(w.id) ?? w.id, kind: "picture", mode: w.mode }, "png", { offset: p.offset, size: p.size });
28723
+ const bytes = new Uint8Array(Buffer.from(p.b64, "base64"));
28724
+ const info = imageInfo(bytes);
28725
+ add(`${w.id}:${w.mode}`, bytes, { nodeId: w.id, layer: names.get(w.id) ?? w.id, kind: "picture", mode: w.mode }, "png", {
28726
+ offset: p.offset,
28727
+ size: p.size,
28728
+ scale: p.scale,
28729
+ ...info ? { px: { w: info.width, h: info.height } } : {}
28730
+ });
28356
28731
  }
28357
28732
  for (const hash of imageHashes(t.tree)) {
28358
28733
  const b64 = t.images[hash];
@@ -30441,6 +30816,15 @@ function snapshot(opts) {
30441
30816
  return false;
30442
30817
  }
30443
30818
  const SKIP = ["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "HEAD", "META", "LINK", "TITLE", "BASE"];
30819
+ const skewed = (t) => {
30820
+ if (!t || t === "none")
30821
+ return false;
30822
+ const m = /^matrix(([^)]+))$/.exec(t);
30823
+ if (!m)
30824
+ return true;
30825
+ const v = m[1].split(",").map(Number);
30826
+ return Math.abs(v[1]) > 0.001 || Math.abs(v[2]) > 0.001;
30827
+ };
30444
30828
  const RASTER = ["CANVAS", "VIDEO", "IFRAME", "OBJECT", "EMBED", "AUDIO", "MATH"];
30445
30829
  function isPlainInline(el, cs) {
30446
30830
  if (cs.display !== "inline" || el.tagName === "IMG" || el.tagName === "svg" || el.tagName === "INPUT" || el.tagName === "BUTTON" || el.tagName === "SELECT" || el.tagName === "TEXTAREA")
@@ -30647,10 +31031,11 @@ function snapshot(opts) {
30647
31031
  return [{ kind: "image", tag, name, box, css: cssOf(cs), children: [], src: img.currentSrc || img.src }];
30648
31032
  }
30649
31033
  const nativeControl = tag === "input" && /^(checkbox|radio|range|color|file)$/i.test(el.type) || tag === "select" && cs.appearance !== "none";
30650
- if (RASTER.indexOf(el.tagName) !== -1 || nativeControl) {
31034
+ if (RASTER.indexOf(el.tagName) !== -1 || nativeControl || skewed(cs.transform) && !(el.textContent || "").trim()) {
30651
31035
  if (box.w < 0.5 || box.h < 0.5)
30652
31036
  return [];
30653
- return [{ kind: "raster", tag, name, box, css: cssOf(cs), children: [] }];
31037
+ el.setAttribute("data-fb-pic", String(count));
31038
+ return [{ kind: "raster", tag, name, box, css: cssOf(cs), children: [], ref: count }];
30654
31039
  }
30655
31040
  const node = { kind: "box", tag, name, box, css: cssOf(cs), children: [] };
30656
31041
  if (tag === "input" || tag === "textarea") {
@@ -30832,6 +31217,7 @@ async function render(source, opts) {
30832
31217
  screenshot = opts.selector ? await page.locator(opts.selector).first().screenshot() : await page.screenshot({ fullPage: true, clip });
30833
31218
  } catch {}
30834
31219
  const r = { viewport: width, snap, screenshot };
31220
+ let queue = Promise.resolve();
30835
31221
  await opts.withPage(r, {
30836
31222
  fetch: async (u) => {
30837
31223
  const res = await context.request.get(u, { timeout: 20000 });
@@ -30839,7 +31225,24 @@ async function render(source, opts) {
30839
31225
  throw new Error(`HTTP ${res.status()}`);
30840
31226
  return new Uint8Array(await res.body());
30841
31227
  },
30842
- picture: async (b) => new Uint8Array(await page.screenshot({ fullPage: true, clip: { x: b.x, y: b.y, width: Math.max(1, b.w), height: Math.max(1, b.h) }, omitBackground: true }))
31228
+ picture: (b, ref) => {
31229
+ const shot = queue.then(async () => {
31230
+ const clip = { x: b.x, y: b.y, width: Math.max(1, b.w), height: Math.max(1, b.h) };
31231
+ if (ref === undefined)
31232
+ return new Uint8Array(await page.screenshot({ fullPage: true, clip, omitBackground: true }));
31233
+ const sel = `[data-fb-pic="${ref}"]`;
31234
+ const style = await page.addStyleTag({
31235
+ content: `html,body{background:transparent!important}body *{visibility:hidden!important}${sel},${sel} *{visibility:visible!important}`
31236
+ });
31237
+ try {
31238
+ return new Uint8Array(await page.screenshot({ fullPage: true, clip, omitBackground: true }));
31239
+ } finally {
31240
+ await style.evaluate((el) => el.remove()).catch(() => {});
31241
+ }
31242
+ });
31243
+ queue = shot.catch(() => {});
31244
+ return shot;
31245
+ }
30843
31246
  });
30844
31247
  out.push(r);
30845
31248
  } finally {
@@ -31073,13 +31476,13 @@ class Converter {
31073
31476
  if (this.warnings.size < 40)
31074
31477
  this.warnings.add(msg);
31075
31478
  }
31076
- asset(url, box, layer) {
31479
+ asset(url, box, layer, ref) {
31077
31480
  if (url && !layer && this.urlKeys.has(url))
31078
31481
  return this.urlKeys.get(url);
31079
31482
  if (url && layer && this.urlKeys.has(url) && this.assets[this.urlKeys.get(url)].layer)
31080
31483
  return this.urlKeys.get(url);
31081
31484
  const key = `w${this.n++}`;
31082
- this.assets[key] = { url, box, layer };
31485
+ this.assets[key] = ref === undefined ? { url, box, layer } : { url, box, layer, ref };
31083
31486
  if (url)
31084
31487
  this.urlKeys.set(url, key);
31085
31488
  return key;
@@ -31141,7 +31544,7 @@ class Converter {
31141
31544
  if (n.kind === "image" || n.kind === "raster") {
31142
31545
  this.stats.images++;
31143
31546
  const fits = { contain: "fit", cover: "fill", none: "crop", "scale-down": "fit" };
31144
- const key = this.asset(n.kind === "image" ? n.src : undefined, n.box, true);
31547
+ const key = this.asset(n.kind === "image" ? n.src : undefined, n.box, true, n.ref);
31145
31548
  return {
31146
31549
  type: "image",
31147
31550
  name: n.name || TAG_NAMES[n.tag] || "Image",
@@ -31187,7 +31590,9 @@ class Converter {
31187
31590
  spec.clip = true;
31188
31591
  const flow = n.children.filter((c) => !outOfFlow(c));
31189
31592
  const positioned = n.children.filter(outOfFlow);
31190
- const layout = flow.length ? this.layout(n, flow) : null;
31593
+ let layout = flow.length ? this.layout(n, flow) : null;
31594
+ if (layout && layout.dir === "grid" && !this.opts.grid)
31595
+ layout = null;
31191
31596
  const children = [];
31192
31597
  if (layout) {
31193
31598
  this.stats.autoLayout++;
@@ -31210,6 +31615,8 @@ class Converter {
31210
31615
  child.colSpan = item.colSpan;
31211
31616
  if (item.rowSpan && item.rowSpan > 1)
31212
31617
  child.rowSpan = item.rowSpan;
31618
+ if (layout.dir === "grid")
31619
+ child.place = [r1(item.node.box.x - n.box.x), r1(item.node.box.y - n.box.y), r1(item.node.box.w), r1(item.node.box.h)];
31213
31620
  if (item.wrap) {
31214
31621
  this.stats.layers++;
31215
31622
  children.push({
@@ -31225,28 +31632,32 @@ class Converter {
31225
31632
  }
31226
31633
  spec.w = layout.fixedW || children.some((c) => c.w === "fill") ? n.box.w : "hug";
31227
31634
  spec.h = layout.fixedH || children.some((c) => c.h === "fill") ? n.box.h : "hug";
31635
+ if (layout.dir === "grid")
31636
+ spec.gridSize = [r1(n.box.w), r1(n.box.h)];
31228
31637
  } else {
31229
31638
  if (flow.length)
31230
31639
  this.stats.free++;
31231
31640
  spec.w = n.box.w;
31232
31641
  spec.h = n.box.h;
31233
- for (const c of paintOrder(flow)) {
31234
- const child = this.node(c);
31235
- if (!child)
31236
- break;
31237
- children.push(this.placeAt(child, c, n));
31238
- }
31239
31642
  }
31240
- for (const c of paintOrder(positioned)) {
31643
+ const free = layout ? positioned : n.children;
31644
+ const floor = layout ? Math.min(...flow.map(stackLevel)) : Infinity;
31645
+ const below = [];
31646
+ for (const c of paintOrder(free)) {
31241
31647
  const child = this.node(c);
31242
31648
  if (!child)
31243
31649
  break;
31244
- children.push({ ...this.placeAt(child, c, n), ...layout ? { absolute: true } : {} });
31650
+ const placed = { ...this.placeAt(child, c, n), ...layout ? { absolute: true } : {} };
31651
+ if (layout && stackLevel(c) < floor)
31652
+ below.push(placed);
31653
+ else
31654
+ children.push(placed);
31245
31655
  }
31656
+ children.unshift(...below);
31246
31657
  if (children.length)
31247
31658
  spec.children = children;
31248
31659
  if (!spec.name)
31249
- spec.name = layout ? layout.dir === "grid" ? "Grid" : layout.dir === "row" ? "Row" : "Column" : "Frame";
31660
+ spec.name = layout ? layout.dir === "grid" ? "Grid" : layout.dir === "row" ? "Row" : "Column" : "Container";
31250
31661
  return spec;
31251
31662
  }
31252
31663
  placeAt(child, c, parent) {
@@ -31310,7 +31721,7 @@ class Converter {
31310
31721
  return span;
31311
31722
  });
31312
31723
  }
31313
- if (!n.autoWidth || (n.lines ?? 1) > 1)
31724
+ if ((n.lines ?? 1) > 1 || !n.autoWidth && align)
31314
31725
  spec.w = n.box.w;
31315
31726
  return spec;
31316
31727
  }
@@ -31345,9 +31756,17 @@ function label(n) {
31345
31756
  function decorationOf(v) {
31346
31757
  return /underline/.test(v) ? "underline" : /line-through/.test(v) ? "strike" : undefined;
31347
31758
  }
31759
+ function stackLevel(n) {
31760
+ const z = n.css.zIndex && n.css.zIndex !== "auto" ? Number(n.css.zIndex) || 0 : null;
31761
+ if (z !== null && z < 0)
31762
+ return z - 1;
31763
+ const positioned = !!n.css.position && n.css.position !== "static";
31764
+ if (!positioned && z === null)
31765
+ return 0;
31766
+ return 1 + Math.max(0, z ?? 0);
31767
+ }
31348
31768
  function paintOrder(list) {
31349
- const z = (n) => n.css.zIndex && n.css.zIndex !== "auto" ? Number(n.css.zIndex) || 0 : 0;
31350
- return list.map((n, i) => ({ n, i })).sort((a, b) => z(a.n) - z(b.n) || a.i - b.i).map((x) => x.n);
31769
+ return list.map((n, i) => ({ n, i })).sort((a, b) => stackLevel(a.n) - stackLevel(b.n) || a.i - b.i).map((x) => x.n);
31351
31770
  }
31352
31771
  function padOf(n) {
31353
31772
  const b = borderWidths(n.css);
@@ -31797,7 +32216,7 @@ async function importWeb(opts, deps) {
31797
32216
  }
31798
32217
  if (a.layer) {
31799
32218
  try {
31800
- images[key] = await page.picture(a.box);
32219
+ images[key] = await page.picture(a.box, a.ref);
31801
32220
  return;
31802
32221
  } catch {}
31803
32222
  }
@@ -32078,7 +32497,7 @@ async function layerNames(session, ids) {
32078
32497
  return bridge.request("node_info", { ids: [...new Set(ids)] }, 20000).catch(() => ({}));
32079
32498
  }
32080
32499
  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.
32500
+ 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
32501
  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
32502
  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
32503
  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.
@@ -32144,6 +32563,7 @@ var init_server3 = __esm(async () => {
32144
32563
  "Workflow:",
32145
32564
  "1. get_context, then get_design_system when the file has styles, variables or components: reuse them instead of raw values. find locates layers by name, text, type, style or component across pages.",
32146
32565
  "2. New UI → build (one call per screen or section, with auto-layout; grid, rich text spans, component sets with variants, prototype reactions). Editing existing design → describe it first, then build into it (parentId) or run_script.",
32566
+ "Name every layer by its role, like a design system would: Header, NavBar, HeroSection, Content, PricingCard, CardTitle, Price, PrimaryButton, Avatar & Info, Icon/Search; components as Category/Variant (Button/Primary). Never leave Frame 12 or Rectangle 3: names become class names in code and instance names in Roblox.",
32147
32567
  "3. Design system → design_tokens writes variables (with Light/Dark modes) and styles from simple JSON, W3C tokens or a Tailwind theme; build uses them via var:, style: and modes.",
32148
32568
  "4. Verify → screenshot with returnImage:true, and audit to catch contrast, overflow and naming issues. Fix, then check again. audit {scope:'design-system'} scores the file's design system; audit {fix:true} binds raw values to variables and styles.",
32149
32569
  "Reproduce a mockup or screenshot: build it at the mockup's size → compare {nodeId, reference, returnImage:true} → fix the largest regions → compare again until mismatchPercent stops dropping.",
@@ -32157,7 +32577,8 @@ var init_server3 = __esm(async () => {
32157
32577
  "Rules: load fonts before editing text; pages load on demand (await figma.setCurrentPageAsync / getNodeByIdAsync); keep results small.",
32158
32578
  "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
32579
  "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.",
32580
+ "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.",
32581
+ "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
32582
  "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
32583
  "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
32584
  ];
@@ -32692,21 +33113,22 @@ ${f.content}`).join(`
32692
33113
  server.registerTool("export_roblox", {
32693
33114
  title: "Export a frame to Roblox UI",
32694
33115
  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"}',
33116
+ ` + "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…).
33117
+ ` + "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.
33118
+ ` + "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
33119
  inputSchema: {
32698
33120
  nodeId: string2().optional(),
32699
- mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default hybrid"),
33121
+ mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default scale (no offsets); offset or hybrid only if the user asks"),
32700
33122
  targetResolution: array(number2().int().min(100).max(8000)).length(2).optional().describe("Screen size the UI is designed for, default [1920, 1080]"),
32701
33123
  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
33124
  outDir: string2().optional().describe("Folder for the files (default: a new temp folder)"),
32703
33125
  upload: boolean2().optional().describe("Upload the pictures through Open Cloud (needs ROBLOX_API_KEY and ROBLOX_CREATOR_ID)"),
32704
33126
  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)"),
33127
+ textScaled: boolean2().optional().describe("TextScaled on labels in offset or hybrid mode (always on in scale mode)"),
32706
33128
  fonts: object2({}).passthrough().optional().describe('Figma family → Roblox family or rbxasset URL, e.g. {"Inter":"GothamSSm"}'),
32707
33129
  parent: string2().optional().describe('Luau expression for the parent, default game:GetService("StarterGui")')
32708
33130
  }
32709
- }, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "hybrid"}`, async () => {
33131
+ }, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "scale"}`, async () => {
32710
33132
  const r = await exportRoblox({ ...args, targetResolution: args.targetResolution, fonts: args.fonts }, { request: (m, p, t) => bridge.request(m, p, t), outDir: OUT_DIR });
32711
33133
  let luau = r.luau;
32712
33134
  if (luau.length > MAX_OUTPUT_CHARS)