@clovis500c/figma-bridge 1.8.0 → 1.12.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
@@ -47,7 +47,7 @@ var __esm = (fn, res, err) => () => {
47
47
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
48
48
 
49
49
  // package.json
50
- var version = "1.8.0";
50
+ var version = "1.12.0";
51
51
  var init_package = () => {};
52
52
 
53
53
  // node_modules/zod/v3/helpers/util.js
@@ -24772,6 +24772,7 @@ var init_wrapper = __esm(() => {
24772
24772
  });
24773
24773
 
24774
24774
  // src/bridge.ts
24775
+ import { AsyncLocalStorage } from "node:async_hooks";
24775
24776
  import { randomUUID } from "node:crypto";
24776
24777
  import { createServer } from "node:http";
24777
24778
  import { connect } from "node:net";
@@ -24998,6 +24999,10 @@ class Hub {
24998
24999
  ws.data.session.fileName = msg.fileName;
24999
25000
  if (typeof msg.page === "string")
25000
25001
  ws.data.session.page = msg.page;
25002
+ if (typeof msg.editorType === "string")
25003
+ ws.data.session.editorType = msg.editorType;
25004
+ if (typeof msg.pageId === "string")
25005
+ ws.data.session.pageId = msg.pageId;
25001
25006
  }
25002
25007
  } else if (ws.data.role === "agent") {
25003
25008
  if (msg.t === "req")
@@ -25025,6 +25030,9 @@ class Hub {
25025
25030
  page: String(s.page || ""),
25026
25031
  channel,
25027
25032
  version: msg.version,
25033
+ editorType: typeof s.editorType === "string" ? s.editorType : undefined,
25034
+ fileKey: typeof s.fileKey === "string" && s.fileKey ? s.fileKey : undefined,
25035
+ pageId: typeof s.pageId === "string" ? s.pageId : undefined,
25028
25036
  connectedAt: Date.now()
25029
25037
  };
25030
25038
  const previous = this.plugins.get(session.id);
@@ -25216,7 +25224,7 @@ class Bridge {
25216
25224
  throw new BridgeError(this.lastError ?? `The bridge could not open or join port ${this.opts.port}.`, "OFFLINE");
25217
25225
  }
25218
25226
  try {
25219
- return await this.send(method, params, timeoutMs);
25227
+ return await this.send(method, params, timeoutMs, await this.selectionFor(method));
25220
25228
  } catch (e) {
25221
25229
  const retryable = e instanceof BridgeError && (e.code === "NO_SESSION" || e.code === "OFFLINE");
25222
25230
  if (!retryable || Date.now() > deadline)
@@ -25225,10 +25233,17 @@ class Bridge {
25225
25233
  }
25226
25234
  }
25227
25235
  }
25228
- send(method, params, timeoutMs) {
25236
+ async selectionFor(method) {
25237
+ const file = method === "$sessions" ? undefined : callTarget.getStore()?.file;
25238
+ if (!file)
25239
+ return this.selection;
25240
+ const s = await this.find(file);
25241
+ return { id: s.id, fileName: s.fileName };
25242
+ }
25243
+ send(method, params, timeoutMs, sel) {
25229
25244
  return new Promise((resolve, reject) => {
25230
25245
  const id = randomUUID();
25231
- const msg = { t: "req", id, method, params, sel: this.selection, timeoutMs };
25246
+ const msg = { t: "req", id, method, params, sel, timeoutMs };
25232
25247
  const timer = setTimeout(() => {
25233
25248
  this.pending.delete(id);
25234
25249
  reject(new BridgeError(`${method} got no answer after ${Math.round(timeoutMs / 1000)} s. Figma may be frozen by a long synchronous script.`, "TIMEOUT"));
@@ -25263,6 +25278,18 @@ class Bridge {
25263
25278
  return this.request("$sessions", {}, 5000, 1500);
25264
25279
  }
25265
25280
  async select(name) {
25281
+ const hit = await this.find(name);
25282
+ this.selection = { id: hit.id, fileName: hit.fileName };
25283
+ return hit;
25284
+ }
25285
+ async current() {
25286
+ const file = callTarget.getStore()?.file;
25287
+ if (file)
25288
+ return this.find(file);
25289
+ const list = await this.sessions().catch(() => []);
25290
+ return list.find((s) => s.id === this.selection.id) ?? (list.length === 1 ? list[0] : null);
25291
+ }
25292
+ async find(name) {
25266
25293
  const list = await this.sessions();
25267
25294
  const q = name.trim().toLowerCase();
25268
25295
  const hit = list.find((s) => s.id === name) ?? list.find((s) => s.fileName.toLowerCase() === q) ?? (list.filter((s) => s.fileName.toLowerCase().includes(q)).length === 1 ? list.find((s) => s.fileName.toLowerCase().includes(q)) : undefined);
@@ -25270,15 +25297,15 @@ class Bridge {
25270
25297
  const names = list.map((s) => `"${s.fileName}"`).join(", ") || "none";
25271
25298
  throw new BridgeError(`No unique connected file matches "${name}". Connected: ${names}.`, "NOT_FOUND");
25272
25299
  }
25273
- this.selection = { id: hit.id, fileName: hit.fileName };
25274
25300
  return hit;
25275
25301
  }
25276
25302
  }
25277
- var BRIDGE_NAME = "figma-bridge", PROTOCOL_VERSION = 1, CHUNK_CHARS, MAX_MESSAGE_CHARS, PARTIAL_TTL_MS = 60000, INFLIGHT_TTL_MS = 180000, HANDSHAKE_MS = 2500, PING_MS = 20000, IDLE_MS = 120000, BridgeError, mb = (chars) => `${(chars / (1 << 20)).toFixed(1)} MB`, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), NO_PLUGIN_HELP;
25303
+ var BRIDGE_NAME = "figma-bridge", PROTOCOL_VERSION = 1, CHUNK_CHARS, MAX_MESSAGE_CHARS, PARTIAL_TTL_MS = 60000, INFLIGHT_TTL_MS = 180000, HANDSHAKE_MS = 2500, PING_MS = 20000, IDLE_MS = 120000, callTarget, BridgeError, mb = (chars) => `${(chars / (1 << 20)).toFixed(1)} MB`, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), NO_PLUGIN_HELP;
25278
25304
  var init_bridge = __esm(() => {
25279
25305
  init_wrapper();
25280
25306
  CHUNK_CHARS = 1 << 20;
25281
25307
  MAX_MESSAGE_CHARS = 256 << 20;
25308
+ callTarget = new AsyncLocalStorage;
25282
25309
  BridgeError = class BridgeError extends Error {
25283
25310
  code;
25284
25311
  details;
@@ -26799,9 +26826,1647 @@ var init_codegen = __esm(() => {
26799
26826
  RADII = { 2: "rounded-sm", 4: "rounded", 6: "rounded-md", 8: "rounded-lg", 12: "rounded-xl", 16: "rounded-2xl", 24: "rounded-3xl" };
26800
26827
  });
26801
26828
 
26829
+ // src/diagram.ts
26830
+ function parseDiagram(source) {
26831
+ const lines = source.replace(/%%.*$/gm, "").split(/\n|;/).map((l) => l.trim()).filter(Boolean);
26832
+ const d = { direction: "TB", nodes: [], edges: [], subgraphs: [] };
26833
+ const byId = new Map;
26834
+ const stack = [];
26835
+ const node = (ref) => {
26836
+ const m = /^([A-Za-z0-9_\-.$]+)\s*(.*)$/s.exec(ref.trim());
26837
+ if (!m)
26838
+ return null;
26839
+ const id = m[1];
26840
+ const rest = m[2].trim();
26841
+ let n = byId.get(id);
26842
+ if (!n) {
26843
+ n = { id, label: id, shape: "rounded" };
26844
+ byId.set(id, n);
26845
+ d.nodes.push(n);
26846
+ }
26847
+ if (rest) {
26848
+ for (const [re, shape] of SHAPES) {
26849
+ const s = re.exec(rest);
26850
+ if (s) {
26851
+ n.label = unquote(s[1]);
26852
+ n.shape = shape;
26853
+ break;
26854
+ }
26855
+ }
26856
+ }
26857
+ for (const g of stack)
26858
+ if (!g.nodes.includes(id))
26859
+ g.nodes.push(id);
26860
+ return id;
26861
+ };
26862
+ for (const line of lines) {
26863
+ const header = /^(?:flowchart|graph)\s*(TB|TD|BT|LR|RL)?\b/i.exec(line);
26864
+ if (header) {
26865
+ const dir = (header[1] ?? "TB").toUpperCase();
26866
+ d.direction = dir === "TD" ? "TB" : dir;
26867
+ continue;
26868
+ }
26869
+ const sub = /^subgraph\s+([^\s[]+)(?:\s*\[(.*)\])?\s*(.*)$/i.exec(line);
26870
+ if (sub) {
26871
+ const g = { id: sub[1], label: unquote(sub[2] ?? (sub[3] || sub[1])), nodes: [] };
26872
+ d.subgraphs.push(g);
26873
+ stack.push(g);
26874
+ continue;
26875
+ }
26876
+ if (/^end$/i.test(line)) {
26877
+ stack.pop();
26878
+ continue;
26879
+ }
26880
+ if (/^(direction|classDef|class|style|linkStyle|click)\b/.test(line))
26881
+ continue;
26882
+ const parts = [];
26883
+ const links = [];
26884
+ let rest = line;
26885
+ for (;; ) {
26886
+ const m = EDGE.exec(rest);
26887
+ if (!m) {
26888
+ parts.push(rest);
26889
+ break;
26890
+ }
26891
+ parts.push(rest.slice(0, m.index));
26892
+ let op = m[1];
26893
+ let label = m[2];
26894
+ const inline = /^(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->)$/.exec(op);
26895
+ if (inline) {
26896
+ label = inline[2];
26897
+ op = inline[1] === "--" ? "-->" : inline[1] === "==" ? "==>" : "-.->";
26898
+ }
26899
+ links.push({ op, label: label?.trim() || undefined });
26900
+ rest = rest.slice(m.index + m[0].length);
26901
+ }
26902
+ const groups = parts.map((p) => p.split(/\s+&\s+/).map(node).filter((x) => !!x));
26903
+ links.forEach((l, i) => {
26904
+ for (const from of groups[i] ?? []) {
26905
+ for (const to of groups[i + 1] ?? []) {
26906
+ d.edges.push({ from, to, label: l.label, dashed: l.op.includes("."), thick: l.op.includes("="), arrow: />$/.test(l.op) });
26907
+ }
26908
+ }
26909
+ });
26910
+ }
26911
+ return d;
26912
+ }
26913
+ function layoutDiagram(d, opts = {}) {
26914
+ const size = opts.size ?? defaultSize;
26915
+ const ids = d.nodes.map((n) => n.id);
26916
+ const out = new Map(ids.map((i) => [i, []]));
26917
+ const inn = new Map(ids.map((i) => [i, []]));
26918
+ const state = new Map;
26919
+ const forward = [];
26920
+ const adj = new Map(ids.map((i) => [i, []]));
26921
+ for (const e of d.edges)
26922
+ if (e.from !== e.to)
26923
+ adj.get(e.from).push(e.to);
26924
+ const dfs = (v) => {
26925
+ state.set(v, 1);
26926
+ for (const w of adj.get(v)) {
26927
+ if (state.get(w) === 1)
26928
+ continue;
26929
+ forward.push([v, w]);
26930
+ if (!state.get(w))
26931
+ dfs(w);
26932
+ }
26933
+ state.set(v, 2);
26934
+ };
26935
+ for (const v of ids)
26936
+ if (!state.get(v))
26937
+ dfs(v);
26938
+ for (const [a, b] of forward) {
26939
+ out.get(a).push(b);
26940
+ inn.get(b).push(a);
26941
+ }
26942
+ const layer = new Map;
26943
+ const visit = (v) => {
26944
+ if (layer.has(v))
26945
+ return layer.get(v);
26946
+ layer.set(v, 0);
26947
+ const l = Math.max(0, ...inn.get(v).map((u) => visit(u) + 1));
26948
+ layer.set(v, l);
26949
+ return l;
26950
+ };
26951
+ for (const v of ids)
26952
+ visit(v);
26953
+ const layers = [];
26954
+ for (const v of ids)
26955
+ (layers[layer.get(v)] ??= []).push(v);
26956
+ const pos = new Map;
26957
+ const index = () => layers.forEach((l) => l.forEach((v, i) => pos.set(v, i)));
26958
+ index();
26959
+ for (let sweep = 0;sweep < 4; sweep++) {
26960
+ const down = sweep % 2 === 0;
26961
+ const order = down ? layers.slice(1) : layers.slice(0, -1).reverse();
26962
+ for (const l of order) {
26963
+ const bary = (v) => {
26964
+ const ns = down ? inn.get(v) : out.get(v);
26965
+ return ns.length ? ns.reduce((a, u) => a + pos.get(u), 0) / ns.length : pos.get(v);
26966
+ };
26967
+ l.sort((a, b) => bary(a) - bary(b));
26968
+ l.forEach((v, i) => pos.set(v, i));
26969
+ }
26970
+ }
26971
+ const horizontal = d.direction === "LR" || d.direction === "RL";
26972
+ const gap = opts.gap ?? 60;
26973
+ const layerGap = opts.layerGap ?? 100;
26974
+ const sizes = new Map(d.nodes.map((n) => [n.id, size(n)]));
26975
+ const along = (v) => horizontal ? sizes.get(v).h : sizes.get(v).w;
26976
+ const across = (v) => horizontal ? sizes.get(v).w : sizes.get(v).h;
26977
+ const spans = layers.map((l) => l.reduce((a, v) => a + along(v), 0) + gap * Math.max(0, l.length - 1));
26978
+ const widest = Math.max(0, ...spans);
26979
+ const positions = new Map;
26980
+ let depth = 0;
26981
+ layers.forEach((l, li) => {
26982
+ const thickness = Math.max(...l.map(across));
26983
+ let cursor = (widest - spans[li]) / 2;
26984
+ for (const v of l) {
26985
+ const s = sizes.get(v);
26986
+ const main = cursor;
26987
+ const cross = depth + (thickness - across(v)) / 2;
26988
+ positions.set(v, horizontal ? { x: cross, y: main, w: s.w, h: s.h } : { x: main, y: cross, w: s.w, h: s.h });
26989
+ cursor += along(v) + gap;
26990
+ }
26991
+ depth += thickness + layerGap;
26992
+ });
26993
+ const total = Math.max(0, depth - layerGap);
26994
+ if (d.direction === "BT" || d.direction === "RL") {
26995
+ for (const p of positions.values()) {
26996
+ if (horizontal)
26997
+ p.x = total - p.x - p.w;
26998
+ else
26999
+ p.y = total - p.y - p.h;
27000
+ }
27001
+ }
27002
+ return { positions, width: horizontal ? total : widest, height: horizontal ? widest : total };
27003
+ }
27004
+ function defaultSize(n) {
27005
+ const lines = n.label.split(`
27006
+ `);
27007
+ const longest = Math.max(...lines.map((l) => l.length));
27008
+ const w = Math.min(320, Math.max(n.shape === "diamond" ? 180 : 160, longest * 9 + 48));
27009
+ const h = Math.max(n.shape === "diamond" ? 120 : 80, lines.length * 22 + 40);
27010
+ return n.shape === "ellipse" ? { w, h: Math.max(h, 100) } : { w, h };
27011
+ }
27012
+ function diagramSpec(spec) {
27013
+ const d = parseDiagram(String(spec.source ?? spec.mermaid ?? ""));
27014
+ if (!d.nodes.length)
27015
+ throw new Error('diagram: no nodes found. Example: source:"flowchart LR\\n A[Start] --> B{OK?}\\n B -->|yes| C(Done)"');
27016
+ const stickies = spec.as === "stickies";
27017
+ const STICKY = { w: 240, h: 240 };
27018
+ const lay = layoutDiagram(d, stickies ? { size: () => STICKY, gap: 80, layerGap: 120 } : {});
27019
+ const pad = 80;
27020
+ const head = 60;
27021
+ const inGroup = new Map;
27022
+ for (const g of d.subgraphs)
27023
+ for (const id of g.nodes)
27024
+ if (!inGroup.has(id))
27025
+ inGroup.set(id, g);
27026
+ const nodeSpec = (n, dx, dy) => {
27027
+ const p = lay.positions.get(n.id);
27028
+ const base = { name: n.label.split(`
27029
+ `)[0], key: `${spec.key ?? "diagram"}:${n.id}`, x: Math.round(p.x + dx), y: Math.round(p.y + dy) };
27030
+ return stickies ? { ...base, type: "sticky", text: n.label, color: spec.color ?? "yellow" } : { ...base, type: "shape", shape: n.shape, text: n.label, w: Math.round(p.w), h: Math.round(p.h), ...spec.color ? { fill: spec.color } : {} };
27031
+ };
27032
+ const children = [];
27033
+ for (const g of d.subgraphs) {
27034
+ const members = g.nodes.filter((id) => inGroup.get(id) === g).map((id) => d.nodes.find((n) => n.id === id));
27035
+ if (!members.length)
27036
+ continue;
27037
+ const ps = members.map((n) => lay.positions.get(n.id));
27038
+ const x0 = Math.min(...ps.map((p) => p.x)) - 40;
27039
+ const y0 = Math.min(...ps.map((p) => p.y)) - head;
27040
+ const x1 = Math.max(...ps.map((p) => p.x + p.w)) + 40;
27041
+ const y1 = Math.max(...ps.map((p) => p.y + p.h)) + 40;
27042
+ children.push({
27043
+ type: "section",
27044
+ name: g.label,
27045
+ x: Math.round(x0 + pad),
27046
+ y: Math.round(y0 + pad + head),
27047
+ w: Math.round(x1 - x0),
27048
+ h: Math.round(y1 - y0),
27049
+ children: members.map((n) => nodeSpec(n, -x0, -y0))
27050
+ });
27051
+ }
27052
+ for (const n of d.nodes)
27053
+ if (!inGroup.has(n.id))
27054
+ children.push(nodeSpec(n, pad, pad + head));
27055
+ for (const e of d.edges) {
27056
+ children.push({
27057
+ type: "connector",
27058
+ from: `${spec.key ?? "diagram"}:${e.from}`,
27059
+ to: `${spec.key ?? "diagram"}:${e.to}`,
27060
+ ...e.label ? { label: e.label } : {},
27061
+ ...e.dashed ? { dashed: true } : {},
27062
+ ...e.thick ? { strokeWidth: 4 } : {},
27063
+ ...e.arrow ? {} : { endArrow: "none" },
27064
+ line: spec.line ?? "elbowed"
27065
+ });
27066
+ }
27067
+ return {
27068
+ type: "section",
27069
+ name: spec.name ?? "Diagram",
27070
+ ...spec.x !== undefined ? { x: spec.x } : {},
27071
+ ...spec.y !== undefined ? { y: spec.y } : {},
27072
+ w: Math.round(lay.width + pad * 2),
27073
+ h: Math.round(lay.height + pad * 2 + head),
27074
+ children
27075
+ };
27076
+ }
27077
+ var SHAPES, unquote = (s) => s.trim().replace(/^"(.*)"$/s, "$1").replace(/<br\s*\/?>/gi, `
27078
+ `), EDGE;
27079
+ var init_diagram = __esm(() => {
27080
+ SHAPES = [
27081
+ [/^\(\((.*)\)\)$/s, "ellipse"],
27082
+ [/^\(\[(.*)\]\)$/s, "rounded"],
27083
+ [/^\[\[(.*)\]\]$/s, "process"],
27084
+ [/^\[\((.*)\)\]$/s, "database"],
27085
+ [/^\{\{(.*)\}\}$/s, "hexagon"],
27086
+ [/^\[\/(.*)\/\]$/s, "parallelogram"],
27087
+ [/^\[\\(.*)\\\]$/s, "parallelogramleft"],
27088
+ [/^\[\/(.*)\\\]$/s, "trapezoid"],
27089
+ [/^>(.*)\]$/s, "chevron"],
27090
+ [/^\((.*)\)$/s, "rounded"],
27091
+ [/^\{(.*)\}$/s, "diamond"],
27092
+ [/^\[(.*)\]$/s, "square"]
27093
+ ];
27094
+ EDGE = /\s*(<?-{2,}>|<?-\.+->|<?={2,}>|-{3,}|={3,}|-\.+-|--\s+[^-]+?\s+-->|==\s+[^=]+?\s+==>|-\.\s+[^.]+?\s+\.->)\s*(?:\|([^|]*)\|)?\s*/;
27095
+ });
27096
+
27097
+ // src/guides.ts
27098
+ function registerGuides(server, docs) {
27099
+ for (const p of PROMPTS) {
27100
+ const argsSchema = {};
27101
+ for (const [k, d] of Object.entries(p.args))
27102
+ argsSchema[k] = string2().optional().describe(d);
27103
+ server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema }, (args) => text(p.render(args ?? {})));
27104
+ }
27105
+ server.registerResource("build-spec", RESOURCES.build, { title: "build spec reference", description: "Every node type and property the build tool accepts, with examples.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: buildSpecMarkdown(docs.buildSpec) }] }));
27106
+ server.registerResource("workflow", RESOURCES.workflow, { title: "Figma Bridge workflow", description: "How to work with Figma Bridge: the workflow, rules and every tool.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: workflowMarkdown(docs.instructions, docs.tools()) }] }));
27107
+ }
27108
+ function buildSpecMarkdown(spec) {
27109
+ const lines = spec.split(`
27110
+ `);
27111
+ const out = ["# build spec reference", "", lines[0], ""];
27112
+ for (const line of lines.slice(1)) {
27113
+ const m = /^([A-Z]{2,}(?: [A-Z]{2,})*)((?: \([^)]*\))?(?: files)?):\s*(.*)$/.exec(line);
27114
+ if (m && !/^Example/.test(line)) {
27115
+ const title = m[1] + m[2];
27116
+ out.push(`## ${title[0]}${title.slice(1).toLowerCase()}`, "", m[3], "");
27117
+ } else if (/^Example:/.test(line))
27118
+ out.push("## Example", "", "```json", line.replace(/^Example:\s*/, ""), "```", "");
27119
+ else
27120
+ out.push(line, "");
27121
+ }
27122
+ return out.join(`
27123
+ `).replace(/\n{3,}/g, `
27124
+
27125
+ `);
27126
+ }
27127
+ function workflowMarkdown(instructions, tools) {
27128
+ const first = (d) => (/^(.*?[.!?])(\s|$)/s.exec(d.replace(/\s+/g, " "))?.[1] ?? d).slice(0, 220);
27129
+ return [
27130
+ "# Working with Figma Bridge",
27131
+ "",
27132
+ ...instructions.map((l) => /^\d\./.test(l) ? l : `- ${l}`),
27133
+ "",
27134
+ "## Tools",
27135
+ "",
27136
+ ...tools.map((t) => `- **${t.name}**${t.title ? ` (${t.title})` : ""}: ${first(t.description)}`),
27137
+ "",
27138
+ "## Prompts",
27139
+ "",
27140
+ ...PROMPTS.map((p) => `- **${p.name}**: ${p.description}`),
27141
+ "",
27142
+ `Full build spec: ${RESOURCES.build}`,
27143
+ ""
27144
+ ].join(`
27145
+ `);
27146
+ }
27147
+ var RESOURCES, text = (t) => ({ messages: [{ role: "user", content: { type: "text", text: t.trim() } }] }), opt = (v, fallback) => v && v.trim() ? v.trim() : fallback, PROMPTS;
27148
+ var init_guides = __esm(() => {
27149
+ init_zod();
27150
+ RESOURCES = {
27151
+ build: "figma-bridge://docs/build-spec",
27152
+ workflow: "figma-bridge://docs/workflow"
27153
+ };
27154
+ PROMPTS = [
27155
+ {
27156
+ name: "new-screen",
27157
+ title: "Design a new screen",
27158
+ description: "Design a screen from a description, reusing the file's design system.",
27159
+ args: { description: "What the screen is for and what it contains", platform: "desktop (1440), tablet (834) or mobile (390); default desktop" },
27160
+ render: (a) => `
27161
+ Design a new ${opt(a.platform, "desktop")} screen in the connected Figma file: ${opt(a.description, "(ask me what the screen is for)")}.
27162
+ 1. get_context, then get_design_system. Reuse its color and text styles, variables and components (style:, var:, {component}) instead of raw values.
27163
+ 2. Build it with build: one call per section (header, hero, content, footer…), auto-layout everywhere, frames named after what they are, width ${a.platform === "mobile" ? 390 : a.platform === "tablet" ? 834 : 1440}.
27164
+ 3. screenshot {returnImage:true} the result and look at it critically: spacing rhythm, alignment, hierarchy, contrast.
27165
+ 4. audit the frame and fix every error and warning.
27166
+ 5. Tell me what you built and what you would improve next.`
27167
+ },
27168
+ {
27169
+ name: "apply-design-system",
27170
+ title: "Apply the design system",
27171
+ description: "Make a screen use the file's variables, styles and components.",
27172
+ args: { target: "Layer name or id (default: the selection)" },
27173
+ render: (a) => `
27174
+ Apply the file's design system to ${opt(a.target, "the selected layers (use wait_for_selection if nothing is selected)")}.
27175
+ 1. get_design_system to see the variables, styles and components available, and describe the target.
27176
+ 2. checkpoint {action:"save"} on the target first.
27177
+ 3. audit {fix:true} on it: raw colors and numbers get bound to matching variables, matching text styles are applied, default layer names are fixed.
27178
+ 4. For what remains (near-miss colors, text without a matching style, frames that should be component instances), fix it with build or run_script: prefer replacing ad-hoc elements by instances of the existing components.
27179
+ 5. audit {scope:"design-system", nodeId} before and after, and report the score change and anything you could not map.`
27180
+ },
27181
+ {
27182
+ name: "reproduce-screenshot",
27183
+ title: "Reproduce a screenshot",
27184
+ description: "Rebuild a mockup or screenshot as an editable, auto-layout frame and check it pixel by pixel.",
27185
+ args: { image: "Local path or URL of the image", width: "Width of the frame in px (default: the image's width)" },
27186
+ render: (a) => `
27187
+ Reproduce ${opt(a.image, "the image I give you")} as an editable Figma frame${a.width ? ` ${a.width} px wide` : ""}.
27188
+ 1. Look at the image carefully. get_design_system and reuse what matches.
27189
+ 2. build it at the image's size, with auto-layout and real text (not images of text). Icons: search_icons, then {icon:"set:name"}.
27190
+ 3. compare {nodeId, reference:"${opt(a.image, "<image>")}", returnImage:true}. Fix the largest regions first (layout and size before color and detail), then compare again.
27191
+ 4. Stop when mismatchPercent no longer drops; report the final number and what still differs.`
27192
+ },
27193
+ {
27194
+ name: "import-website",
27195
+ title: "Import a website",
27196
+ description: "Turn a web page into editable Figma frames at several widths, then clean them up.",
27197
+ args: { url: "The page URL, or a local .html path", viewports: "Comma-separated widths, default 1440,390" },
27198
+ render: (a) => {
27199
+ const viewports = opt(a.viewports, "1440,390").split(",").map((v) => Number(v.trim())).filter((n) => n > 0);
27200
+ return `
27201
+ Import ${opt(a.url, "(ask me for the URL)")} into the connected Figma file.
27202
+ 1. import_web {${/^https?:/.test(a.url ?? "") ? "url" : "path"}:"${opt(a.url, "<url>")}", viewports:${JSON.stringify(viewports)}}.
27203
+ 2. For each frame, compare {nodeId: rootId, reference} and look at the regions with the largest mismatch: fix layout or spacing problems with run_script or build.
27204
+ 3. Report fontSubstitutions: suggest installing the missing fonts or switching to the file's fonts.
27205
+ 4. Rename layers that are still generic (Row, Column, Frame) after what they contain, and turn repeated cards or buttons into components if it helps.`;
27206
+ }
27207
+ },
27208
+ {
27209
+ name: "figma-to-code",
27210
+ title: "Figma to code",
27211
+ description: "Export a frame as code that uses the project's own components and tokens.",
27212
+ args: { projectPath: "Root folder of the app (where package.json is)", target: "Layer name or id (default: the selection)" },
27213
+ render: (a) => `
27214
+ Turn ${opt(a.target, "the selected frame")} into code for the project at ${opt(a.projectPath, "(ask me for the project folder)")}.
27215
+ 1. export_code {projectPath, nodeId} without writing. Read the plan: the detected stack, the components used and the unmatched instances, the token sources.
27216
+ 2. If important instances are unmatched, add figma-bridge.map.json entries for them (Figma component → code component and props) and export again.
27217
+ 3. Review the code like a teammate would: semantics, accessibility (labels, alt text, buttons vs links), responsive behaviour. Fix names in Figma if class or component names are poor.
27218
+ 4. When it looks right, export_code with write:true, then run the project's type-check or linter and fix what they report.`
27219
+ },
27220
+ {
27221
+ name: "figma-to-roblox",
27222
+ title: "Figma to Roblox",
27223
+ description: "Turn a frame into Roblox UI and place it in Studio with a Roblox Studio MCP.",
27224
+ args: { target: "Layer name or id (default: the selection)", mode: "scale, offset or hybrid (default hybrid)" },
27225
+ render: (a) => `
27226
+ Turn ${opt(a.target, "the selected frame")} into Roblox UI that looks the same, and put it in the open Roblox Studio place.
27227
+ 1. export_roblox {${a.target ? `nodeId:"<id of ${a.target}>", ` : ""}mode:"${opt(a.mode, "hybrid")}"}. Read the warnings (approximations, font substitutions).
27228
+ 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.
27229
+ 3. Run the Luau with execute_luau: it creates the ScreenGui under StarterGui (replacing an earlier copy) and returns it.
27230
+ 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).
27231
+ 5. Report the result and anything Roblox could not reproduce.`
27232
+ },
27233
+ {
27234
+ name: "audit-and-fix",
27235
+ title: "Audit and fix",
27236
+ description: "Score the design system's health, apply safe fixes, and report what changed.",
27237
+ args: { target: "Layer name or id to limit the fixes (default: the current page)" },
27238
+ render: (a) => `
27239
+ Audit and improve this file${a.target ? ` (fixes limited to ${a.target})` : ""}.
27240
+ 1. audit {scope:"design-system"} and summarize the score per category with the most important issues.
27241
+ 2. checkpoint {action:"save"} on what you are going to change.
27242
+ 3. audit {fix:true${a.target ? `, nodeId:"<id of ${a.target}>"` : ""}} and read the changes.
27243
+ 4. Fix the remaining contrast errors and text without styles by hand where the right choice is clear; list the ones that need a design decision.
27244
+ 5. audit {scope:"design-system"} again and report the score before and after.`
27245
+ }
27246
+ ];
27247
+ });
27248
+
27249
+ // src/roblox/fonts.ts
27250
+ function robloxFamily(family, overrides = {}) {
27251
+ const key = family.trim().toLowerCase();
27252
+ const custom = Object.entries(overrides).find(([k]) => k.trim().toLowerCase() === key)?.[1];
27253
+ const name = custom ?? ROBLOX_FONTS[key] ?? DEFAULT_FONT;
27254
+ const url = /^rbxasset(id)?:\/\//.test(name) ? name : `rbxasset://fonts/families/${name.replace(/\s+/g, "")}.json`;
27255
+ const exact = custom !== undefined || name.toLowerCase() === key.replace(/\s+/g, "") || key === "gotham" && name === "GothamSSm";
27256
+ return { url, substituted: !exact };
27257
+ }
27258
+ function robloxWeight(weight) {
27259
+ let best = WEIGHTS2[3];
27260
+ for (const w of WEIGHTS2)
27261
+ if (Math.abs(w[0] - weight) < Math.abs(best[0] - weight))
27262
+ best = w;
27263
+ return { value: best[0], name: best[1] };
27264
+ }
27265
+ var ROBLOX_FONTS, DEFAULT_FONT = "BuilderSans", WEIGHTS2;
27266
+ var init_fonts = __esm(() => {
27267
+ ROBLOX_FONTS = {
27268
+ "builder sans": "BuilderSans",
27269
+ montserrat: "Montserrat",
27270
+ roboto: "Roboto",
27271
+ "roboto condensed": "RobotoCondensed",
27272
+ "roboto mono": "RobotoMono",
27273
+ "source sans pro": "SourceSansPro",
27274
+ "source sans 3": "SourceSansPro",
27275
+ nunito: "Nunito",
27276
+ oswald: "Oswald",
27277
+ merriweather: "Merriweather",
27278
+ ubuntu: "Ubuntu",
27279
+ "titillium web": "TitilliumWeb",
27280
+ "press start 2p": "PressStart2P",
27281
+ "fredoka one": "FredokaOne",
27282
+ fredoka: "FredokaOne",
27283
+ bangers: "Bangers",
27284
+ creepster: "Creepster",
27285
+ "luckiest guy": "LuckiestGuy",
27286
+ "permanent marker": "PermanentMarker",
27287
+ "patrick hand": "PatrickHand",
27288
+ "indie flower": "IndieFlower",
27289
+ "amatic sc": "AmaticSC",
27290
+ kalam: "Kalam",
27291
+ michroma: "Michroma",
27292
+ jura: "Jura",
27293
+ sarpanch: "Sarpanch",
27294
+ "special elite": "SpecialElite",
27295
+ "denk one": "DenkOne",
27296
+ inconsolata: "Inconsolata",
27297
+ arimo: "Arimo",
27298
+ gotham: "GothamSSm",
27299
+ "gotham ssm": "GothamSSm",
27300
+ inter: "BuilderSans",
27301
+ "sf pro": "BuilderSans",
27302
+ "sf pro text": "BuilderSans",
27303
+ "sf pro display": "BuilderSans",
27304
+ "open sans": "BuilderSans",
27305
+ lato: "BuilderSans",
27306
+ poppins: "Montserrat",
27307
+ "dm sans": "BuilderSans",
27308
+ manrope: "BuilderSans",
27309
+ "plus jakarta sans": "BuilderSans",
27310
+ "work sans": "BuilderSans",
27311
+ "ibm plex sans": "SourceSansPro",
27312
+ arial: "Arimo",
27313
+ helvetica: "Arimo",
27314
+ "helvetica neue": "Arimo",
27315
+ "segoe ui": "BuilderSans",
27316
+ "jetbrains mono": "RobotoMono",
27317
+ "fira code": "RobotoMono",
27318
+ "source code pro": "RobotoMono",
27319
+ "sf mono": "RobotoMono",
27320
+ "courier new": "RobotoMono",
27321
+ georgia: "Merriweather",
27322
+ "times new roman": "Merriweather",
27323
+ "playfair display": "Merriweather",
27324
+ "bebas neue": "Oswald",
27325
+ anton: "Oswald",
27326
+ "comic sans ms": "PatrickHand"
27327
+ };
27328
+ WEIGHTS2 = [
27329
+ [100, "Thin"],
27330
+ [200, "ExtraLight"],
27331
+ [300, "Light"],
27332
+ [400, "Regular"],
27333
+ [500, "Medium"],
27334
+ [600, "SemiBold"],
27335
+ [700, "Bold"],
27336
+ [800, "ExtraBold"],
27337
+ [900, "Heavy"]
27338
+ ];
27339
+ });
27340
+
27341
+ // src/roblox/map.ts
27342
+ function color3(hex) {
27343
+ const h = hex.replace("#", "");
27344
+ const c = (i) => num(parseInt(h.slice(i, i + 2), 16) / 255);
27345
+ return { r: c(0), g: c(2), b: c(4) };
27346
+ }
27347
+ function visible(list) {
27348
+ return (list ?? []).filter((p) => p.opacity > 0);
27349
+ }
27350
+ function decide(n, opts) {
27351
+ const approximations = [];
27352
+ if (n.text)
27353
+ return { kind: "text", shadow: false, approximations };
27354
+ const raster = opts.rasterize;
27355
+ const fills = visible(n.fills);
27356
+ const strokes = visible(n.strokes);
27357
+ const effects = n.effects ?? [];
27358
+ const drop = effects.filter((e) => e.type === "DROP_SHADOW");
27359
+ const other = effects.filter((e) => e.type !== "DROP_SHADOW");
27360
+ const hasChildren = !!n.children?.some((c) => !c.hidden);
27361
+ if (n.vector || ["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON", "LINE"].includes(n.type)) {
27362
+ return raster === "none" ? { kind: "frame", shadow: false, approximations: ["vector shape skipped (rasterize is none)"] } : { kind: "picture", shadow: false, approximations };
27363
+ }
27364
+ const image = fills.length === 1 && fills[0].type === "IMAGE";
27365
+ if (image && !hasChildren && !strokes.length && !effects.length && raster !== "all")
27366
+ return { kind: "image", shadow: false, approximations };
27367
+ const reasons = [];
27368
+ if (fills.length > 1)
27369
+ reasons.push("several fills");
27370
+ if (fills.length === 1 && !["SOLID", "GRADIENT_LINEAR", "IMAGE"].includes(fills[0].type))
27371
+ reasons.push(`${fills[0].type.toLowerCase().replace("_", " ")} fill`);
27372
+ if (image && (strokes.length || effects.length))
27373
+ reasons.push("image with stroke or effects");
27374
+ if (strokes.length > 1 || strokes[0] && strokes[0].type !== "SOLID")
27375
+ reasons.push("complex stroke");
27376
+ if (n.dashed)
27377
+ reasons.push("dashed stroke");
27378
+ if (other.length)
27379
+ reasons.push(other.map((e) => e.type.toLowerCase().replace(/_/g, " ")).join(", "));
27380
+ if (n.type === "ELLIPSE" && Math.abs(n.w - n.h) > 0.5)
27381
+ reasons.push("oval");
27382
+ const styled = fills.length + strokes.length + effects.length > 0;
27383
+ if ((reasons.length || raster === "all" && styled) && raster !== "none") {
27384
+ return { kind: hasChildren ? "panel" : "picture", shadow: false, approximations };
27385
+ }
27386
+ if (reasons.length)
27387
+ approximations.push(`${reasons.join(", ")}: approximated (rasterize is none)`);
27388
+ const r = n.radius ?? [];
27389
+ if (r.length && r.some((v) => Math.abs(v - r[0]) > 0.5))
27390
+ approximations.push("different corner radii: used the largest");
27391
+ if (drop.length && raster === "none")
27392
+ approximations.push("drop shadow skipped (rasterize is none)");
27393
+ return { kind: "frame", shadow: drop.length > 0 && raster !== "none", approximations };
27394
+ }
27395
+ function pictureRequests(root, opts) {
27396
+ const out = [];
27397
+ const walk = (n) => {
27398
+ if (n.hidden)
27399
+ return;
27400
+ const d = decide(n, opts);
27401
+ if (d.kind === "picture")
27402
+ out.push({ id: n.id, mode: "full" });
27403
+ if (d.kind === "panel")
27404
+ out.push({ id: n.id, mode: "panel" });
27405
+ if (d.shadow)
27406
+ out.push({ id: n.id, mode: "shadow" });
27407
+ if (d.kind !== "picture")
27408
+ for (const c of n.children ?? [])
27409
+ walk(c);
27410
+ };
27411
+ walk(root);
27412
+ return out;
27413
+ }
27414
+ function imageHashes(root) {
27415
+ const out = new Set;
27416
+ const walk = (n) => {
27417
+ for (const f of n.fills ?? [])
27418
+ if (f.imageHash)
27419
+ out.add(f.imageHash);
27420
+ for (const c of n.children ?? [])
27421
+ walk(c);
27422
+ };
27423
+ walk(root);
27424
+ return [...out];
27425
+ }
27426
+
27427
+ class Mapper {
27428
+ opts;
27429
+ assets;
27430
+ warnings = new Set;
27431
+ fontSubstitutions = new Map;
27432
+ counts = { instances: 0, pictures: 0, skippedHidden: 0 };
27433
+ textFactor = 1;
27434
+ constructor(opts, assets) {
27435
+ this.opts = opts;
27436
+ this.assets = assets;
27437
+ }
27438
+ warn(n, msg) {
27439
+ if (this.warnings.size < 60)
27440
+ this.warnings.add(n ? `${n.name}: ${msg}` : msg);
27441
+ }
27442
+ map(root) {
27443
+ const [tw, th] = this.opts.targetResolution;
27444
+ const fullScreen = Math.abs(root.w / root.h - tw / th) < 0.1 * (tw / th) && root.w >= tw * 0.4;
27445
+ if (fullScreen && this.opts.mode !== "offset")
27446
+ this.textFactor = tw / root.w;
27447
+ const top = this.node(root, null, 0);
27448
+ 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)]];
27449
+ top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex"].includes(k));
27450
+ top.props.splice(1, 0, ...geo);
27451
+ top.children.push(this.inst("UIAspectRatioConstraint", "AspectRatio", [["AspectRatio", F(root.w / root.h)]]));
27452
+ if (this.opts.asRootFrame)
27453
+ return top;
27454
+ return this.inst("ScreenGui", root.name, [["ResetOnSpawn", B(false)], ["ZIndexBehavior", E("ZIndexBehavior", "Sibling")], ["IgnoreGuiInset", B(true)]], [top]);
27455
+ }
27456
+ inst(className, name, props = [], children = [], source) {
27457
+ this.counts.instances++;
27458
+ return { className, name, props: [["Name", S(name)], ...props], children, ...source ? { source } : {} };
27459
+ }
27460
+ geometry(n, parent, index, d) {
27461
+ const out = [];
27462
+ if (!parent)
27463
+ return out;
27464
+ const inFlow = !!parent.layout && !n.absolute;
27465
+ const useScale = this.opts.mode === "scale" || this.opts.mode === "hybrid" && !inFlow;
27466
+ const hugW = n.sizing?.h === "HUG" && (d.kind === "text" || !!n.layout);
27467
+ const hugH = n.sizing?.v === "HUG" && (d.kind === "text" || !!n.layout);
27468
+ if (inFlow) {
27469
+ const l = parent.layout;
27470
+ const horizontal = l.mode === "HORIZONTAL";
27471
+ const pw = parent.w - (l.padding[1] ?? 0) - (l.padding[3] ?? 0);
27472
+ const ph = parent.h - (l.padding[0] ?? 0) - (l.padding[2] ?? 0);
27473
+ const crossFill = horizontal ? n.sizing?.v === "FILL" || n.stretch : n.sizing?.h === "FILL" || n.stretch;
27474
+ 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);
27475
+ 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);
27476
+ out.push(["Size", { t: "UDim2", xs: w.s, xo: w.o, ys: h.s, yo: h.o }]);
27477
+ out.push(["LayoutOrder", I(index + 1)]);
27478
+ return out;
27479
+ }
27480
+ const c = n.constraints ?? { h: "MIN", v: "MIN" };
27481
+ const axis = (pos, size, total, k) => {
27482
+ const end = total - pos - size;
27483
+ if (k === "SCALE" || useScale) {
27484
+ if (k === "MAX")
27485
+ return { anchor: 1, p: [(pos + size) / total, 0], s: [size / total, 0] };
27486
+ if (k === "CENTER")
27487
+ return { anchor: 0.5, p: [(pos + size / 2) / total, 0], s: [size / total, 0] };
27488
+ return { anchor: 0, p: [pos / total, 0], s: [size / total, 0] };
27489
+ }
27490
+ if (k === "MAX")
27491
+ return { anchor: 1, p: [1, -end], s: [0, size] };
27492
+ if (k === "CENTER")
27493
+ return { anchor: 0.5, p: [0.5, pos + size / 2 - total / 2], s: [0, size] };
27494
+ if (k === "STRETCH")
27495
+ return { anchor: 0, p: [0, pos], s: [1, -(pos + end)] };
27496
+ return { anchor: 0, p: [0, pos], s: [0, size] };
27497
+ };
27498
+ const x = axis(n.x, n.w, Math.max(1, parent.w), c.h);
27499
+ const y = axis(n.y, n.h, Math.max(1, parent.h), c.v);
27500
+ if (x.anchor || y.anchor)
27501
+ out.push(["AnchorPoint", V2(x.anchor, y.anchor)]);
27502
+ out.push(["Position", U2(x.p[0], x.p[1], y.p[0], y.p[1])]);
27503
+ out.push(["Size", U2(hugW ? 0 : x.s[0], hugW ? 0 : x.s[1], hugH ? 0 : y.s[0], hugH ? 0 : y.s[1])]);
27504
+ out.push(["ZIndex", I(index + 1)]);
27505
+ return out;
27506
+ }
27507
+ automaticSize(n, d) {
27508
+ const hugW = n.sizing?.h === "HUG" && (d.kind === "text" || !!n.layout);
27509
+ const hugH = n.sizing?.v === "HUG" && (d.kind === "text" || !!n.layout);
27510
+ if (!hugW && !hugH)
27511
+ return [];
27512
+ return [["AutomaticSize", E("AutomaticSize", hugW && hugH ? "XY" : hugW ? "X" : "Y")]];
27513
+ }
27514
+ corner(n) {
27515
+ const r = Math.max(0, ...n.radius ?? []);
27516
+ if (!r && !(n.type === "ELLIPSE"))
27517
+ return null;
27518
+ const pill = n.type === "ELLIPSE" || r >= Math.min(n.w, n.h) / 2 - 0.5;
27519
+ return this.inst("UICorner", "UICorner", [["CornerRadius", pill ? U(0.5, 0) : U(0, r)]]);
27520
+ }
27521
+ stroke(n) {
27522
+ const s = visible(n.strokes)[0];
27523
+ if (!s || s.type !== "SOLID" || !n.strokeWeight)
27524
+ return null;
27525
+ return this.inst("UIStroke", "UIStroke", [
27526
+ ["ApplyStrokeMode", E("ApplyStrokeMode", "Border")],
27527
+ ["Color", C3(s.color)],
27528
+ ["Thickness", F(n.strokeWeight)],
27529
+ ["Transparency", F(1 - s.opacity)],
27530
+ ["LineJoinMode", E("LineJoinMode", "Round")]
27531
+ ]);
27532
+ }
27533
+ gradient(p) {
27534
+ let stops = [...p.stops ?? []].sort((a, b) => a.at - b.at);
27535
+ if (stops.length > 20) {
27536
+ this.warn(null, "a gradient has more than 20 stops: kept 20");
27537
+ stops = stops.filter((_, i) => i % Math.ceil(stops.length / 20) === 0);
27538
+ }
27539
+ if (stops[0].at > 0)
27540
+ stops.unshift({ ...stops[0], at: 0 });
27541
+ if (stops[stops.length - 1].at < 1)
27542
+ stops.push({ ...stops[stops.length - 1], at: 1 });
27543
+ stops[0] = { ...stops[0], at: 0 };
27544
+ stops[stops.length - 1] = { ...stops[stops.length - 1], at: 1 };
27545
+ return this.inst("UIGradient", "UIGradient", [
27546
+ ["Color", { t: "ColorSequence", keys: stops.map((s) => ({ time: num(s.at), ...color3(s.color) })) }],
27547
+ ["Transparency", { t: "NumberSequence", keys: stops.map((s) => ({ time: num(s.at), value: num(1 - s.alpha * p.opacity) })) }],
27548
+ ["Rotation", F(p.angle ?? 0)]
27549
+ ]);
27550
+ }
27551
+ layoutChildren(n) {
27552
+ const l = n.layout;
27553
+ const out = [];
27554
+ const [t, r, b, lft] = l.padding;
27555
+ if (t || r || b || lft)
27556
+ out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop", U(0, t)], ["PaddingRight", U(0, r)], ["PaddingBottom", U(0, b)], ["PaddingLeft", U(0, lft)]]));
27557
+ if (l.mode === "GRID") {
27558
+ const kids = (n.children ?? []).filter((c) => !c.hidden && !c.absolute);
27559
+ const first = kids[0];
27560
+ if (kids.some((k) => Math.abs(k.w - (first?.w ?? 0)) > 1 || Math.abs(k.h - (first?.h ?? 0)) > 1))
27561
+ this.warn(n, "grid cells of different sizes: UIGridLayout uses the first cell's size");
27562
+ out.push(this.inst("UIGridLayout", "UIGridLayout", [
27563
+ ["CellSize", U2(0, first?.w ?? 100, 0, first?.h ?? 100)],
27564
+ ["CellPadding", U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
27565
+ ["FillDirectionMaxCells", I(l.columns ?? 0)],
27566
+ ["SortOrder", E("SortOrder", "LayoutOrder")]
27567
+ ]));
27568
+ return out;
27569
+ }
27570
+ const horizontal = l.mode === "HORIZONTAL";
27571
+ const main = l.primary === "CENTER" ? "Center" : l.primary === "MAX" ? horizontal ? "Right" : "Bottom" : horizontal ? "Left" : "Top";
27572
+ const cross = l.counter === "CENTER" ? "Center" : l.counter === "MAX" ? horizontal ? "Bottom" : "Right" : horizontal ? "Top" : "Left";
27573
+ const props = [
27574
+ ["FillDirection", E("FillDirection", horizontal ? "Horizontal" : "Vertical")],
27575
+ ["SortOrder", E("SortOrder", "LayoutOrder")],
27576
+ ["Padding", U(0, l.primary === "SPACE_BETWEEN" ? 0 : l.gap)],
27577
+ ["HorizontalAlignment", E("HorizontalAlignment", horizontal ? main === "Right" ? "Right" : main === "Center" ? "Center" : "Left" : cross === "Right" ? "Right" : cross === "Center" ? "Center" : "Left")],
27578
+ ["VerticalAlignment", E("VerticalAlignment", horizontal ? cross === "Bottom" ? "Bottom" : cross === "Center" ? "Center" : "Top" : main === "Bottom" ? "Bottom" : main === "Center" ? "Center" : "Top")]
27579
+ ];
27580
+ if (l.primary === "SPACE_BETWEEN")
27581
+ props.push([horizontal ? "HorizontalFlex" : "VerticalFlex", E("UIFlexAlignment", "SpaceBetween")]);
27582
+ if (l.wrap)
27583
+ props.push(["Wraps", B(true)]);
27584
+ if (l.counter === "BASELINE")
27585
+ this.warn(n, "baseline alignment: aligned to the top");
27586
+ out.push(this.inst("UIListLayout", "UIListLayout", props));
27587
+ return out;
27588
+ }
27589
+ assetUrl(key, n) {
27590
+ const a = this.assets(key);
27591
+ if (!a) {
27592
+ this.warn(n, "picture missing: left empty");
27593
+ return { url: "" };
27594
+ }
27595
+ return a;
27596
+ }
27597
+ textProps(n) {
27598
+ const t = n.text;
27599
+ const segs = t.segments.length ? t.segments : [{ text: t.characters, family: "Inter", style: "Regular", weight: 400, size: 14 }];
27600
+ const base = segs.reduce((a, s) => s.text.length > a.text.length ? s : a, segs[0]);
27601
+ const family = robloxFamily(base.family, this.opts.fonts);
27602
+ if (family.substituted)
27603
+ this.fontSubstitutions.set(base.family, family.url.replace(/^.*\/(\w+)\.json$/, "$1"));
27604
+ const weight = robloxWeight(base.weight);
27605
+ const italic = /italic|oblique/i.test(base.style);
27606
+ const size = Math.max(1, Math.round(base.size * this.textFactor));
27607
+ const props = [
27608
+ ["BackgroundTransparency", F(1)],
27609
+ ["FontFace", { t: "Font", family: family.url, weight: weight.value, weightName: weight.name, style: italic ? "Italic" : "Normal" }],
27610
+ ["TextSize", I(size)],
27611
+ ["TextColor3", C3(base.color ?? "#000000")]
27612
+ ];
27613
+ const alpha = (base.alpha ?? 1) * (n.opacity ?? 1);
27614
+ if (alpha < 1)
27615
+ props.push(["TextTransparency", F(1 - alpha)]);
27616
+ const rich = segs.length > 1 && segs.some((s) => s.color !== base.color || s.weight !== base.weight || s.size !== base.size || s.family !== base.family || s.decoration || s.textCase || /italic/i.test(s.style) !== italic);
27617
+ const text = segs.map((s) => s.text).join("");
27618
+ if (rich) {
27619
+ props.push(["RichText", B(true)], ["Text", S(segs.map((s) => this.richRun(s, base)).join(""))]);
27620
+ } else {
27621
+ let value = text;
27622
+ if (base.textCase === "UPPER")
27623
+ value = value.toUpperCase();
27624
+ else if (base.textCase === "LOWER")
27625
+ value = value.toLowerCase();
27626
+ if (base.decoration)
27627
+ props.push(["RichText", B(true)], ["Text", S(this.richRun({ ...base, text: value, textCase: undefined }, { ...base, decoration: undefined }))]);
27628
+ else
27629
+ props.push(["Text", S(value)]);
27630
+ }
27631
+ props.push(["TextXAlignment", E("TextXAlignment", t.align === "CENTER" ? "Center" : t.align === "RIGHT" ? "Right" : "Left")]);
27632
+ props.push(["TextYAlignment", E("TextYAlignment", t.valign === "CENTER" ? "Center" : t.valign === "BOTTOM" ? "Bottom" : "Top")]);
27633
+ if (t.autoResize !== "WIDTH_AND_HEIGHT")
27634
+ props.push(["TextWrapped", B(true)]);
27635
+ if (base.lineHeight)
27636
+ props.push(["LineHeight", F(Math.max(1, Math.min(3, base.lineHeight / (base.size * 1.2))))]);
27637
+ if (t.maxLines)
27638
+ props.push(["TextTruncate", E("TextTruncate", "AtEnd")]);
27639
+ if (this.opts.textScaled)
27640
+ props.push(["TextScaled", B(true)]);
27641
+ if (segs.some((s) => s.letterSpacing))
27642
+ this.warn(n, "letter spacing has no Roblox equivalent: ignored");
27643
+ return props;
27644
+ }
27645
+ richRun(s, base) {
27646
+ let text = s.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
27647
+ const attrs = [];
27648
+ if (s.color && s.color !== base.color)
27649
+ attrs.push(`color="${s.color}"`);
27650
+ if (s.size !== base.size)
27651
+ attrs.push(`size="${Math.round(s.size * this.textFactor)}"`);
27652
+ if (s.weight !== base.weight)
27653
+ attrs.push(`weight="${robloxWeight(s.weight).value}"`);
27654
+ if (s.family !== base.family)
27655
+ attrs.push(`family="${robloxFamily(s.family, this.opts.fonts).url}"`);
27656
+ if (s.alpha !== undefined && s.alpha < 1)
27657
+ attrs.push(`transparency="${num(1 - s.alpha)}"`);
27658
+ if (attrs.length)
27659
+ text = `<font ${attrs.join(" ")}>${text}</font>`;
27660
+ if (/italic/i.test(s.style) && !/italic/i.test(base.style))
27661
+ text = `<i>${text}</i>`;
27662
+ if (s.decoration === "UNDERLINE")
27663
+ text = `<u>${text}</u>`;
27664
+ if (s.decoration === "STRIKETHROUGH")
27665
+ text = `<s>${text}</s>`;
27666
+ if (s.textCase === "UPPER")
27667
+ text = `<uc>${text}</uc>`;
27668
+ if (s.textCase === "SMALL_CAPS" || s.textCase === "SMALL_CAPS_FORCED")
27669
+ text = `<sc>${text}</sc>`;
27670
+ return text;
27671
+ }
27672
+ node(n, parent, index) {
27673
+ if (n.hidden) {
27674
+ this.counts.skippedHidden++;
27675
+ return null;
27676
+ }
27677
+ const d = decide(n, this.opts);
27678
+ for (const a of d.approximations)
27679
+ this.warn(n, a);
27680
+ const button = !!parent && (n.clickable || BUTTON_NAME.test(n.name)) && d.kind !== "text";
27681
+ const geo = this.geometry(n, parent, index, d);
27682
+ const common = [...geo, ...this.automaticSize(n, d)];
27683
+ if (n.rotation)
27684
+ common.push(["Rotation", F(n.rotation)]);
27685
+ const children = [];
27686
+ let className = "Frame";
27687
+ let props = [];
27688
+ if (d.kind === "text") {
27689
+ className = n.clickable || BUTTON_NAME.test(n.name) ? "TextButton" : "TextLabel";
27690
+ props = this.textProps(n);
27691
+ if (className === "TextButton")
27692
+ props.push(["AutoButtonColor", B(false)]);
27693
+ const size = props.find(([k]) => k === "TextSize")[1].v;
27694
+ children.push(this.inst("UITextSizeConstraint", "UITextSizeConstraint", [["MaxTextSize", I(size)], ["MinTextSize", I(Math.max(1, Math.round(size * 0.5)))]]));
27695
+ return this.inst(className, n.name, [...common, ...props], children, n.id);
27696
+ }
27697
+ if (d.kind === "picture") {
27698
+ this.counts.pictures++;
27699
+ const a = this.assetUrl(`${n.id}:full`, n);
27700
+ className = button ? "ImageButton" : "ImageLabel";
27701
+ const pic = this.pictureGeometry(n, a, geo);
27702
+ props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", "Stretch")]];
27703
+ if (n.opacity !== undefined && n.opacity < 1)
27704
+ props.push(["ImageTransparency", F(1 - n.opacity)]);
27705
+ if (button)
27706
+ props.push(["AutoButtonColor", B(false)]);
27707
+ children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F((a.size?.w ?? n.w) / Math.max(1, a.size?.h ?? n.h))]]));
27708
+ return this.inst(className, n.name, [...pic, ...props], children, n.id);
27709
+ }
27710
+ const fills = visible(n.fills);
27711
+ const opacity = n.opacity ?? 1;
27712
+ const hasKids = !!n.children?.some((c) => !c.hidden);
27713
+ if (d.kind === "image") {
27714
+ const f = fills[0];
27715
+ const a = this.assetUrl(`image:${f.imageHash}`, n);
27716
+ className = button ? "ImageButton" : "ImageLabel";
27717
+ const scaleType = f.scaleMode === "FIT" ? "Fit" : f.scaleMode === "TILE" ? "Tile" : "Crop";
27718
+ props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", scaleType)]];
27719
+ if (f.opacity * opacity < 1)
27720
+ props.push(["ImageTransparency", F(1 - f.opacity * opacity)]);
27721
+ if (button)
27722
+ props.push(["AutoButtonColor", B(false)]);
27723
+ const corner = this.corner(n);
27724
+ if (corner)
27725
+ children.push(corner);
27726
+ children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F(n.w / Math.max(1, n.h))]]));
27727
+ return this.inst(className, n.name, [...common, ...props], children, n.id);
27728
+ }
27729
+ if (d.kind === "panel") {
27730
+ this.counts.pictures++;
27731
+ const a = this.assetUrl(`${n.id}:panel`, n);
27732
+ const scale = this.opts.scale ?? 2;
27733
+ const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + (n.strokeWeight ?? 0) + 2) * scale);
27734
+ const pw = Math.round((a.size?.w ?? n.w) * scale);
27735
+ const ph = Math.round((a.size?.h ?? n.h) * scale);
27736
+ const ix = Math.min(inset, Math.floor(pw / 2) - 1);
27737
+ const iy = Math.min(inset, Math.floor(ph / 2) - 1);
27738
+ className = button ? "ImageButton" : "ImageLabel";
27739
+ props = [
27740
+ ["BackgroundTransparency", F(1)],
27741
+ ["Image", { t: "Content", url: a.url }],
27742
+ ["ScaleType", E("ScaleType", "Slice")],
27743
+ ["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
27744
+ ["SliceScale", F(1 / scale)]
27745
+ ];
27746
+ if (opacity < 1)
27747
+ props.push(["ImageTransparency", F(1 - opacity)]);
27748
+ if (button)
27749
+ props.push(["AutoButtonColor", B(false)]);
27750
+ } else {
27751
+ className = button ? "TextButton" : hasKids && opacity < 1 ? "CanvasGroup" : "Frame";
27752
+ const f = fills[0];
27753
+ if (!f)
27754
+ props.push(["BackgroundTransparency", F(1)]);
27755
+ else if (f.type === "SOLID") {
27756
+ props.push(["BackgroundColor3", C3(f.color)]);
27757
+ const t = 1 - f.opacity * (className === "CanvasGroup" ? 1 : opacity);
27758
+ if (t > 0)
27759
+ props.push(["BackgroundTransparency", F(t)]);
27760
+ } else if (f.type === "GRADIENT_LINEAR") {
27761
+ props.push(["BackgroundColor3", C3("#FFFFFF")]);
27762
+ children.push(this.gradient(f));
27763
+ } else if (f.type === "IMAGE") {
27764
+ props.push(["BackgroundTransparency", F(1)]);
27765
+ const a = this.assetUrl(`image:${f.imageHash}`, n);
27766
+ 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")]]);
27767
+ const corner = this.corner(n);
27768
+ if (corner)
27769
+ bg.children.push(corner);
27770
+ children.push(bg);
27771
+ }
27772
+ if (className === "CanvasGroup")
27773
+ props.push(["GroupTransparency", F(1 - opacity)]);
27774
+ if (button)
27775
+ props.push(["Text", S("")], ["AutoButtonColor", B(false)]);
27776
+ const corner = this.corner(n);
27777
+ if (corner)
27778
+ children.push(corner);
27779
+ const stroke = this.stroke(n);
27780
+ if (stroke)
27781
+ children.push(stroke);
27782
+ }
27783
+ props.push(["BorderSizePixel", I(0)]);
27784
+ if (n.clip)
27785
+ props.push(["ClipsDescendants", B(true)]);
27786
+ const kids = n.children ?? [];
27787
+ const flow = n.layout ? kids.filter((k) => !k.absolute) : kids;
27788
+ const loose = n.layout ? kids.filter((k) => k.absolute) : [];
27789
+ const me = { w: n.w, h: n.h, layout: n.layout };
27790
+ const mapped = (list, p) => list.map((k, i) => this.node(k, p, i)).filter((x) => !!x);
27791
+ const flowMapped = mapped(flow, me);
27792
+ if (n.layout && n.layout.mode !== "GRID") {
27793
+ for (const k of flow) {
27794
+ const target = k.grow && !k.hidden ? flowMapped.find((c) => c.source === k.id) : undefined;
27795
+ if (target)
27796
+ target.children.push(this.inst("UIFlexItem", "UIFlexItem", [["FlexMode", E("UIFlexMode", "Fill")]]));
27797
+ }
27798
+ }
27799
+ if (n.layout && loose.length) {
27800
+ const contentZ = kids.findIndex((k) => !k.absolute) + 1;
27801
+ const content = this.inst("Frame", "Content", [["Size", U2(1, 0, 1, 0)], ["ZIndex", I(contentZ)], ["BackgroundTransparency", F(1)], ["BorderSizePixel", I(0)]], [...this.layoutChildren(n), ...flowMapped]);
27802
+ const overlay = loose.map((k) => this.node(k, { w: n.w, h: n.h }, kids.indexOf(k))).filter((x) => !!x);
27803
+ children.push(content, ...overlay);
27804
+ } else {
27805
+ if (n.layout)
27806
+ children.push(...this.layoutChildren(n));
27807
+ children.push(...flowMapped);
27808
+ }
27809
+ const self = this.inst(className, n.name, [...common, ...props], children, n.id);
27810
+ if (!d.shadow)
27811
+ return self;
27812
+ this.counts.pictures++;
27813
+ const a = this.assetUrl(`${n.id}:shadow`, n);
27814
+ const scale = this.opts.scale ?? 2;
27815
+ const sh = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
27816
+ const reach = Math.max(...sh.map((e) => e.blur + (e.spread ?? 0) + Math.max(Math.abs(e.x ?? 0), Math.abs(e.y ?? 0))));
27817
+ const off = a.offset ?? { x: 0, y: 0 };
27818
+ const size = a.size ?? { w: n.w, h: n.h };
27819
+ const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + reach) * scale);
27820
+ const pw = Math.round(size.w * scale);
27821
+ const ph = Math.round(size.h * scale);
27822
+ const ix = Math.min(inset, Math.floor(pw / 2) - 1);
27823
+ const iy = Math.min(inset, Math.floor(ph / 2) - 1);
27824
+ const shadow = this.inst("ImageLabel", "Shadow", [
27825
+ ["Position", U2(0, off.x, 0, off.y)],
27826
+ ["Size", U2(1, size.w - n.w, 1, size.h - n.h)],
27827
+ ["ZIndex", I(1)],
27828
+ ["BackgroundTransparency", F(1)],
27829
+ ["Image", { t: "Content", url: a.url }],
27830
+ ["ScaleType", E("ScaleType", "Slice")],
27831
+ ["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
27832
+ ["SliceScale", F(1 / scale)]
27833
+ ]);
27834
+ self.props = self.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "AutomaticSize"].includes(k));
27835
+ self.props.splice(1, 0, ["Size", U2(1, 0, 1, 0)], ["ZIndex", I(2)]);
27836
+ return this.inst("Frame", n.name, [...common.filter(([k]) => k !== "AutomaticSize"), ["BackgroundTransparency", F(1)], ["BorderSizePixel", I(0)]], [shadow, self], n.id);
27837
+ }
27838
+ pictureGeometry(n, a, geo) {
27839
+ 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)
27840
+ return geo;
27841
+ return geo.map(([k, v]) => {
27842
+ if (k === "Size" && v.t === "UDim2")
27843
+ return [k, { ...v, xo: Math.round(v.xo + a.size.w - n.w), yo: Math.round(v.yo + a.size.h - n.h) }];
27844
+ if (k === "Position" && v.t === "UDim2")
27845
+ return [k, { ...v, xo: Math.round(v.xo + a.offset.x), yo: Math.round(v.yo + a.offset.y) }];
27846
+ return [k, v];
27847
+ });
27848
+ }
27849
+ }
27850
+ function mapToRoblox(root, opts, assets) {
27851
+ const m = new Mapper(opts, assets);
27852
+ const out = m.map(root);
27853
+ if (m.counts.skippedHidden)
27854
+ m.warn(null, `${m.counts.skippedHidden} hidden layer(s) skipped`);
27855
+ return { root: out, warnings: [...m.warnings], fontSubstitutions: Object.fromEntries(m.fontSubstitutions), counts: m.counts };
27856
+ }
27857
+ 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;
27858
+ var init_map2 = __esm(() => {
27859
+ init_fonts();
27860
+ BUTTON_NAME = /\b(button|btn|cta)\b/i;
27861
+ });
27862
+
27863
+ // src/roblox/output.ts
27864
+ function xmlProp(name, v) {
27865
+ switch (v.t) {
27866
+ case "string":
27867
+ return `<string name="${name}">${xml(v.v)}</string>`;
27868
+ case "bool":
27869
+ return `<bool name="${name}">${v.v}</bool>`;
27870
+ case "int":
27871
+ return `<int name="${name}">${v.v}</int>`;
27872
+ case "float":
27873
+ return `<float name="${name}">${n(v.v)}</float>`;
27874
+ case "Color3":
27875
+ return `<Color3 name="${name}"><R>${n(v.r)}</R><G>${n(v.g)}</G><B>${n(v.b)}</B></Color3>`;
27876
+ case "UDim":
27877
+ return `<UDim name="${name}"><S>${n(v.s)}</S><O>${v.o}</O></UDim>`;
27878
+ case "UDim2":
27879
+ return `<UDim2 name="${name}"><XS>${n(v.xs)}</XS><XO>${v.xo}</XO><YS>${n(v.ys)}</YS><YO>${v.yo}</YO></UDim2>`;
27880
+ case "Vector2":
27881
+ return `<Vector2 name="${name}"><X>${n(v.x)}</X><Y>${n(v.y)}</Y></Vector2>`;
27882
+ case "Rect":
27883
+ return `<Rect2D name="${name}"><min><X>${n(v.x0)}</X><Y>${n(v.y0)}</Y></min><max><X>${n(v.x1)}</X><Y>${n(v.y1)}</Y></max></Rect2D>`;
27884
+ case "enum": {
27885
+ const value = ENUMS[v.e]?.[v.v];
27886
+ if (value === undefined)
27887
+ throw new Error(`No enum value for ${v.e}.${v.v}`);
27888
+ return `<token name="${name}">${value}</token>`;
27889
+ }
27890
+ case "Content":
27891
+ return v.url ? `<Content name="${name}"><url>${xml(v.url)}</url></Content>` : `<Content name="${name}"><null></null></Content>`;
27892
+ case "ColorSequence":
27893
+ return `<ColorSequence name="${name}">${v.keys.map((k) => `${n(k.time)} ${n(k.r)} ${n(k.g)} ${n(k.b)} 0 `).join("")}</ColorSequence>`;
27894
+ case "NumberSequence":
27895
+ return `<NumberSequence name="${name}">${v.keys.map((k) => `${n(k.time)} ${n(k.value)} 0 `).join("")}</NumberSequence>`;
27896
+ case "Font":
27897
+ return `<Font name="${name}"><Family><url>${xml(v.family)}</url></Family><Weight>${v.weight}</Weight><Style>${v.style}</Style></Font>`;
27898
+ }
27899
+ }
27900
+ function toRbxmx(root) {
27901
+ let ref = 0;
27902
+ const item = (i, depth) => {
27903
+ const pad = "\t".repeat(depth);
27904
+ const props = i.props.map(([k, v]) => `${pad} ${xmlProp(k, v)}`).join(`
27905
+ `);
27906
+ const kids = i.children.map((c) => item(c, depth + 1)).join(`
27907
+ `);
27908
+ return `${pad}<Item class="${i.className}" referent="RBX${(ref++).toString(16).toUpperCase().padStart(8, "0")}">
27909
+ ${pad} <Properties>
27910
+ ${props}
27911
+ ${pad} </Properties>${kids ? `
27912
+ ${kids}` : ""}
27913
+ ${pad}</Item>`;
27914
+ };
27915
+ return `<roblox xmlns:xmime="http://www.w3.org/2005/05/xmlmime" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://www.roblox.com/roblox.xsd" version="4">
27916
+ ` + ` <External>null</External>
27917
+ <External>nil</External>
27918
+ ` + item(root, 1) + `
27919
+ </roblox>
27920
+ `;
27921
+ }
27922
+ function luauString(s) {
27923
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, (c) => `\\${c.charCodeAt(0)}`)}"`;
27924
+ }
27925
+ function luauValue(v) {
27926
+ switch (v.t) {
27927
+ case "string":
27928
+ return luauString(v.v);
27929
+ case "bool":
27930
+ return String(v.v);
27931
+ case "int":
27932
+ return String(v.v);
27933
+ case "float":
27934
+ return n(v.v);
27935
+ case "Color3":
27936
+ return `Color3.fromRGB(${rgb(v.r)}, ${rgb(v.g)}, ${rgb(v.b)})`;
27937
+ case "UDim":
27938
+ return `UDim.new(${n(v.s)}, ${v.o})`;
27939
+ case "UDim2":
27940
+ if (!v.xo && !v.yo)
27941
+ return `UDim2.fromScale(${n(v.xs)}, ${n(v.ys)})`;
27942
+ if (!v.xs && !v.ys)
27943
+ return `UDim2.fromOffset(${v.xo}, ${v.yo})`;
27944
+ return `UDim2.new(${n(v.xs)}, ${v.xo}, ${n(v.ys)}, ${v.yo})`;
27945
+ case "Vector2":
27946
+ return `Vector2.new(${n(v.x)}, ${n(v.y)})`;
27947
+ case "Rect":
27948
+ return `Rect.new(${n(v.x0)}, ${n(v.y0)}, ${n(v.x1)}, ${n(v.y1)})`;
27949
+ case "enum":
27950
+ return `Enum.${v.e}.${v.v}`;
27951
+ case "Content":
27952
+ return luauString(v.url);
27953
+ case "ColorSequence":
27954
+ return `ColorSequence.new({
27955
+ ${v.keys.map((k) => ` ColorSequenceKeypoint.new(${n(k.time)}, Color3.fromRGB(${rgb(k.r)}, ${rgb(k.g)}, ${rgb(k.b)})),`).join(`
27956
+ `)}
27957
+ })`;
27958
+ case "NumberSequence":
27959
+ return `NumberSequence.new({
27960
+ ${v.keys.map((k) => ` NumberSequenceKeypoint.new(${n(k.time)}, ${n(k.value)}),`).join(`
27961
+ `)}
27962
+ })`;
27963
+ case "Font":
27964
+ return `Font.new(${luauString(v.family)}, Enum.FontWeight.${v.weightName}, Enum.FontStyle.${v.style})`;
27965
+ }
27966
+ }
27967
+ function identifier(name, className, used) {
27968
+ const words = name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean).slice(0, 5);
27969
+ let id = words.map((w, i) => i ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w.toLowerCase()).join("");
27970
+ if (!id || /^\d/.test(id))
27971
+ id = className[0].toLowerCase() + className.slice(1) + (id ? id[0].toUpperCase() + id.slice(1) : "");
27972
+ if (KEYWORDS.has(id))
27973
+ id += "Gui";
27974
+ let unique = id;
27975
+ for (let k = 2;used.has(unique); k++)
27976
+ unique = `${id}${k}`;
27977
+ used.add(unique);
27978
+ return unique;
27979
+ }
27980
+ function toLuau(root, opts = {}) {
27981
+ const used = new Set;
27982
+ const lines = [];
27983
+ const emit = (i, parentVar, depth) => {
27984
+ const v = identifier(i.name, i.className, used);
27985
+ if (!/^UI/.test(i.className) && depth <= 2 && parentVar)
27986
+ lines.push("", `-- ${i.name.replace(/\n/g, " ")}`);
27987
+ lines.push(`local ${v} = Instance.new(${luauString(i.className)})`);
27988
+ for (const [k, val] of i.props)
27989
+ lines.push(`${v}.${k} = ${luauValue(val)}`);
27990
+ for (const c of i.children)
27991
+ emit(c, v, depth + 1);
27992
+ if (parentVar)
27993
+ lines.push(`${v}.Parent = ${parentVar}`);
27994
+ return v;
27995
+ };
27996
+ const header = [
27997
+ `-- ${root.name}: generated by Figma Bridge${opts.source ? ` from "${opts.source.replace(/\n/g, " ")}"` : ""} (export_roblox).`,
27998
+ "-- Creates the UI under PARENT and returns it. Running it again replaces the previous copy.",
27999
+ `local PARENT = ${opts.parent ?? 'game:GetService("StarterGui")'}`,
28000
+ "",
28001
+ `local existing = PARENT:FindFirstChild(${luauString(root.name)})`,
28002
+ "if existing then",
28003
+ "\texisting:Destroy()",
28004
+ "end",
28005
+ ""
28006
+ ];
28007
+ const rootVar = emit(root, null, 0);
28008
+ return `${header.join(`
28009
+ `)}
28010
+ ${lines.join(`
28011
+ `)}
28012
+
28013
+ ${rootVar}.Parent = PARENT
28014
+ return ${rootVar}
28015
+ `.replace(/\n{3,}/g, `
28016
+
28017
+ `);
28018
+ }
28019
+ function substituteAssets(text, ids) {
28020
+ return text.replace(/rbxassetid:\/\/PENDING_\d+/g, (m) => ids[m] ? `rbxassetid://${ids[m]}` : m);
28021
+ }
28022
+ 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);
28023
+ var init_output = __esm(() => {
28024
+ ENUMS = {
28025
+ ZIndexBehavior: { Global: 0, Sibling: 1 },
28026
+ FillDirection: { Horizontal: 0, Vertical: 1 },
28027
+ HorizontalAlignment: { Center: 0, Left: 1, Right: 2 },
28028
+ VerticalAlignment: { Center: 0, Top: 1, Bottom: 2 },
28029
+ SortOrder: { Name: 0, Custom: 1, LayoutOrder: 2 },
28030
+ UIFlexAlignment: { None: 0, Fill: 1, SpaceAround: 2, SpaceBetween: 3, SpaceEvenly: 4 },
28031
+ UIFlexMode: { None: 0, Grow: 1, Shrink: 2, Fill: 3, Custom: 4 },
28032
+ AutomaticSize: { None: 0, X: 1, Y: 2, XY: 3 },
28033
+ TextXAlignment: { Left: 0, Right: 1, Center: 2 },
28034
+ TextYAlignment: { Top: 0, Center: 1, Bottom: 2 },
28035
+ ScaleType: { Stretch: 0, Slice: 1, Tile: 2, Fit: 3, Crop: 4 },
28036
+ ApplyStrokeMode: { Contextual: 0, Border: 1 },
28037
+ LineJoinMode: { Round: 0, Bevel: 1, Miter: 2 },
28038
+ TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 }
28039
+ };
28040
+ 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"]);
28041
+ });
28042
+
28043
+ // src/roblox/upload.ts
28044
+ import { createHash } from "node:crypto";
28045
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
28046
+ import { dirname as dirname2 } from "node:path";
28047
+ function creatorFrom(id) {
28048
+ const m = /^(?:(user|group)[:/])?\s*(\d+)$/i.exec(id.trim());
28049
+ if (!m)
28050
+ throw new RobloxUploadError(`ROBLOX_CREATOR_ID must be a user id, "user:<id>" or "group:<id>", not "${id}".`, "BAD_CREATOR");
28051
+ return m[1]?.toLowerCase() === "group" ? { groupId: m[2] } : { userId: m[2] };
28052
+ }
28053
+
28054
+ class RobloxUploader {
28055
+ opts;
28056
+ cache = {};
28057
+ fetchImpl;
28058
+ sleep;
28059
+ base;
28060
+ constructor(opts) {
28061
+ this.opts = opts;
28062
+ this.fetchImpl = opts.fetchImpl ?? fetch;
28063
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
28064
+ this.base = opts.base ?? process.env.ROBLOX_API_URL ?? "https://apis.roblox.com";
28065
+ try {
28066
+ this.cache = JSON.parse(readFileSync2(opts.cacheFile, "utf8"));
28067
+ } catch {}
28068
+ }
28069
+ static hash(bytes) {
28070
+ return createHash("sha256").update(bytes).digest("hex");
28071
+ }
28072
+ async request(url, init) {
28073
+ let delay = 1000;
28074
+ for (let attempt = 0;; attempt++) {
28075
+ const res = await this.fetchImpl(url, { ...init, headers: { ...init.headers, "x-api-key": this.opts.apiKey }, signal: AbortSignal.timeout(60000) });
28076
+ if (res.ok)
28077
+ return res.json();
28078
+ const retryAfter = Number(res.headers.get("retry-after"));
28079
+ if ((res.status === 429 || res.status >= 500) && attempt < 3) {
28080
+ await this.sleep(retryAfter > 0 ? retryAfter * 1000 : delay);
28081
+ delay *= 2;
28082
+ continue;
28083
+ }
28084
+ let detail = "";
28085
+ try {
28086
+ const body = await res.json();
28087
+ detail = body.message ?? body.errors?.[0]?.message ?? "";
28088
+ } catch {}
28089
+ if (res.status === 401 || res.status === 403)
28090
+ throw new RobloxUploadError(`Roblox refused the API key (${res.status}${detail ? `: ${detail}` : ""}). It needs the Assets API with Read and Write, for this creator.`, "FORBIDDEN");
28091
+ throw new RobloxUploadError(`Roblox Open Cloud error ${res.status}${detail ? `: ${detail}` : ""}`, "UPLOAD_FAILED");
28092
+ }
28093
+ }
28094
+ async upload(bytes, name) {
28095
+ const hash = RobloxUploader.hash(bytes);
28096
+ if (this.cache[hash])
28097
+ return { assetId: this.cache[hash], cached: true };
28098
+ const form = new FormData;
28099
+ form.append("request", JSON.stringify({
28100
+ assetType: "Image",
28101
+ displayName: name.slice(0, 50) || "Figma Bridge image",
28102
+ description: "Uploaded by Figma Bridge (export_roblox)",
28103
+ creationContext: { creator: creatorFrom(this.opts.creatorId) }
28104
+ }));
28105
+ form.append("fileContent", new Blob([new Uint8Array(bytes)], { type: "image/png" }), `${name.replace(/[^\w.-]+/g, "_") || "image"}.png`);
28106
+ let op = await this.request(`${this.base}/assets/v1/assets`, { method: "POST", body: form });
28107
+ for (let i = 0;!op.done; i++) {
28108
+ if (i >= 20)
28109
+ throw new RobloxUploadError(`Roblox is still processing "${name}": try again in a minute.`, "UPLOAD_PENDING");
28110
+ await this.sleep(Math.min(500 * 2 ** i, 4000));
28111
+ const id = String(op.operationId ?? op.path ?? "").replace(/^operations\//, "");
28112
+ if (!id)
28113
+ throw new RobloxUploadError("Roblox returned no operation to wait for.", "UPLOAD_FAILED");
28114
+ op = await this.request(`${this.base}/assets/v1/operations/${id}`, { method: "GET" });
28115
+ }
28116
+ const assetId = String(op.response?.assetId ?? "");
28117
+ if (!assetId)
28118
+ throw new RobloxUploadError(`Upload of "${name}" finished without an asset id${op.error?.message ? `: ${op.error.message}` : ""}.`, "UPLOAD_FAILED");
28119
+ this.cache[hash] = assetId;
28120
+ mkdirSync2(dirname2(this.opts.cacheFile), { recursive: true });
28121
+ writeFileSync2(this.opts.cacheFile, JSON.stringify(this.cache, null, 2));
28122
+ return { assetId, cached: false };
28123
+ }
28124
+ }
28125
+ var RobloxUploadError, UPLOAD_HELP;
28126
+ var init_upload = __esm(() => {
28127
+ RobloxUploadError = class RobloxUploadError extends Error {
28128
+ code;
28129
+ constructor(message, code) {
28130
+ super(message);
28131
+ this.code = code;
28132
+ }
28133
+ };
28134
+ UPLOAD_HELP = "To upload pictures automatically, create an Open Cloud API key at create.roblox.com → Open Cloud → API Keys with the " + "Assets API (Read and Write) and your experience's creator, then set ROBLOX_API_KEY and ROBLOX_CREATOR_ID (your user id, " + 'or "group:<id>") in the Figma Bridge env of your AI client, and call export_roblox with upload:true.';
28135
+ });
28136
+
28137
+ // src/roblox/export.ts
28138
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
28139
+ import { homedir } from "node:os";
28140
+ import { join as join2 } from "node:path";
28141
+ async function exportRoblox(args, deps) {
28142
+ const env = deps.env ?? process.env;
28143
+ const opts = {
28144
+ mode: args.mode ?? "hybrid",
28145
+ targetResolution: args.targetResolution ?? [1920, 1080],
28146
+ rasterize: args.rasterize ?? "auto",
28147
+ asRootFrame: args.asRootFrame,
28148
+ textScaled: args.textScaled,
28149
+ fonts: args.fonts,
28150
+ scale: 2
28151
+ };
28152
+ const t = await deps.request("roblox_tree", { nodeId: args.nodeId }, 120000);
28153
+ const warnings = [];
28154
+ if (t.truncated)
28155
+ warnings.push(`Stopped after ${t.nodes} layers: export a smaller frame for the rest.`);
28156
+ const wanted = pictureRequests(t.tree, opts);
28157
+ const pictures = new Map;
28158
+ for (let i = 0;i < wanted.length; i += 20) {
28159
+ const r = await deps.request("roblox_images", { items: wanted.slice(i, i + 20).map((w) => ({ ...w, scale: opts.scale })) }, 120000);
28160
+ for (const img of r.images) {
28161
+ if (img.b64)
28162
+ pictures.set(`${img.id}:${img.mode}`, { b64: img.b64, offset: img.offset, size: img.size });
28163
+ else
28164
+ warnings.push(`Picture of ${img.id} failed: ${img.error}`);
28165
+ }
28166
+ }
28167
+ const assets = [];
28168
+ const files = new Map;
28169
+ const refs = new Map;
28170
+ const names = new Map;
28171
+ const layerNames = (n) => {
28172
+ names.set(n.id, n.name);
28173
+ for (const c of n.children ?? [])
28174
+ layerNames(c);
28175
+ };
28176
+ layerNames(t.tree);
28177
+ const add = (key, bytes, a, ext, ref) => {
28178
+ const placeholder = `rbxassetid://PENDING_${assets.length + 1}`;
28179
+ const file = `${String(assets.length + 1).padStart(2, "0")}-${safe(a.layer)}${a.mode && a.mode !== "full" ? `-${a.mode}` : ""}.${ext}`;
28180
+ assets.push({ placeholder, file, ...a });
28181
+ files.set(file, bytes);
28182
+ refs.set(key, { url: placeholder, ...ref });
28183
+ };
28184
+ for (const w of wanted) {
28185
+ const p = pictures.get(`${w.id}:${w.mode}`);
28186
+ if (!p)
28187
+ continue;
28188
+ 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 });
28189
+ }
28190
+ for (const hash of imageHashes(t.tree)) {
28191
+ const b64 = t.images[hash];
28192
+ if (!b64)
28193
+ continue;
28194
+ const bytes = new Uint8Array(Buffer.from(b64, "base64"));
28195
+ const info = imageInfo(bytes);
28196
+ if (info && info.format !== "png" && info.format !== "jpeg")
28197
+ warnings.push(`An image is ${info.format.toUpperCase()}: Roblox takes PNG, JPG, BMP or TGA, convert it before uploading.`);
28198
+ const owner = findImageOwner(t.tree, hash);
28199
+ add(`image:${hash}`, bytes, { nodeId: owner?.id ?? "", layer: owner?.name ?? "image", kind: "image" }, info?.format === "jpeg" ? "jpg" : info?.format ?? "png", {});
28200
+ }
28201
+ const mapped = mapToRoblox(t.tree, opts, (key) => refs.get(key));
28202
+ warnings.push(...mapped.warnings);
28203
+ let rbxmx = toRbxmx(mapped.root);
28204
+ let luau = toLuau(mapped.root, { parent: args.parent, source: `${t.tree.name}" in "${t.fileName}` });
28205
+ let uploaded = false;
28206
+ if (args.upload) {
28207
+ if (!env.ROBLOX_API_KEY || !env.ROBLOX_CREATOR_ID)
28208
+ warnings.push(`upload skipped: ROBLOX_API_KEY and ROBLOX_CREATOR_ID are not set. ${UPLOAD_HELP}`);
28209
+ else if (assets.length) {
28210
+ const uploader = new RobloxUploader({
28211
+ apiKey: env.ROBLOX_API_KEY,
28212
+ creatorId: env.ROBLOX_CREATOR_ID,
28213
+ cacheFile: join2(env.FIGMA_BRIDGE_HOME ?? join2(homedir(), ".figma-bridge"), "roblox-assets.json"),
28214
+ fetchImpl: deps.fetchImpl,
28215
+ sleep: deps.sleep
28216
+ });
28217
+ const ids = {};
28218
+ for (const a of assets) {
28219
+ const r = await uploader.upload(files.get(a.file), `${a.layer}${a.mode && a.mode !== "full" ? ` ${a.mode}` : ""}`);
28220
+ a.assetId = r.assetId;
28221
+ a.cached = r.cached;
28222
+ ids[a.placeholder] = r.assetId;
28223
+ }
28224
+ rbxmx = substituteAssets(rbxmx, ids);
28225
+ luau = substituteAssets(luau, ids);
28226
+ uploaded = true;
28227
+ }
28228
+ }
28229
+ const dir = args.outDir ?? join2(deps.outDir, `roblox-${safe(t.tree.name)}-${Date.now()}`);
28230
+ mkdirSync3(dir, { recursive: true });
28231
+ const base = safe(mapped.root.name);
28232
+ writeFileSync3(join2(dir, `${base}.rbxmx`), rbxmx);
28233
+ writeFileSync3(join2(dir, `${base}.luau`), luau);
28234
+ for (const [file, bytes] of files)
28235
+ writeFileSync3(join2(dir, file), bytes);
28236
+ writeFileSync3(join2(dir, "assets.json"), JSON.stringify(assets, null, 2));
28237
+ const pending = assets.filter((a) => !a.assetId);
28238
+ const next = pending.length ? `Upload the ${pending.length} picture(s) in ${dir} (assets[].file) with your Roblox Studio tools (e.g. upload_image), replace each placeholder (rbxassetid://PENDING_n) in the Luau with the returned id, run it with execute_luau, then check it with a Studio screenshot. Or set ROBLOX_API_KEY and ROBLOX_CREATOR_ID and call export_roblox with upload:true.` : "Run the Luau with execute_luau (it replaces a previous copy and returns the root), or insert the .rbxmx in Studio, then check it with a Studio screenshot.";
28239
+ return {
28240
+ result: {
28241
+ root: mapped.root.name,
28242
+ dir,
28243
+ rbxmx: join2(dir, `${base}.rbxmx`),
28244
+ luau: join2(dir, `${base}.luau`),
28245
+ assets: assets.map((a) => ({ ...a, file: join2(dir, a.file) })),
28246
+ uploaded,
28247
+ instances: mapped.counts.instances,
28248
+ layers: t.nodes,
28249
+ options: { mode: opts.mode, targetResolution: opts.targetResolution, rasterize: opts.rasterize },
28250
+ ...Object.keys(mapped.fontSubstitutions).length ? { fontSubstitutions: mapped.fontSubstitutions } : {},
28251
+ ...warnings.length ? { warnings: warnings.slice(0, 60) } : {},
28252
+ next
28253
+ },
28254
+ luau,
28255
+ rbxmx
28256
+ };
28257
+ }
28258
+ function findImageOwner(n, hash) {
28259
+ if ((n.fills ?? []).some((f) => f.imageHash === hash))
28260
+ return n;
28261
+ for (const c of n.children ?? []) {
28262
+ const hit = findImageOwner(c, hash);
28263
+ if (hit)
28264
+ return hit;
28265
+ }
28266
+ return null;
28267
+ }
28268
+ var safe = (s) => s.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "ui";
28269
+ var init_export = __esm(() => {
28270
+ init_image();
28271
+ init_map2();
28272
+ init_output();
28273
+ init_upload();
28274
+ });
28275
+
28276
+ // src/rest.ts
28277
+ function fileKeyFrom(input) {
28278
+ const m = /figma\.com\/(?:file|design|board|slides|proto|deck)\/([A-Za-z0-9]{10,})/.exec(input);
28279
+ if (m)
28280
+ return m[1];
28281
+ return /^[A-Za-z0-9]{10,}$/.test(input.trim()) ? input.trim() : null;
28282
+ }
28283
+
28284
+ class FigmaRest {
28285
+ token;
28286
+ fetchImpl;
28287
+ sleep;
28288
+ base;
28289
+ constructor(token, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), base = process.env.FIGMA_API_URL || "https://api.figma.com") {
28290
+ this.token = token;
28291
+ this.fetchImpl = fetchImpl;
28292
+ this.sleep = sleep;
28293
+ this.base = base;
28294
+ }
28295
+ async call(method, path, body) {
28296
+ let delay = 1000;
28297
+ for (let attempt = 0;; attempt++) {
28298
+ let res;
28299
+ try {
28300
+ res = await this.fetchImpl(this.base + path, {
28301
+ method,
28302
+ headers: { "X-Figma-Token": this.token, ...body !== undefined ? { "Content-Type": "application/json" } : {} },
28303
+ body: body !== undefined ? JSON.stringify(body) : undefined,
28304
+ signal: AbortSignal.timeout(60000)
28305
+ });
28306
+ } catch (e) {
28307
+ if (attempt >= 3)
28308
+ throw new RestError(`Figma API unreachable: ${e.message}`, "NETWORK");
28309
+ await this.sleep(delay);
28310
+ delay *= 2;
28311
+ continue;
28312
+ }
28313
+ if (res.ok)
28314
+ return res.status === 204 ? {} : await res.json();
28315
+ const retryAfter = Number(res.headers.get("retry-after"));
28316
+ if ((res.status === 429 || res.status >= 500) && attempt < 3) {
28317
+ await this.sleep(res.status === 429 && retryAfter > 0 ? Math.min(retryAfter, 120) * 1000 : delay);
28318
+ delay *= 2;
28319
+ continue;
28320
+ }
28321
+ let detail = "";
28322
+ try {
28323
+ const j = await res.json();
28324
+ detail = j.err ?? j.message ?? "";
28325
+ } catch {}
28326
+ if (res.status === 403)
28327
+ throw new RestError(`Figma API refused the request (403${detail ? `: ${detail}` : ""}). Check that FIGMA_TOKEN is valid, has the needed scopes and can open this file.`, "FORBIDDEN", 403);
28328
+ if (res.status === 404)
28329
+ throw new RestError(`Not found (404${detail ? `: ${detail}` : ""}): check the file key or id.`, "NOT_FOUND", 404);
28330
+ if (res.status === 429)
28331
+ throw new RestError("Figma API rate limit reached: try again in a minute.", "RATE_LIMITED", 429);
28332
+ throw new RestError(`Figma API error ${res.status}${detail ? `: ${detail}` : ""}`, "REST_ERROR", res.status);
28333
+ }
28334
+ }
28335
+ async comments(key) {
28336
+ const r = await this.call("GET", `/v1/files/${key}/comments?as_md=true`);
28337
+ return r.comments ?? [];
28338
+ }
28339
+ postComment(key, message, opts = {}) {
28340
+ const body = { message };
28341
+ if (opts.replyTo)
28342
+ body.comment_id = opts.replyTo;
28343
+ else if (opts.nodeId)
28344
+ body.client_meta = { node_id: opts.nodeId, node_offset: opts.offset ?? { x: 0, y: 0 } };
28345
+ return this.call("POST", `/v1/files/${key}/comments`, body);
28346
+ }
28347
+ deleteComment(key, id) {
28348
+ return this.call("DELETE", `/v1/files/${key}/comments/${id}`);
28349
+ }
28350
+ async versions(key, limit = 30) {
28351
+ const out = [];
28352
+ let before;
28353
+ while (out.length < limit) {
28354
+ const r = await this.call("GET", `/v1/files/${key}/versions?page_size=${Math.min(50, limit)}${before ? `&before=${before}` : ""}`);
28355
+ out.push(...r.versions ?? []);
28356
+ const next = r.pagination?.prev_page ? new URL(r.pagination.prev_page).searchParams.get("before") : null;
28357
+ if (!next || !r.versions?.length)
28358
+ break;
28359
+ before = next;
28360
+ }
28361
+ return out.slice(0, limit);
28362
+ }
28363
+ async nodes(key, ids, version) {
28364
+ const q = `ids=${encodeURIComponent(ids.join(","))}${version ? `&version=${encodeURIComponent(version)}` : ""}`;
28365
+ const r = await this.call("GET", `/v1/files/${key}/nodes?${q}`);
28366
+ const out = {};
28367
+ for (const [id, n] of Object.entries(r.nodes ?? {}))
28368
+ if (n?.document)
28369
+ out[id] = n.document;
28370
+ return out;
28371
+ }
28372
+ async pages(key, version) {
28373
+ const r = await this.call("GET", `/v1/files/${key}?depth=1${version ? `&version=${encodeURIComponent(version)}` : ""}`);
28374
+ return r.document?.children ?? [];
28375
+ }
28376
+ }
28377
+ function threadComments(list, includeResolved) {
28378
+ const replies = new Map;
28379
+ for (const c of list)
28380
+ if (c.parent_id)
28381
+ replies.set(c.parent_id, [...replies.get(c.parent_id) ?? [], c]);
28382
+ const brief = (c) => ({ id: c.id, author: c.user?.handle, message: c.message, createdAt: c.created_at });
28383
+ return list.filter((c) => !c.parent_id && (includeResolved || !c.resolved_at)).sort((a, b) => a.created_at.localeCompare(b.created_at)).map((c) => ({
28384
+ ...brief(c),
28385
+ ...c.order_id ? { number: Number(c.order_id) } : {},
28386
+ ...c.resolved_at ? { resolvedAt: c.resolved_at } : {},
28387
+ ...c.client_meta?.node_id ? { nodeId: c.client_meta.node_id } : {},
28388
+ replies: (replies.get(c.id) ?? []).sort((a, b) => a.created_at.localeCompare(b.created_at)).map(brief)
28389
+ }));
28390
+ }
28391
+ function flatten(root, prefix, out) {
28392
+ const seen = new Map;
28393
+ for (const c of root.children ?? []) {
28394
+ const n = (seen.get(c.name) ?? 0) + 1;
28395
+ seen.set(c.name, n);
28396
+ const path = `${prefix}${c.name}${n > 1 ? ` #${n}` : ""}`;
28397
+ out.set(path, c);
28398
+ flatten(c, `${path}/`, out);
28399
+ }
28400
+ }
28401
+ function diffTrees(before, after, max = 200) {
28402
+ const a = new Map;
28403
+ const b = new Map;
28404
+ flatten(before, "", a);
28405
+ flatten(after, "", b);
28406
+ const added = [];
28407
+ const removed = [];
28408
+ const changed = [];
28409
+ for (const [path, n] of b)
28410
+ if (!a.has(path))
28411
+ added.push({ path, type: n.type, id: n.id });
28412
+ for (const [path, n] of a)
28413
+ if (!b.has(path))
28414
+ removed.push({ path, type: n.type, id: n.id });
28415
+ for (const [path, now] of b) {
28416
+ const old = a.get(path);
28417
+ if (!old)
28418
+ continue;
28419
+ const c = [];
28420
+ if (old.type !== now.type)
28421
+ c.push(`type ${old.type} → ${now.type}`);
28422
+ if ((old.visible ?? true) !== (now.visible ?? true))
28423
+ c.push(now.visible === false ? "hidden" : "shown");
28424
+ if ((old.characters ?? "") !== (now.characters ?? ""))
28425
+ c.push(`text "${(old.characters ?? "").slice(0, 60)}" → "${(now.characters ?? "").slice(0, 60)}"`);
28426
+ const ob = old.absoluteBoundingBox;
28427
+ const nb = now.absoluteBoundingBox;
28428
+ if (ob && nb) {
28429
+ if (Math.abs(ob.width - nb.width) > 0.5 || Math.abs(ob.height - nb.height) > 0.5)
28430
+ c.push(`size ${Math.round(ob.width)}×${Math.round(ob.height)} → ${Math.round(nb.width)}×${Math.round(nb.height)}`);
28431
+ if (Math.abs(ob.x - nb.x) > 0.5 || Math.abs(ob.y - nb.y) > 0.5)
28432
+ c.push(`moved ${Math.round(nb.x - ob.x)}, ${Math.round(nb.y - ob.y)}`);
28433
+ }
28434
+ if (fillsOf(old) !== fillsOf(now))
28435
+ c.push(`fill ${fillsOf(old) || "none"} → ${fillsOf(now) || "none"}`);
28436
+ if (c.length)
28437
+ changed.push({ path, type: now.type, id: now.id, changes: c });
28438
+ }
28439
+ const movedOnly = (x) => x.changes.length === 1 && x.changes[0].startsWith("moved");
28440
+ const movedPaths = new Set(changed.filter(movedOnly).map((x) => x.path));
28441
+ const cleaned = changed.filter((x) => !(movedOnly(x) && [...movedPaths].some((p) => p !== x.path && x.path.startsWith(`${p}/`))));
28442
+ const top = (list) => list.filter((x) => !list.some((y) => y !== x && x.path.startsWith(`${y.path}/`)));
28443
+ const addedTop = top(added);
28444
+ const removedTop = top(removed);
28445
+ return {
28446
+ summary: { added: addedTop.length, removed: removedTop.length, changed: cleaned.length, layersBefore: a.size, layersAfter: b.size },
28447
+ added: addedTop.slice(0, max),
28448
+ removed: removedTop.slice(0, max),
28449
+ changed: cleaned.slice(0, max),
28450
+ truncated: addedTop.length > max || removedTop.length > max || cleaned.length > max
28451
+ };
28452
+ }
28453
+ var TOKEN_HELP, RestError, hex = (c) => "#" + [c.r, c.g, c.b].map((v) => Math.round(v * 255).toString(16).padStart(2, "0")).join("").toUpperCase() + (c.a < 0.999 ? Math.round(c.a * 255).toString(16).padStart(2, "0").toUpperCase() : ""), fillsOf = (n) => (n.fills ?? []).filter((f) => f.visible !== false).map((f) => f.type === "SOLID" && f.color ? hex(f.color) : f.type.toLowerCase()).join(",");
28454
+ var init_rest = __esm(() => {
28455
+ TOKEN_HELP = "These features use Figma's REST API and need a personal access token. In Figma: Settings → Security → " + "Personal access tokens → Generate (scopes: file_content:read, file_comments:write, file_versions:read). " + 'Then add it to the Figma Bridge entry of your AI client config: "env": {"FIGMA_TOKEN": "figd_…"}, and restart the client.';
28456
+ RestError = class RestError extends Error {
28457
+ code;
28458
+ status;
28459
+ constructor(message, code, status) {
28460
+ super(message);
28461
+ this.code = code;
28462
+ this.status = status;
28463
+ }
28464
+ };
28465
+ });
28466
+
26802
28467
  // src/project/detect.ts
26803
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
26804
- import { join as join2, relative, sep } from "node:path";
28468
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
28469
+ import { join as join3, relative, sep } from "node:path";
26805
28470
  function listFiles(root, filter, max = 4000) {
26806
28471
  const out = [];
26807
28472
  const walk = (dir, depth) => {
@@ -26816,7 +28481,7 @@ function listFiles(root, filter, max = 4000) {
26816
28481
  for (const name of entries.sort()) {
26817
28482
  if (IGNORE.has(name) || name.startsWith(".") && name !== ".storybook")
26818
28483
  continue;
26819
- const full = join2(dir, name);
28484
+ const full = join3(dir, name);
26820
28485
  let st;
26821
28486
  try {
26822
28487
  st = statSync(full);
@@ -26839,19 +28504,19 @@ function listFiles(root, filter, max = 4000) {
26839
28504
  }
26840
28505
  function readJsonc(path) {
26841
28506
  try {
26842
- const text = readFileSync2(path, "utf8").replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, str) => str ?? "").replace(/,(\s*[}\]])/g, "$1");
28507
+ const text = readFileSync3(path, "utf8").replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, str) => str ?? "").replace(/,(\s*[}\]])/g, "$1");
26843
28508
  return JSON.parse(text);
26844
28509
  } catch {
26845
28510
  return null;
26846
28511
  }
26847
28512
  }
26848
28513
  function detectStack(root) {
26849
- const pkg = readJsonc(join2(root, "package.json"));
28514
+ const pkg = readJsonc(join3(root, "package.json"));
26850
28515
  if (!pkg)
26851
28516
  throw new Error(`No package.json in ${root}: give the root folder of a JavaScript project as projectPath.`);
26852
28517
  const deps = { ...pkg.peerDependencies, ...pkg.devDependencies, ...pkg.dependencies };
26853
28518
  const has = (d) => deps[d] !== undefined;
26854
- const at = (...p) => existsSync2(join2(root, ...p));
28519
+ const at = (...p) => existsSync2(join3(root, ...p));
26855
28520
  const evidence = [];
26856
28521
  let kind = "html";
26857
28522
  let framework = "html";
@@ -26887,7 +28552,7 @@ function detectStack(root) {
26887
28552
  styling = "css-modules";
26888
28553
  evidence.push(`styling: ${styling}`);
26889
28554
  const ui = [];
26890
- const shadcn = readJsonc(join2(root, "components.json"));
28555
+ const shadcn = readJsonc(join3(root, "components.json"));
26891
28556
  if (shadcn?.aliases || has("class-variance-authority") && sourceFiles.some((f) => /components\/ui\/button\.(t|j)sx$/.test(f)))
26892
28557
  ui.push("shadcn");
26893
28558
  if (has("@mui/material"))
@@ -26896,7 +28561,7 @@ function detectStack(root) {
26896
28561
  ui.push("chakra");
26897
28562
  if (ui.length)
26898
28563
  evidence.push(`ui: ${ui.join(", ")}`);
26899
- const tsconfig = readJsonc(join2(root, "tsconfig.json")) ?? readJsonc(join2(root, "jsconfig.json"));
28564
+ const tsconfig = readJsonc(join3(root, "tsconfig.json")) ?? readJsonc(join3(root, "jsconfig.json"));
26900
28565
  const typescript = at("tsconfig.json") || has("typescript");
26901
28566
  const aliases = [];
26902
28567
  const paths = tsconfig?.compilerOptions?.paths ?? {};
@@ -26905,7 +28570,7 @@ function detectStack(root) {
26905
28570
  const target = Array.isArray(targets) ? String(targets[0]) : "";
26906
28571
  if (!key.endsWith("/*") || !target.endsWith("/*"))
26907
28572
  continue;
26908
- const t = join2(baseUrl, target.slice(0, -2)).split(sep).join("/").replace(/^\.\//, "");
28573
+ const t = join3(baseUrl, target.slice(0, -2)).split(sep).join("/").replace(/^\.\//, "");
26909
28574
  aliases.push({ prefix: key.slice(0, -1), target: t === "." ? "" : `${t}/` });
26910
28575
  }
26911
28576
  if (kind === "nuxt" && !aliases.length)
@@ -26942,8 +28607,8 @@ var init_detect = __esm(() => {
26942
28607
  });
26943
28608
 
26944
28609
  // src/project/components.ts
26945
- import { readFileSync as readFileSync3 } from "node:fs";
26946
- import { basename, join as join3 } from "node:path";
28610
+ import { readFileSync as readFileSync4 } from "node:fs";
28611
+ import { basename, join as join4 } from "node:path";
26947
28612
  function block(text, start, open = "{", close = "}") {
26948
28613
  let depth = 0;
26949
28614
  for (let i = start;i < text.length; i++) {
@@ -27123,7 +28788,7 @@ function indexComponents(stack, max = 400) {
27123
28788
  for (const file of files) {
27124
28789
  let src;
27125
28790
  try {
27126
- src = readFileSync3(join3(stack.root, file), "utf8");
28791
+ src = readFileSync4(join4(stack.root, file), "utf8");
27127
28792
  } catch {
27128
28793
  continue;
27129
28794
  }
@@ -27174,14 +28839,14 @@ var init_components = __esm(() => {
27174
28839
  });
27175
28840
 
27176
28841
  // src/project/mapping.ts
27177
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
27178
- import { join as join4 } from "node:path";
28842
+ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "node:fs";
28843
+ import { join as join5 } from "node:path";
27179
28844
  function loadMapFile(root) {
27180
- const path = join4(root, MAP_FILE);
28845
+ const path = join5(root, MAP_FILE);
27181
28846
  if (!existsSync3(path))
27182
28847
  return { map: {} };
27183
28848
  try {
27184
- return { map: JSON.parse(readFileSync4(path, "utf8")), path };
28849
+ return { map: JSON.parse(readFileSync5(path, "utf8")), path };
27185
28850
  } catch (e) {
27186
28851
  return { map: {}, error: `${MAP_FILE} is not valid JSON: ${e.message}` };
27187
28852
  }
@@ -27338,8 +29003,8 @@ var init_mapping = __esm(() => {
27338
29003
  });
27339
29004
 
27340
29005
  // src/project/tokens.ts
27341
- import { readFileSync as readFileSync5 } from "node:fs";
27342
- import { join as join5 } from "node:path";
29006
+ import { readFileSync as readFileSync6 } from "node:fs";
29007
+ import { join as join6 } from "node:path";
27343
29008
  function oklch(l, c, h, a) {
27344
29009
  const hr = h * Math.PI / 180;
27345
29010
  return oklab(l, c * Math.cos(hr), c * Math.sin(hr), a);
@@ -27426,7 +29091,7 @@ function loadTokens(stack) {
27426
29091
  for (const file of stack.cssFiles) {
27427
29092
  let css;
27428
29093
  try {
27429
- css = readFileSync5(join5(stack.root, file), "utf8");
29094
+ css = readFileSync6(join6(stack.root, file), "utf8");
27430
29095
  } catch {
27431
29096
  continue;
27432
29097
  }
@@ -27470,7 +29135,7 @@ function loadTokens(stack) {
27470
29135
  }
27471
29136
  if (stack.tailwind === 3 && stack.tailwindConfig) {
27472
29137
  try {
27473
- for (const t of tailwind3Colors(readFileSync5(join5(stack.root, stack.tailwindConfig), "utf8")))
29138
+ for (const t of tailwind3Colors(readFileSync6(join6(stack.root, stack.tailwindConfig), "utf8")))
27474
29139
  index.add(t);
27475
29140
  index.sources.push(stack.tailwindConfig);
27476
29141
  } catch {}
@@ -27534,8 +29199,8 @@ var init_tokens2 = __esm(() => {
27534
29199
  });
27535
29200
 
27536
29201
  // src/project/plan.ts
27537
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
27538
- import { dirname as dirname2, join as join6, posix } from "node:path";
29202
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
29203
+ import { dirname as dirname3, join as join7, posix } from "node:path";
27539
29204
  function planProjectExport(root, tree, assets, opts = {}) {
27540
29205
  const stack = detectStack(root);
27541
29206
  if (opts.framework)
@@ -27607,19 +29272,19 @@ function planProjectExport(root, tree, assets, opts = {}) {
27607
29272
  }
27608
29273
  function writePlan(root, files, overwrite) {
27609
29274
  return files.map((f) => {
27610
- const target = join6(root, f.path);
29275
+ const target = join7(root, f.path);
27611
29276
  const next = typeof f.content === "string" ? Buffer.from(f.content) : Buffer.from(f.content);
27612
29277
  if (existsSync4(target)) {
27613
- const before = readFileSync6(target);
29278
+ const before = readFileSync7(target);
27614
29279
  if (before.equals(next))
27615
29280
  return { path: f.path, status: "unchanged" };
27616
29281
  if (!overwrite)
27617
29282
  return { path: f.path, status: "skipped (exists)" };
27618
- writeFileSync2(target, next);
29283
+ writeFileSync4(target, next);
27619
29284
  return { path: f.path, status: "updated" };
27620
29285
  }
27621
- mkdirSync2(dirname2(target), { recursive: true });
27622
- writeFileSync2(target, next);
29286
+ mkdirSync4(dirname3(target), { recursive: true });
29287
+ writeFileSync4(target, next);
27623
29288
  return { path: f.path, status: "created" };
27624
29289
  });
27625
29290
  }
@@ -27688,7 +29353,7 @@ class FontIndex {
27688
29353
  }
27689
29354
  }
27690
29355
  var GENERIC, STYLE_WEIGHTS;
27691
- var init_fonts = __esm(() => {
29356
+ var init_fonts2 = __esm(() => {
27692
29357
  GENERIC = {
27693
29358
  "sans-serif": ["Inter", "Roboto", "Arial", "Helvetica"],
27694
29359
  "system-ui": ["Inter", "SF Pro Text", "Segoe UI", "Roboto", "Arial"],
@@ -27838,7 +29503,7 @@ function cssLiteral(v, value) {
27838
29503
  if (typeof value === "boolean")
27839
29504
  return null;
27840
29505
  if (typeof value === "number")
27841
- return unitless(v) ? num(value) : `${num(value)}px`;
29506
+ return unitless(v) ? num2(value) : `${num2(value)}px`;
27842
29507
  if (v.type === "string" && category(v) === "fontFamily")
27843
29508
  return /^[\w-]+$/.test(String(value)) ? String(value) : quote(String(value));
27844
29509
  if (v.type === "string")
@@ -27846,12 +29511,12 @@ function cssLiteral(v, value) {
27846
29511
  return String(value);
27847
29512
  }
27848
29513
  function gradientCss(g) {
27849
- const stops = g.gradient.map((s) => `${s.color} ${num(s.at * 100)}%`).join(", ");
29514
+ const stops = g.gradient.map((s) => `${s.color} ${num2(s.at * 100)}%`).join(", ");
27850
29515
  if (g.type === "radial")
27851
29516
  return `radial-gradient(circle, ${stops})`;
27852
29517
  if (g.type)
27853
29518
  return `conic-gradient(${stops})`;
27854
- return `linear-gradient(${num((g.angle ?? 0) + 90)}deg, ${stops})`;
29519
+ return `linear-gradient(${num2((g.angle ?? 0) + 90)}deg, ${stops})`;
27855
29520
  }
27856
29521
  function paintCss(value, ref) {
27857
29522
  const one = (p) => typeof p === "string" ? p : p && p.alias ? ref(p.alias) : p && p.gradient ? gradientCss(p) : "transparent";
@@ -27860,7 +29525,7 @@ function paintCss(value, ref) {
27860
29525
  return value.slice().reverse().map((p) => typeof p === "string" || p && p.alias ? `linear-gradient(${one(p)}, ${one(p)})` : one(p)).join(", ");
27861
29526
  }
27862
29527
  function shadowCss(effects, ref) {
27863
- const list = effects.filter((e) => e.blur !== undefined && e.type === undefined).reverse().map((e) => `${e.inner ? "inset " : ""}${num(e.x)}px ${num(e.y)}px ${num(e.blur)}px ${num(e.spread ?? 0)}px ${e.colorVariable ? ref(e.colorVariable) : e.color}`);
29528
+ const list = effects.filter((e) => e.blur !== undefined && e.type === undefined).reverse().map((e) => `${e.inner ? "inset " : ""}${num2(e.x)}px ${num2(e.y)}px ${num2(e.blur)}px ${num2(e.spread ?? 0)}px ${e.colorVariable ? ref(e.colorVariable) : e.color}`);
27864
29529
  return list.length ? list.join(", ") : null;
27865
29530
  }
27866
29531
  function textDecls(t, ref) {
@@ -27869,7 +29534,7 @@ function textDecls(t, ref) {
27869
29534
  const d = [
27870
29535
  ["font-family", bound.fontFamily ? ref(bound.fontFamily) : `${/^[\w-]+$/.test(t.font) ? t.font : quote(t.font)}, sans-serif`],
27871
29536
  ["font-weight", bound.fontWeight ? ref(bound.fontWeight) : String(w.weight)],
27872
- ["font-size", bound.fontSize ? ref(bound.fontSize) : `${num(t.size)}px`]
29537
+ ["font-size", bound.fontSize ? ref(bound.fontSize) : `${num2(t.size)}px`]
27873
29538
  ];
27874
29539
  if (w.italic)
27875
29540
  d.push(["font-style", "italic"]);
@@ -28146,7 +29811,7 @@ ${textDecls(t, ref).map(([k, v]) => ` ${k}: ${v};`).join(`
28146
29811
  }
28147
29812
  function tsLiteral(value) {
28148
29813
  if (typeof value === "number")
28149
- return num(value);
29814
+ return num2(value);
28150
29815
  if (typeof value === "boolean")
28151
29816
  return String(value);
28152
29817
  return quote(String(value));
@@ -28266,7 +29931,7 @@ function dtcgType(v) {
28266
29931
  function toDtcg(model) {
28267
29932
  const out = {};
28268
29933
  const dtcgRef = (a) => `{${[a.collection, ...a.name.split("/")].join(".")}}`;
28269
- const literal = (v, x) => isAlias(x) ? dtcgRef(x.alias) : dtcgType(v) === "dimension" ? `${num(Number(x))}px` : x;
29934
+ const literal = (v, x) => isAlias(x) ? dtcgRef(x.alias) : dtcgType(v) === "dimension" ? `${num2(Number(x))}px` : x;
28270
29935
  for (const c of model.data.collections) {
28271
29936
  const group = { $extensions: { "figma-bridge": { collection: true, modes: c.modes } } };
28272
29937
  for (const v of c.variables) {
@@ -28297,9 +29962,9 @@ function toDtcg(model) {
28297
29962
  }
28298
29963
  for (const t of s.text) {
28299
29964
  const w = styleWeight(t.style);
28300
- const value = { fontFamily: t.font, fontWeight: w.weight, fontSize: `${num(t.size)}px` };
29965
+ const value = { fontFamily: t.font, fontWeight: w.weight, fontSize: `${num2(t.size)}px` };
28301
29966
  if (t.lineHeight !== undefined)
28302
- value.lineHeight = typeof t.lineHeight === "number" ? `${num(t.lineHeight)}px` : t.lineHeight;
29967
+ value.lineHeight = typeof t.lineHeight === "number" ? `${num2(t.lineHeight)}px` : t.lineHeight;
28303
29968
  if (t.letterSpacing !== undefined)
28304
29969
  value.letterSpacing = letterSpacingCss(t.letterSpacing);
28305
29970
  const token = { $type: "typography", $value: value, $extensions: { "figma-bridge": { style: t.style } } };
@@ -28313,7 +29978,7 @@ function toDtcg(model) {
28313
29978
  continue;
28314
29979
  const token = {
28315
29980
  $type: "shadow",
28316
- $value: shadows.map((x) => ({ color: x.color, offsetX: `${num(x.x)}px`, offsetY: `${num(x.y)}px`, blur: `${num(x.blur)}px`, spread: `${num(x.spread ?? 0)}px`, ...x.inner ? { inset: true } : {} }))
29981
+ $value: shadows.map((x) => ({ color: x.color, offsetX: `${num2(x.x)}px`, offsetY: `${num2(x.y)}px`, blur: `${num2(x.blur)}px`, spread: `${num2(x.spread ?? 0)}px`, ...x.inner ? { inset: true } : {} }))
28317
29982
  };
28318
29983
  if (e.description)
28319
29984
  token.$description = e.description;
@@ -28360,9 +30025,9 @@ function exportTokenFiles(data, formats, opts = {}) {
28360
30025
  }
28361
30026
  return { files, warnings: [...data.warnings ?? [], ...model.warnings] };
28362
30027
  }
28363
- var FORMATS, FILE_NAMES, isAlias = (v) => typeof v === "object" && v !== null && ("alias" in v), kebab2 = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), camel2 = (s) => kebab2(s).replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()), num = (n) => String(Math.round(n * 1000) / 1000), quote = (s) => JSON.stringify(s), qualified = (collection, name) => `${collection}/${name}`, CATEGORY_WORDS, letterSpacingCss = (v) => v === undefined ? undefined : typeof v === "string" && v.endsWith("%") ? `${num(parseFloat(v) / 100)}em` : `${num(Number(v))}px`, lineHeightCss = (v) => v === undefined ? undefined : typeof v === "string" ? v : `${num(v)}px`, TW3_KEYS, TW4_NS;
30028
+ var FORMATS, FILE_NAMES, isAlias = (v) => typeof v === "object" && v !== null && ("alias" in v), kebab2 = (s) => s.normalize("NFKD").replace(/[̀-ͯ]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), camel2 = (s) => kebab2(s).replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()), num2 = (n) => String(Math.round(n * 1000) / 1000), quote = (s) => JSON.stringify(s), qualified = (collection, name) => `${collection}/${name}`, CATEGORY_WORDS, letterSpacingCss = (v) => v === undefined ? undefined : typeof v === "string" && v.endsWith("%") ? `${num2(parseFloat(v) / 100)}em` : `${num2(Number(v))}px`, lineHeightCss = (v) => v === undefined ? undefined : typeof v === "string" ? v : `${num2(v)}px`, TW3_KEYS, TW4_NS;
28364
30029
  var init_tokens_export = __esm(() => {
28365
- init_fonts();
30030
+ init_fonts2();
28366
30031
  FORMATS = ["dtcg", "css", "tailwind", "tailwind4", "scss", "ts", "json"];
28367
30032
  FILE_NAMES = {
28368
30033
  dtcg: "tokens.json",
@@ -29975,16 +31640,16 @@ var BUILD_LIMIT = 2800, isSvg = (bytes) => /^\s*(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-
29975
31640
  var init_import = __esm(() => {
29976
31641
  init_browser();
29977
31642
  init_convert();
29978
- init_fonts();
31643
+ init_fonts2();
29979
31644
  });
29980
31645
 
29981
31646
  // src/snippets.ts
29982
- import { createHash } from "node:crypto";
29983
- import { mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync7, rmSync, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
29984
- import { homedir } from "node:os";
29985
- import { join as join7 } from "node:path";
31647
+ import { createHash as createHash2 } from "node:crypto";
31648
+ import { mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync8, rmSync, statSync as statSync2, writeFileSync as writeFileSync5 } from "node:fs";
31649
+ import { homedir as homedir2 } from "node:os";
31650
+ import { join as join8 } from "node:path";
29986
31651
  function ensureDir() {
29987
- mkdirSync3(SNIPPETS_DIR, { recursive: true });
31652
+ mkdirSync5(SNIPPETS_DIR, { recursive: true });
29988
31653
  }
29989
31654
  function checkName(name) {
29990
31655
  if (!NAME.test(name))
@@ -29999,14 +31664,14 @@ function files() {
29999
31664
  }
30000
31665
  function listSnippets() {
30001
31666
  return files().map((f) => {
30002
- const source = readFileSync7(join7(SNIPPETS_DIR, f), "utf8");
31667
+ const source = readFileSync8(join8(SNIPPETS_DIR, f), "utf8");
30003
31668
  return { name: f.slice(0, -3), description: header2(source, "description"), usage: header2(source, "usage") };
30004
31669
  });
30005
31670
  }
30006
31671
  function getSnippet(name) {
30007
31672
  checkName(name);
30008
31673
  try {
30009
- return readFileSync7(join7(SNIPPETS_DIR, `${name}.js`), "utf8");
31674
+ return readFileSync8(join8(SNIPPETS_DIR, `${name}.js`), "utf8");
30010
31675
  } catch {
30011
31676
  throw new BridgeError(`No snippet named "${name}".`, "NOT_FOUND");
30012
31677
  }
@@ -30019,37 +31684,37 @@ function saveSnippet(name, code, description = "", usage = "") {
30019
31684
  // @usage ${usage.replace(/\n/g, " ") || `await lib.${name}(args)`}
30020
31685
  ${body}
30021
31686
  `;
30022
- const path = join7(SNIPPETS_DIR, `${name}.js`);
30023
- writeFileSync3(path, text);
31687
+ const path = join8(SNIPPETS_DIR, `${name}.js`);
31688
+ writeFileSync5(path, text);
30024
31689
  return path;
30025
31690
  }
30026
31691
  function deleteSnippet(name) {
30027
31692
  checkName(name);
30028
- rmSync(join7(SNIPPETS_DIR, `${name}.js`), { force: true });
31693
+ rmSync(join8(SNIPPETS_DIR, `${name}.js`), { force: true });
30029
31694
  }
30030
31695
  function loadLibrary() {
30031
31696
  const list = files();
30032
- const stamp = list.map((f) => `${f}:${statSync2(join7(SNIPPETS_DIR, f)).mtimeMs}`).join("|");
31697
+ const stamp = list.map((f) => `${f}:${statSync2(join8(SNIPPETS_DIR, f)).mtimeMs}`).join("|");
30033
31698
  if (cached2 && cached2.stamp === stamp)
30034
31699
  return cached2;
30035
31700
  const lib = {};
30036
31701
  for (const f of list)
30037
- lib[f.slice(0, -3)] = readFileSync7(join7(SNIPPETS_DIR, f), "utf8");
30038
- cached2 = { stamp, hash: stamp ? createHash("sha1").update(stamp + JSON.stringify(lib)).digest("base64url").slice(0, 12) : "empty", lib };
31702
+ lib[f.slice(0, -3)] = readFileSync8(join8(SNIPPETS_DIR, f), "utf8");
31703
+ cached2 = { stamp, hash: stamp ? createHash2("sha1").update(stamp + JSON.stringify(lib)).digest("base64url").slice(0, 12) : "empty", lib };
30039
31704
  return cached2;
30040
31705
  }
30041
31706
  var SNIPPETS_DIR, NAME, cached2 = null;
30042
31707
  var init_snippets = __esm(() => {
30043
31708
  init_bridge();
30044
- SNIPPETS_DIR = process.env.FIGMA_BRIDGE_SNIPPETS || join7(homedir(), ".figma-bridge", "snippets");
31709
+ SNIPPETS_DIR = process.env.FIGMA_BRIDGE_SNIPPETS || join8(homedir2(), ".figma-bridge", "snippets");
30045
31710
  NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
30046
31711
  });
30047
31712
 
30048
31713
  // src/server.ts
30049
- import { appendFileSync, mkdirSync as mkdirSync4, statSync as statSync3, truncateSync, writeFileSync as writeFileSync4 } from "node:fs";
31714
+ import { appendFileSync, mkdirSync as mkdirSync6, statSync as statSync3, truncateSync, writeFileSync as writeFileSync6 } from "node:fs";
30050
31715
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
30051
31716
  import { tmpdir } from "node:os";
30052
- import { dirname as dirname3, join as join8 } from "node:path";
31717
+ import { dirname as dirname4, join as join9 } from "node:path";
30053
31718
  import { fileURLToPath } from "node:url";
30054
31719
  function log(line) {
30055
31720
  const text = `[${new Date().toISOString().slice(11, 23)}] ${line}`;
@@ -30140,6 +31805,12 @@ async function prepareSpec(spec, defaultColor) {
30140
31805
  return;
30141
31806
  if (Array.isArray(node))
30142
31807
  return node.forEach(walk);
31808
+ if (String(node.type ?? "").toLowerCase() === "diagram") {
31809
+ const expanded = diagramSpec(node);
31810
+ for (const k of Object.keys(node))
31811
+ delete node[k];
31812
+ Object.assign(node, expanded);
31813
+ }
30143
31814
  if (node.icon && !node.svg) {
30144
31815
  const size = typeof node.size === "number" ? node.size : 24;
30145
31816
  jobs.push(iconSvg(String(node.icon), size, typeof node.color === "string" ? node.color : defaultColor).then((svg) => void (node.svg = svg)));
@@ -30164,6 +31835,8 @@ async function prepareSpec(spec, defaultColor) {
30164
31835
  node.children.forEach(walk);
30165
31836
  if (Array.isArray(node.variants))
30166
31837
  node.variants.forEach(walk);
31838
+ if (Array.isArray(node.slides))
31839
+ node.slides.forEach(walk);
30167
31840
  if (node.base)
30168
31841
  walk(node.base);
30169
31842
  };
@@ -30175,7 +31848,7 @@ async function writeIfChanged(path, content) {
30175
31848
  const before = await readFile(path, "utf8").catch(() => null);
30176
31849
  if (before === content)
30177
31850
  return "unchanged";
30178
- await mkdir(dirname3(path), { recursive: true });
31851
+ await mkdir(dirname4(path), { recursive: true });
30179
31852
  await writeFile(path, content);
30180
31853
  return before === null ? "created" : "updated";
30181
31854
  }
@@ -30189,7 +31862,46 @@ function renameAssets(n, names) {
30189
31862
  out.children = n.children.map((c) => renameAssets(c, names));
30190
31863
  return out;
30191
31864
  }
30192
- 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", server, shutdown = () => process.exit(0);
31865
+ async function restContext(fileUrl) {
31866
+ const token = process.env.FIGMA_TOKEN;
31867
+ if (!token)
31868
+ throw new BridgeError(TOKEN_HELP, "NO_TOKEN");
31869
+ const session = await bridge.current().catch(() => null);
31870
+ let key = fileUrl ? fileKeyFrom(fileUrl) : null;
31871
+ if (fileUrl && !key)
31872
+ throw new BridgeError(`Not a Figma file link or key: ${fileUrl}`, "BAD_ARGS");
31873
+ key ??= session?.fileKey ?? null;
31874
+ if (!key) {
31875
+ throw new BridgeError("Unknown file key: the plugin can't read it here (Figma gives it only to development and private plugins). Pass fileUrl: the file's link (Share → Copy link).", "NO_FILE_KEY");
31876
+ }
31877
+ return { rest: new FigmaRest(token), key, session: session?.fileKey === key ? session : null };
31878
+ }
31879
+ async function layerNames(session, ids) {
31880
+ if (!session || !ids.length)
31881
+ return {};
31882
+ return bridge.request("node_info", { ids: [...new Set(ids)] }, 20000).catch(() => ({}));
31883
+ }
31884
+ 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.
31885
+ 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.
31886
+ 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.
31887
+ 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.
31888
+ 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.
31889
+ TEXT: text, font ("Inter" | "Inter:Bold"), weight (400|500|600|700 or style name), size, color, lineHeight (1.5 | 24 | "150%"), letterSpacing (px | "2%"), align (left|center|right|justify), case (upper|lower|title), decoration (underline|strike), maxLines, textStyle "style:Name". Give w:"fill" or a number to wrap text.
31890
+ RICH TEXT: spans:[{text:"Read the "},{text:"docs",weight:600,color:"#0D99FF",link:"https://…"}] instead of text; a span may set font, weight, size, color, decoration, case, link.
31891
+ PAINT (fill, stroke, color): "#RRGGBB[AA]", "style:<paint style>", "var:<color variable>", {gradient:["#a","#b"], angle:90, type?:"radial"}, {image:"https://…"|path, fit:"fill"|"fit"|"crop"|"tile"}, null, or an array (last on top). stroke + strokeWidth (n | [t,r,b,l]) + strokeAlign (inside|center|outside) + strokeDash [dash,gap].
31892
+ EFFECTS: radius (n | [tl,tr,br,bl] | "var:x"), opacity, shadow (true | {x,y,blur,spread,color} | [...] | "style:Name"), blur, backgroundBlur, rotation, visible.
31893
+ ICON: {icon:"lucide:house", size:20, color:"#111"} (any Iconify set). IMAGE: {src:"C:/img.png" | "https://…", w, h, fit:"fill"|"fit"|"crop"|"tile"}. SVG: {svg:"<svg…>"}.
31894
+ INSTANCE: {component:"Button" | node id | library key, props:{Variant:"Primary", Label:"Buy"}, text:{"Label layer name":"Buy"}}.
31895
+ COMPONENT SET: {type:"componentSet", name:"Button", base:{shared frame spec}, variants:[{props:{Variant:"Primary",Size:"M"}, fill:"#0D99FF", children:[…]}, …], properties:{Label:"Button", "Show icon":{type:"boolean",default:true}, Icon:{type:"instance",default:"Icon/Star"}}}. One component per variant, combined as variants.
31896
+ On a layer inside a component: bind:"Label" links a text to a text property (created if missing); bind:{visible:"Show icon"} or {mainComponent:"Icon"} for the others. type:"component" takes properties too.
31897
+ PROTOTYPE: reactions:[{trigger:"click", action:"navigate", to:"Details", transition:"smart", duration:300}] on any layer (see the prototype tool).
31898
+ SECTION: {type:"section", name, children (placed with x/y), w?, h?} (wraps its children when no size).
31899
+ FIGJAM files: {type:"sticky", text, color:"yellow"|"blue"|…|"#hex", wide?}, {type:"shape", shape:"rounded"|"square"|"ellipse"|"diamond"|"database"|"hexagon"|…, text, w, h, fill, stroke}, {type:"connector", from, to, label?, line:"elbowed"|"straight"|"curved", dashed?, endArrow?:"arrow"|"none"} (from/to: layer name, key or id; any node can set key:"a"), {type:"table", rows:[["Name","Role"],["Ada","Eng"]]}, {type:"codeBlock", code, language:"typescript"}.
31900
+ DIAGRAM (FigJam): {type:"diagram", source:"flowchart LR\\n A[Start] --> B{Valid?}\\n B -->|yes| C(Done)\\n B -.->|no| A", as?:"shapes"|"stickies", name?} lays out a Mermaid flowchart (shapes [] () {} (()) [()] {{}}, edges --> --- -.-> ==> with |labels|, subgraphs → sections).
31901
+ SLIDES files: {slides:[{name, fill?, children:[…]}, …]} or {type:"slide", …}: one 1920×1080 slide per entry, children placed with x/y or auto-layout.
31902
+ gap/padding/radius accept "var:<number variable>". MODES: modes:{"Theme":"Dark"} sets a collection's variable mode on a frame and its children.
31903
+ Example: {"name":"Card","layout":"column","w":320,"padding":24,"gap":12,"fill":"#FFFFFF","radius":16,"shadow":true,"children":[{"text":"Pro plan","size":20,"weight":600},{"text":"Everything you need","color":"#6B7280","w":"fill"},{"layout":"row","gap":8,"align":"center","children":[{"icon":"lucide:check","size":16,"color":"#16A34A"},{"text":"Unlimited projects"}]}]}
31904
+ Returns {rootId, ids:{layerName:id}, created, warnings}. The result is selected and zoomed to unless select:false.`, server, SESSION_TOOLS, registerTool, toolDocs, shutdown = () => process.exit(0);
30193
31905
  var init_server3 = __esm(async () => {
30194
31906
  init_mcp();
30195
31907
  init_stdio2();
@@ -30198,6 +31910,10 @@ var init_server3 = __esm(async () => {
30198
31910
  init_icons();
30199
31911
  init_image();
30200
31912
  init_codegen();
31913
+ init_diagram();
31914
+ init_guides();
31915
+ init_export();
31916
+ init_rest();
30201
31917
  init_plan();
30202
31918
  init_tokens();
30203
31919
  init_tokens_export();
@@ -30206,38 +31922,58 @@ var init_server3 = __esm(async () => {
30206
31922
  init_package();
30207
31923
  PORT = Number(process.env.FIGMA_BRIDGE_PORT) || 3055;
30208
31924
  CHANNEL = process.env.FIGMA_BRIDGE_CHANNEL || "default";
30209
- OUT_DIR = process.env.FIGMA_BRIDGE_OUT || join8(tmpdir(), "figma-bridge");
30210
- LOG_FILE = join8(OUT_DIR, "bridge.log");
31925
+ OUT_DIR = process.env.FIGMA_BRIDGE_OUT || join9(tmpdir(), "figma-bridge");
31926
+ LOG_FILE = join9(OUT_DIR, "bridge.log");
30211
31927
  MAX_IMAGE_BYTES = 50 << 20;
30212
- mkdirSync4(OUT_DIR, { recursive: true });
31928
+ mkdirSync6(OUT_DIR, { recursive: true });
30213
31929
  try {
30214
31930
  if (statSync3(LOG_FILE).size > 5 << 20)
30215
31931
  truncateSync(LOG_FILE, 0);
30216
31932
  } catch {}
30217
31933
  bridge = new Bridge({ port: PORT, channel: CHANNEL, version, log });
30218
31934
  bridge.start();
31935
+ INSTRUCTIONS = [
31936
+ "Figma Bridge drives the user's Figma desktop app through a local plugin. No rate limits: iterate freely.",
31937
+ "Workflow:",
31938
+ "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.",
31939
+ "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.",
31940
+ "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.",
31941
+ "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.",
31942
+ "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.",
31943
+ "5. Before risky changes to existing work → checkpoint {action:'save'}; restore if the result is worse. Each command is one Ctrl+Z step for the user.",
31944
+ "Existing website or HTML → import_web turns it into editable auto-layout frames (one per viewport); then compare against the returned reference screenshot.",
31945
+ "When the target is unclear, wait_for_selection asks the user to pick it in Figma. prototype links screens, annotate leaves Dev Mode notes, export_code turns a frame into code; give it projectPath to reuse the project's components and tokens.",
31946
+ "run_script: `figma` global, top-level await, `return` a small JSON value (nodes come back as {id,name,type}).",
31947
+ "Script helpers: utils.loadFonts('Inter:Bold', …), utils.node(id), utils.page(name), utils.hex('#hex'), utils.solid('#hex', opacity?),",
31948
+ "utils.build(spec, {parentId}), utils.describe(nodeOrId, depth); lib.<name>(args) runs a saved snippet; console.log is returned as `logs`.",
31949
+ "Save helpers you will reuse with the snippets tool. Icons: search_icons, then insert_icon or {icon:'set:name'} in build.",
31950
+ "Rules: load fonts before editing text; pages load on demand (await figma.setCurrentPageAsync / getNodeByIdAsync); keep results small.",
31951
+ "The user can cancel a command from the plugin (code CANCELLED): build stops, a script may still finish, so check the file before retrying.",
31952
+ "With FIGMA_TOKEN set: comments reads and posts file comments pinned to layers, versions lists the history and diffs a version against now.",
31953
+ "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.",
31954
+ "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.",
31955
+ "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."
31956
+ ];
30219
31957
  server = new McpServer({ name: "figma-bridge", version }, {
30220
- instructions: [
30221
- "Figma Bridge drives the user's Figma desktop app through a local plugin. No rate limits: iterate freely.",
30222
- "Workflow:",
30223
- "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.",
30224
- "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.",
30225
- "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.",
30226
- "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.",
30227
- "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.",
30228
- "5. Before risky changes to existing work → checkpoint {action:'save'}; restore if the result is worse. Each command is one Ctrl+Z step for the user.",
30229
- "Existing website or HTML → import_web turns it into editable auto-layout frames (one per viewport); then compare against the returned reference screenshot.",
30230
- "When the target is unclear, wait_for_selection asks the user to pick it in Figma. prototype links screens, annotate leaves Dev Mode notes, export_code turns a frame into code; give it projectPath to reuse the project's components and tokens.",
30231
- "run_script: `figma` global, top-level await, `return` a small JSON value (nodes come back as {id,name,type}).",
30232
- "Script helpers: utils.loadFonts('Inter:Bold', …), utils.node(id), utils.page(name), utils.hex('#hex'), utils.solid('#hex', opacity?),",
30233
- "utils.build(spec, {parentId}), utils.describe(nodeOrId, depth); lib.<name>(args) runs a saved snippet; console.log is returned as `logs`.",
30234
- "Save helpers you will reuse with the snippets tool. Icons: search_icons, then insert_icon or {icon:'set:name'} in build.",
30235
- "Rules: load fonts before editing text; pages load on demand (await figma.setCurrentPageAsync / getNodeByIdAsync); keep results small.",
30236
- "The user can cancel a command from the plugin (code CANCELLED): build stops, a script may still finish, so check the file before retrying.",
30237
- "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."
30238
- ].join(`
31958
+ instructions: INSTRUCTIONS.join(`
30239
31959
  `)
30240
31960
  });
31961
+ SESSION_TOOLS = new Set(["list_sessions", "select_session"]);
31962
+ registerTool = server.registerTool.bind(server);
31963
+ toolDocs = [];
31964
+ server.registerTool = (name, config, handler) => {
31965
+ toolDocs.push({ name, title: config.title, description: config.description ?? "" });
31966
+ if (SESSION_TOOLS.has(name))
31967
+ return registerTool(name, config, handler);
31968
+ const inputSchema = {
31969
+ ...config.inputSchema,
31970
+ file: string2().optional().describe("Target file name or session id when several files are connected (default: the selected file)")
31971
+ };
31972
+ return registerTool(name, { ...config, inputSchema }, (args, extra) => {
31973
+ const { file, ...rest } = args ?? {};
31974
+ return callTarget.run({ file: typeof file === "string" && file ? file : undefined }, () => handler(rest, extra));
31975
+ });
31976
+ };
30241
31977
  server.registerTool("run_script", {
30242
31978
  title: "Run Figma Plugin API script",
30243
31979
  description: "Execute JavaScript in the Figma plugin main thread (full Plugin API via `figma`). The code is the body of an async " + "function: use top-level await and `return` the output (JSON-serialized; a single expression is returned automatically). " + "Helpers: utils.loadFonts, utils.node, utils.page, utils.hex, utils.solid, utils.build, utils.describe; lib.<snippet>(args); " + "console.log is captured. On error returns {ok:false, error, line, stack}. Prefer build for creating new layouts. " + "If the user presses Cancel in the plugin you get code CANCELLED, but a script cannot be interrupted and may still finish: check the file before retrying.",
@@ -30253,23 +31989,7 @@ var init_server3 = __esm(async () => {
30253
31989
  }));
30254
31990
  server.registerTool("build", {
30255
31991
  title: "Build a layout from a JSON spec",
30256
- 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.
30257
- 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.
30258
- 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.
30259
- 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.
30260
- 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.
30261
- TEXT: text, font ("Inter" | "Inter:Bold"), weight (400|500|600|700 or style name), size, color, lineHeight (1.5 | 24 | "150%"), letterSpacing (px | "2%"), align (left|center|right|justify), case (upper|lower|title), decoration (underline|strike), maxLines, textStyle "style:Name". Give w:"fill" or a number to wrap text.
30262
- RICH TEXT: spans:[{text:"Read the "},{text:"docs",weight:600,color:"#0D99FF",link:"https://…"}] instead of text; a span may set font, weight, size, color, decoration, case, link.
30263
- PAINT (fill, stroke, color): "#RRGGBB[AA]", "style:<paint style>", "var:<color variable>", {gradient:["#a","#b"], angle:90, type?:"radial"}, {image:"https://…"|path, fit:"fill"|"fit"|"crop"|"tile"}, null, or an array (last on top). stroke + strokeWidth (n | [t,r,b,l]) + strokeAlign (inside|center|outside) + strokeDash [dash,gap].
30264
- EFFECTS: radius (n | [tl,tr,br,bl] | "var:x"), opacity, shadow (true | {x,y,blur,spread,color} | [...] | "style:Name"), blur, backgroundBlur, rotation, visible.
30265
- ICON: {icon:"lucide:house", size:20, color:"#111"} (any Iconify set). IMAGE: {src:"C:/img.png" | "https://…", w, h, fit:"fill"|"fit"|"crop"|"tile"}. SVG: {svg:"<svg…>"}.
30266
- INSTANCE: {component:"Button" | node id | library key, props:{Variant:"Primary", Label:"Buy"}, text:{"Label layer name":"Buy"}}.
30267
- COMPONENT SET: {type:"componentSet", name:"Button", base:{shared frame spec}, variants:[{props:{Variant:"Primary",Size:"M"}, fill:"#0D99FF", children:[…]}, …], properties:{Label:"Button", "Show icon":{type:"boolean",default:true}, Icon:{type:"instance",default:"Icon/Star"}}}. One component per variant, combined as variants.
30268
- On a layer inside a component: bind:"Label" links a text to a text property (created if missing); bind:{visible:"Show icon"} or {mainComponent:"Icon"} for the others. type:"component" takes properties too.
30269
- PROTOTYPE: reactions:[{trigger:"click", action:"navigate", to:"Details", transition:"smart", duration:300}] on any layer (see the prototype tool).
30270
- gap/padding/radius accept "var:<number variable>". MODES: modes:{"Theme":"Dark"} sets a collection's variable mode on a frame and its children.
30271
- Example: {"name":"Card","layout":"column","w":320,"padding":24,"gap":12,"fill":"#FFFFFF","radius":16,"shadow":true,"children":[{"text":"Pro plan","size":20,"weight":600},{"text":"Everything you need","color":"#6B7280","w":"fill"},{"layout":"row","gap":8,"align":"center","children":[{"icon":"lucide:check","size":16,"color":"#16A34A"},{"text":"Unlimited projects"}]}]}
30272
- Returns {rootId, ids:{layerName:id}, created, warnings}. The result is selected and zoomed to unless select:false.`,
31992
+ description: BUILD_DESCRIPTION,
30273
31993
  inputSchema: {
30274
31994
  spec: object2({}).passthrough().describe("Root node: {name, layout, children:[...], ...}. Several roots: wrap them in a frame or call build again."),
30275
31995
  parentId: string2().optional().describe("Parent frame (default: current page)"),
@@ -30356,7 +32076,7 @@ ${f.content}`).join(`
30356
32076
  const single = files.length === 1 && /\.[a-z0-9]+$/i.test(args.path);
30357
32077
  const written = [];
30358
32078
  for (const f of files) {
30359
- const target = single ? args.path : join8(args.path, f.path);
32079
+ const target = single ? args.path : join9(args.path, f.path);
30360
32080
  written.push({ format: f.format, path: target, status: await writeIfChanged(target, f.content) });
30361
32081
  }
30362
32082
  return ok({ counts, files: written, ...warnings.length ? { warnings } : {} });
@@ -30394,7 +32114,7 @@ ${f.content}`).join(`
30394
32114
  const bytes = Buffer.from(r.bytesB64, "base64");
30395
32115
  const info = imageInfo(bytes);
30396
32116
  const ext = r.format === "JPG" ? "jpg" : "png";
30397
- const path = join8(OUT_DIR, `${safeName(r.name)}-${safeName(r.nodeId)}-${Date.now()}.${ext}`);
32117
+ const path = join9(OUT_DIR, `${safeName(r.name)}-${safeName(r.nodeId)}-${Date.now()}.${ext}`);
30398
32118
  await writeFile(path, bytes);
30399
32119
  const meta = { path, nodeId: r.nodeId, name: r.name, width: info?.width, height: info?.height, bytes: bytes.length };
30400
32120
  const content = [{ type: "text", text: json(meta) }];
@@ -30421,7 +32141,7 @@ ${f.content}`).join(`
30421
32141
  const diff = await bridge.request("compare_images", { actual: shot.bytesB64, reference: Buffer.from(refBytes).toString("base64"), referenceMime: info.mime, threshold: args.threshold, sideBySide: !!args.returnImage }, 90000);
30422
32142
  const pct = (v) => Math.round(v * 1e4) / 100;
30423
32143
  const toLayer = (v, axis) => Math.round((v / shot.scale + shot.offset[axis]) * 10) / 10;
30424
- const heatmapPath = join8(OUT_DIR, `compare-${safeName(shot.name)}-${Date.now()}.png`);
32144
+ const heatmapPath = join9(OUT_DIR, `compare-${safeName(shot.name)}-${Date.now()}.png`);
30425
32145
  await writeFile(heatmapPath, Buffer.from(diff.heatmap, "base64"));
30426
32146
  const refRatio = diff.reference.width / diff.reference.height;
30427
32147
  const ratio = diff.width / diff.height;
@@ -30495,7 +32215,7 @@ Example: {"url":"https://example.com","viewports":[1440,390]}`,
30495
32215
  const built = await bridge.request("build", { spec: v.spec, images, parentId: args.parentId, x, y, select: false, label: "Web import" }, 120000);
30496
32216
  let reference;
30497
32217
  if (v.screenshot) {
30498
- reference = join8(OUT_DIR, `import-${safeName(v.title || "page")}-${v.viewport}-${Date.now()}.png`);
32218
+ reference = join9(OUT_DIR, `import-${safeName(v.title || "page")}-${v.viewport}-${Date.now()}.png`);
30499
32219
  await writeFile(reference, v.screenshot);
30500
32220
  }
30501
32221
  frames.push({ viewport: v.viewport, rootId: built.rootId, name: v.spec.name, layers: built.created, size: { width: v.width, height: v.height }, reference });
@@ -30565,7 +32285,7 @@ Links are added to existing interactions unless replace:true; clear:[ids] remove
30565
32285
  }
30566
32286
  }, ({ nodeId, projectPath, framework, styling, name, outDir, write, overwrite }) => track("export_code", `${nodeId ?? "(selection)"} ${projectPath ?? `${framework ?? "html"}/${styling ?? "css"}`}`, async () => {
30567
32287
  const r = await bridge.request("export_tree", { nodeId }, 120000);
30568
- const dir = join8(OUT_DIR, `export-${safeName(r.tree.name)}-${Date.now()}`);
32288
+ const dir = join9(OUT_DIR, `export-${safeName(r.tree.name)}-${Date.now()}`);
30569
32289
  const finalName = {};
30570
32290
  const assetBytes = {};
30571
32291
  for (const [file, a] of Object.entries(r.assets)) {
@@ -30581,8 +32301,8 @@ Links are added to existing interactions unless replace:true; clear:[ids] remove
30581
32301
  if (projectPath) {
30582
32302
  const plan = planProjectExport(projectPath, tree, assetBytes, { framework, styling, name, outDir });
30583
32303
  for (const f of plan.files) {
30584
- mkdirSync4(dirname3(join8(dir, f.path)), { recursive: true });
30585
- writeFileSync4(join8(dir, f.path), typeof f.content === "string" ? f.content : Buffer.from(f.content));
32304
+ mkdirSync6(dirname4(join9(dir, f.path)), { recursive: true });
32305
+ writeFileSync6(join9(dir, f.path), typeof f.content === "string" ? f.content : Buffer.from(f.content));
30586
32306
  }
30587
32307
  const written = write ? writePlan(projectPath, plan.files, !!overwrite) : null;
30588
32308
  const s = plan.stack;
@@ -30607,13 +32327,13 @@ ${f.content}`).join(`
30607
32327
  … truncated: the full code is in ${dir}`;
30608
32328
  return { content: [{ type: "text", text: json(meta) }, { type: "text", text }] };
30609
32329
  }
30610
- mkdirSync4(join8(dir, "assets"), { recursive: true });
32330
+ mkdirSync6(join9(dir, "assets"), { recursive: true });
30611
32331
  for (const [file, bytes] of Object.entries(assetBytes))
30612
- writeFileSync4(join8(dir, "assets", file), bytes);
32332
+ writeFileSync6(join9(dir, "assets", file), bytes);
30613
32333
  const fw = framework ?? "html";
30614
32334
  const code = generateCode(tree, { framework: fw, styling: styling ?? "css", assetPath: (f) => `assets/${f}` });
30615
32335
  for (const f of code.files)
30616
- writeFileSync4(join8(dir, f.path), f.content);
32336
+ writeFileSync6(join9(dir, f.path), f.content);
30617
32337
  warnings.push(...code.warnings);
30618
32338
  const meta = {
30619
32339
  dir,
@@ -30762,9 +32482,119 @@ ${f.content}`).join(`
30762
32482
  return ok({ deleted: args.name });
30763
32483
  }
30764
32484
  }));
32485
+ server.registerTool("export_roblox", {
32486
+ title: "Export a frame to Roblox UI",
32487
+ 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.
32488
+ ` + "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.
32489
+ ` + "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"}',
32490
+ inputSchema: {
32491
+ nodeId: string2().optional(),
32492
+ mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default hybrid"),
32493
+ targetResolution: array(number2().int().min(100).max(8000)).length(2).optional().describe("Screen size the UI is designed for, default [1920, 1080]"),
32494
+ rasterize: _enum(["auto", "none", "all"]).optional().describe("auto (default): pictures only where Roblox can't draw it; none: approximate instead; all: every styled layer"),
32495
+ outDir: string2().optional().describe("Folder for the files (default: a new temp folder)"),
32496
+ upload: boolean2().optional().describe("Upload the pictures through Open Cloud (needs ROBLOX_API_KEY and ROBLOX_CREATOR_ID)"),
32497
+ asRootFrame: boolean2().optional().describe("Root is a Frame instead of a ScreenGui"),
32498
+ textScaled: boolean2().optional().describe("TextScaled on labels (UITextSizeConstraint keeps the design size as the maximum)"),
32499
+ fonts: object2({}).passthrough().optional().describe('Figma family → Roblox family or rbxasset URL, e.g. {"Inter":"GothamSSm"}'),
32500
+ parent: string2().optional().describe('Luau expression for the parent, default game:GetService("StarterGui")')
32501
+ }
32502
+ }, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "hybrid"}`, async () => {
32503
+ const r = await exportRoblox({ ...args, targetResolution: args.targetResolution, fonts: args.fonts }, { request: (m, p, t) => bridge.request(m, p, t), outDir: OUT_DIR });
32504
+ let luau = r.luau;
32505
+ if (luau.length > MAX_OUTPUT_CHARS)
32506
+ luau = luau.slice(0, MAX_OUTPUT_CHARS) + `
32507
+ -- … truncated: the full script is in ${r.result.luau}`;
32508
+ return { content: [{ type: "text", text: json(r.result) }, { type: "text", text: luau }] };
32509
+ }));
32510
+ server.registerTool("comments", {
32511
+ title: "Read and write file comments",
32512
+ description: "Figma comments through the REST API (needs FIGMA_TOKEN; without it, returns how to set one). " + "list: open threads with their replies and the layer each is pinned to (includeResolved for all). " + "post: {message, nodeId?} pinned to a layer (x/y offset inside it), or on the file. reply: {commentId, message}. " + `resolve: Figma's API cannot resolve comments, so this replies "✓ Resolved" (or message) and the user clicks Resolve. delete: {commentId} (your own comments). ` + 'The file is the connected one, or fileUrl. Example: {"action":"post","nodeId":"12:34","message":"Contrast is 2.8:1 here"}',
32513
+ inputSchema: {
32514
+ action: _enum(["list", "post", "reply", "resolve", "delete"]).optional().describe("Default list"),
32515
+ fileUrl: string2().optional().describe("File link or key (default: the connected file)"),
32516
+ nodeId: string2().optional().describe("post: layer to pin the comment to"),
32517
+ x: number2().optional().describe("post: offset inside the layer, px"),
32518
+ y: number2().optional(),
32519
+ message: string2().max(5000).optional(),
32520
+ commentId: string2().optional().describe("reply, resolve, delete"),
32521
+ includeResolved: boolean2().optional().describe("list: include resolved threads")
32522
+ }
32523
+ }, (args) => track("comments", `${args.action ?? "list"} ${args.commentId ?? args.nodeId ?? ""}`, async () => {
32524
+ const { rest, key, session } = await restContext(args.fileUrl);
32525
+ const action = args.action ?? "list";
32526
+ const need = (v, what) => {
32527
+ if (!v)
32528
+ throw new BridgeError(`\`${what}\` is required for ${action}.`, "BAD_ARGS");
32529
+ return v;
32530
+ };
32531
+ if (action === "list") {
32532
+ const threads = threadComments(await rest.comments(key), !!args.includeResolved);
32533
+ const names = await layerNames(session, threads.map((t) => t.nodeId).filter((x) => !!x));
32534
+ return ok({
32535
+ fileKey: key,
32536
+ open: threads.filter((t) => !t.resolvedAt).length,
32537
+ threads: threads.map((t) => t.nodeId && names[t.nodeId] ? { ...t, layer: names[t.nodeId] } : t)
32538
+ });
32539
+ }
32540
+ if (action === "post") {
32541
+ const c = await rest.postComment(key, need(args.message, "message"), { nodeId: args.nodeId, offset: args.nodeId ? { x: args.x ?? 0, y: args.y ?? 0 } : undefined });
32542
+ return ok({ posted: c.id, nodeId: args.nodeId ?? null });
32543
+ }
32544
+ if (action === "reply") {
32545
+ const c = await rest.postComment(key, need(args.message, "message"), { replyTo: need(args.commentId, "commentId") });
32546
+ return ok({ replied: c.id, to: args.commentId });
32547
+ }
32548
+ if (action === "resolve") {
32549
+ const c = await rest.postComment(key, args.message ?? "✓ Resolved", { replyTo: need(args.commentId, "commentId") });
32550
+ return ok({ replied: c.id, to: args.commentId, note: "Figma's REST API has no way to resolve a comment: a reply says it is resolved; ask the user to click Resolve." });
32551
+ }
32552
+ await rest.deleteComment(key, need(args.commentId, "commentId"));
32553
+ return ok({ deleted: args.commentId });
32554
+ }));
32555
+ server.registerTool("versions", {
32556
+ title: "File versions and what changed",
32557
+ description: "Version history through the REST API (needs FIGMA_TOKEN). list: saved versions (id, label, description, author, date). " + "diff: {versionId} compares that version with now (or with toVersionId) and lists layers added, removed and changed (text, size, position, fill, visibility, type), matched by their name path. " + "Scope: nodeId, else the current page of the connected file, else every page. The file is the connected one, or fileUrl. " + 'Example: {"action":"diff","versionId":"1234567890"}',
32558
+ inputSchema: {
32559
+ action: _enum(["list", "diff"]).optional().describe("Default list"),
32560
+ fileUrl: string2().optional().describe("File link or key (default: the connected file)"),
32561
+ versionId: string2().optional().describe("diff: the older version"),
32562
+ toVersionId: string2().optional().describe("diff: the newer version (default: now)"),
32563
+ nodeId: string2().optional().describe("diff: only this layer and its children"),
32564
+ limit: number2().int().min(1).max(200).optional().describe("list: max versions, default 30; diff: max items per list, default 200")
32565
+ }
32566
+ }, (args) => track("versions", `${args.action ?? "list"} ${args.versionId ?? ""}`, async () => {
32567
+ const { rest, key, session } = await restContext(args.fileUrl);
32568
+ if ((args.action ?? "list") === "list") {
32569
+ const list = await rest.versions(key, args.limit ?? 30);
32570
+ return ok({
32571
+ fileKey: key,
32572
+ versions: list.map((v) => ({ id: v.id, createdAt: v.created_at, ...v.label ? { label: v.label } : {}, ...v.description ? { description: v.description } : {}, author: v.user?.handle }))
32573
+ });
32574
+ }
32575
+ if (!args.versionId)
32576
+ throw new BridgeError('`versionId` is required for diff: call versions {action:"list"} first.', "BAD_ARGS");
32577
+ let ids;
32578
+ if (args.nodeId)
32579
+ ids = [args.nodeId];
32580
+ else if (session?.pageId)
32581
+ ids = [session.pageId];
32582
+ else
32583
+ ids = [...new Set([...await rest.pages(key, args.versionId), ...await rest.pages(key, args.toVersionId)].map((p) => p.id))];
32584
+ const [before, after] = await Promise.all([rest.nodes(key, ids, args.versionId), rest.nodes(key, ids, args.toVersionId)]);
32585
+ const empty = (id, name) => ({ id, name, type: "PAGE", children: [] });
32586
+ const results = ids.map((id) => {
32587
+ const b = before[id];
32588
+ const a = after[id];
32589
+ const name = (a ?? b)?.name ?? id;
32590
+ const d = diffTrees(b ?? empty(id, name), a ?? empty(id, name), args.limit ?? 200);
32591
+ return { id, name, ...b ? {} : { note: "did not exist in the older version" }, ...a ? {} : { note: "no longer exists" }, ...d };
32592
+ });
32593
+ return ok({ fileKey: key, from: args.versionId, to: args.toVersionId ?? "now", scopes: results });
32594
+ }));
30765
32595
  server.registerTool("list_sessions", {
30766
32596
  title: "List connected Figma files",
30767
- description: "Figma files that currently have the plugin open, plus bridge status. Needed only when several files are open.",
32597
+ description: "Figma files that currently have the plugin open (with their editor: figma, figjam or slides), plus bridge status. With several files, " + 'select_session picks the default one, or pass file:"<name>" to any tool to target a file for that call only.',
30768
32598
  inputSchema: {}
30769
32599
  }, () => track("list_sessions", "", async () => {
30770
32600
  const sessions = await bridge.sessions().catch(() => []);
@@ -30776,6 +32606,7 @@ ${f.content}`).join(`
30776
32606
  fileName: s.fileName,
30777
32607
  page: s.page,
30778
32608
  pluginVersion: s.version,
32609
+ editor: s.editorType,
30779
32610
  ...s.version && s.version !== version ? { versionMismatch: `Plugin v${s.version}, server v${version}: ask the user to reopen the plugin in Figma, or update it.` } : {},
30780
32611
  connectedSeconds: Math.round((Date.now() - s.connectedAt) / 1000),
30781
32612
  selected: s.id === selected || undefined
@@ -30791,6 +32622,7 @@ ${f.content}`).join(`
30791
32622
  const s = await bridge.select(name);
30792
32623
  return ok({ selected: { id: s.id, fileName: s.fileName, page: s.page } });
30793
32624
  }));
32625
+ registerGuides(server, { instructions: INSTRUCTIONS, buildSpec: BUILD_DESCRIPTION, tools: () => toolDocs });
30794
32626
  await server.connect(new StdioServerTransport);
30795
32627
  log(`Figma Bridge MCP ${version} ready (port ${PORT}, channel ${CHANNEL}, files in ${OUT_DIR})`);
30796
32628
  process.stdin.on("close", shutdown);
@@ -30799,8 +32631,8 @@ ${f.content}`).join(`
30799
32631
  });
30800
32632
 
30801
32633
  // src/cli.ts
30802
- import { homedir as homedir2 } from "node:os";
30803
- import { dirname as dirname4, join as join9 } from "node:path";
32634
+ import { homedir as homedir3 } from "node:os";
32635
+ import { dirname as dirname5, join as join10 } from "node:path";
30804
32636
  import { fileURLToPath as fileURLToPath2 } from "node:url";
30805
32637
 
30806
32638
  // src/setup.ts
@@ -30941,9 +32773,9 @@ function parseClients(argv) {
30941
32773
  init_package();
30942
32774
  var PACKAGE = "@clovis500c/figma-bridge";
30943
32775
  var [cmd, ...rest] = process.argv.slice(2);
30944
- var home = homedir2();
30945
- var pluginSource = join9(dirname4(fileURLToPath2(import.meta.url)), "..", "plugin");
30946
- var pluginTarget = join9(home, ".figma-bridge", "plugin");
32776
+ var home = homedir3();
32777
+ var pluginSource = join10(dirname5(fileURLToPath2(import.meta.url)), "..", "plugin");
32778
+ var pluginTarget = join10(home, ".figma-bridge", "plugin");
30947
32779
  var npx = process.platform === "win32" ? { command: "cmd", args: ["/c", "npx", "-y", PACKAGE] } : { command: "npx", args: ["-y", PACKAGE] };
30948
32780
  function plugin() {
30949
32781
  const manifest = installPlugin(pluginSource, pluginTarget);
@@ -30973,7 +32805,7 @@ Figma Bridge setup
30973
32805
  break;
30974
32806
  }
30975
32807
  try {
30976
- const appData = process.env.APPDATA || join9(home, "AppData", "Roaming");
32808
+ const appData = process.env.APPDATA || join10(home, "AppData", "Roaming");
30977
32809
  printSetup(runSetup({ home, appData, command: npx.command, args: npx.args, clients: parseClients(rest) }), `npx ${PACKAGE} setup`);
30978
32810
  } catch (e) {
30979
32811
  console.log(yellow(e.message));