@clovis500c/figma-bridge 1.8.0 → 1.13.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
@@ -46,8 +46,175 @@ var __esm = (fn, res, err) => () => {
46
46
  };
47
47
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
48
48
 
49
+ // src/setup.ts
50
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
51
+ import { dirname, join } from "node:path";
52
+ function clientList(home, appData) {
53
+ return [
54
+ { id: "claude-code", label: "Claude Code", files: [join(home, ".claude.json")], format: "json", extra: { type: "stdio" } },
55
+ { id: "claude-desktop", label: "Claude Desktop", files: [join(appData, "Claude", "claude_desktop_config.json")], format: "json" },
56
+ { id: "codex", label: "Codex", files: [join(home, ".codex", "config.toml")], format: "toml" },
57
+ {
58
+ id: "antigravity",
59
+ label: "Antigravity",
60
+ files: [join(home, ".gemini", "antigravity", "mcp_config.json"), join(home, ".gemini", "config", "mcp_config.json")],
61
+ format: "json"
62
+ },
63
+ { id: "gemini-cli", label: "Gemini CLI", files: [join(home, ".gemini", "settings.json")], format: "json" },
64
+ { id: "cursor", label: "Cursor", files: [join(home, ".cursor", "mcp.json")], format: "json" },
65
+ { id: "windsurf", label: "Windsurf", files: [join(home, ".codeium", "windsurf", "mcp_config.json")], format: "json" }
66
+ ];
67
+ }
68
+ function backup(path, text) {
69
+ writeFileSync(path + ".bak-figma-bridge", text);
70
+ }
71
+ function writeJson(path, entry) {
72
+ const text = existsSync(path) ? readFileSync(path, "utf8") : "";
73
+ const config = text.trim() ? JSON.parse(text) : {};
74
+ if (text.trim())
75
+ backup(path, text);
76
+ config.mcpServers = { ...config.mcpServers, [SERVER_NAME]: entry };
77
+ mkdirSync(dirname(path), { recursive: true });
78
+ writeFileSync(path, JSON.stringify(config, null, 2) + `
79
+ `);
80
+ }
81
+ function tomlBlock(command, args) {
82
+ return [
83
+ `[mcp_servers.${SERVER_NAME}]`,
84
+ `command = '${command}'`,
85
+ `args = [${args.map((a) => `'${a}'`).join(", ")}]`,
86
+ "startup_timeout_sec = 60",
87
+ "tool_timeout_sec = 120"
88
+ ].join(`
89
+ `);
90
+ }
91
+ function writeToml(path, command, args) {
92
+ const text = existsSync(path) ? readFileSync(path, "utf8") : "";
93
+ if (text.trim())
94
+ backup(path, text);
95
+ const kept = [];
96
+ let skipping = false;
97
+ for (const line of text.split(/\r?\n/)) {
98
+ const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
99
+ if (header)
100
+ skipping = header[1] === `mcp_servers.${SERVER_NAME}` || header[1].startsWith(`mcp_servers.${SERVER_NAME}.`);
101
+ if (!skipping)
102
+ kept.push(line);
103
+ }
104
+ const body = kept.join(`
105
+ `).trimEnd();
106
+ mkdirSync(dirname(path), { recursive: true });
107
+ writeFileSync(path, (body ? body + `
108
+
109
+ ` : "") + tomlBlock(command, args) + `
110
+ `);
111
+ }
112
+ function runSetup(opts) {
113
+ const all = clientList(opts.home, opts.appData);
114
+ const forced = opts.clients ?? [];
115
+ const unknown = forced.filter((id) => !all.some((c) => c.id === id));
116
+ if (unknown.length)
117
+ throw new Error(`Unknown client(s): ${unknown.join(", ")}. Known: ${all.map((c) => c.id).join(", ")}`);
118
+ const result = { updated: [], failed: [], missing: [] };
119
+ for (const client of all) {
120
+ const detected = client.files.filter((f) => existsSync(dirname(f)) && (client.id !== "gemini-cli" || existsSync(f)));
121
+ const targets = forced.length ? forced.includes(client.id) ? detected.length ? detected : [client.files[0]] : [] : detected;
122
+ if (!targets.length) {
123
+ if (!forced.length)
124
+ result.missing.push(client.label);
125
+ continue;
126
+ }
127
+ for (const file of targets) {
128
+ try {
129
+ if (client.format === "toml")
130
+ writeToml(file, opts.command, opts.args);
131
+ else
132
+ writeJson(file, { ...client.extra, command: opts.command, args: opts.args });
133
+ result.updated.push({ client: client.id, label: client.label, file });
134
+ } catch (e) {
135
+ result.failed.push({ client: client.id, label: client.label, file, error: e.message });
136
+ }
137
+ }
138
+ }
139
+ return result;
140
+ }
141
+ function installPlugin(from, to, version) {
142
+ mkdirSync(to, { recursive: true });
143
+ for (const f of PLUGIN_FILES)
144
+ copyFileSync(join(from, f), join(to, f));
145
+ if (version)
146
+ writeFileSync(join(to, "version.json"), JSON.stringify({ version }) + `
147
+ `);
148
+ return join(to, "manifest.json");
149
+ }
150
+ function compareVersions(a, b) {
151
+ const pa = a.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
152
+ const pb = b.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
153
+ for (let i = 0;i < Math.max(pa.length, pb.length); i++) {
154
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
155
+ if (d)
156
+ return d;
157
+ }
158
+ return 0;
159
+ }
160
+ function syncPlugin(from, to, version) {
161
+ if (!PLUGIN_FILES.every((f) => existsSync(join(from, f))))
162
+ return "skipped";
163
+ const had = existsSync(join(to, "manifest.json"));
164
+ let installed = "";
165
+ try {
166
+ installed = JSON.parse(readFileSync(join(to, "version.json"), "utf8")).version || "";
167
+ } catch {}
168
+ if (installed) {
169
+ const order = compareVersions(installed, version);
170
+ if (order > 0)
171
+ return "newer";
172
+ if (order === 0 && PLUGIN_FILES.every((f) => existsSync(join(to, f))))
173
+ return "current";
174
+ }
175
+ installPlugin(from, to, version);
176
+ return had ? "updated" : "installed";
177
+ }
178
+ function printSetup(r, rerun) {
179
+ for (const label of r.missing)
180
+ console.log(dim(` – ${label.padEnd(15)} not found`));
181
+ for (const u of r.updated)
182
+ console.log(green(` ✔ ${u.label.padEnd(15)}`) + dim(u.file));
183
+ for (const f of r.failed)
184
+ console.log(yellow(` ! ${f.label.padEnd(15)}could not update ${f.file}: ${f.error}`));
185
+ if (!r.updated.length)
186
+ console.log(yellow(`
187
+ No AI client found. Use \`${rerun} --client <name>\` or \`${rerun} --print\` for manual config.`));
188
+ }
189
+ function printSnippets(command, args) {
190
+ console.log(bold("JSON clients") + dim(" (Claude, Antigravity, Gemini CLI, Cursor, Windsurf…)"));
191
+ console.log(JSON.stringify({ mcpServers: { [SERVER_NAME]: { command, args } } }, null, 2));
192
+ console.log(bold(`
193
+ Codex`) + dim(" (~/.codex/config.toml)"));
194
+ console.log(tomlBlock(command, args) + `
195
+ `);
196
+ }
197
+ function parseClients(argv) {
198
+ const out = [];
199
+ argv.forEach((a, i) => {
200
+ if (a === "--client" && argv[i + 1])
201
+ out.push(...argv[i + 1].split(","));
202
+ else if (a.startsWith("--client="))
203
+ out.push(...a.slice(9).split(","));
204
+ });
205
+ return out;
206
+ }
207
+ var SERVER_NAME = "FigmaBridge", PLUGIN_FILES, color = (code) => (s) => process.stdout.isTTY ? `\x1B[${code}m${s}\x1B[0m` : s, bold, green, dim, yellow;
208
+ var init_setup = __esm(() => {
209
+ PLUGIN_FILES = ["manifest.json", "code.js", "ui.html"];
210
+ bold = color(1);
211
+ green = color(32);
212
+ dim = color(2);
213
+ yellow = color(33);
214
+ });
215
+
49
216
  // package.json
50
- var version = "1.8.0";
217
+ var version = "1.13.0";
51
218
  var init_package = () => {};
52
219
 
53
220
  // node_modules/zod/v3/helpers/util.js
@@ -24772,6 +24939,7 @@ var init_wrapper = __esm(() => {
24772
24939
  });
24773
24940
 
24774
24941
  // src/bridge.ts
24942
+ import { AsyncLocalStorage } from "node:async_hooks";
24775
24943
  import { randomUUID } from "node:crypto";
24776
24944
  import { createServer } from "node:http";
24777
24945
  import { connect } from "node:net";
@@ -24998,6 +25166,10 @@ class Hub {
24998
25166
  ws.data.session.fileName = msg.fileName;
24999
25167
  if (typeof msg.page === "string")
25000
25168
  ws.data.session.page = msg.page;
25169
+ if (typeof msg.editorType === "string")
25170
+ ws.data.session.editorType = msg.editorType;
25171
+ if (typeof msg.pageId === "string")
25172
+ ws.data.session.pageId = msg.pageId;
25001
25173
  }
25002
25174
  } else if (ws.data.role === "agent") {
25003
25175
  if (msg.t === "req")
@@ -25025,6 +25197,9 @@ class Hub {
25025
25197
  page: String(s.page || ""),
25026
25198
  channel,
25027
25199
  version: msg.version,
25200
+ editorType: typeof s.editorType === "string" ? s.editorType : undefined,
25201
+ fileKey: typeof s.fileKey === "string" && s.fileKey ? s.fileKey : undefined,
25202
+ pageId: typeof s.pageId === "string" ? s.pageId : undefined,
25028
25203
  connectedAt: Date.now()
25029
25204
  };
25030
25205
  const previous = this.plugins.get(session.id);
@@ -25216,7 +25391,7 @@ class Bridge {
25216
25391
  throw new BridgeError(this.lastError ?? `The bridge could not open or join port ${this.opts.port}.`, "OFFLINE");
25217
25392
  }
25218
25393
  try {
25219
- return await this.send(method, params, timeoutMs);
25394
+ return await this.send(method, params, timeoutMs, await this.selectionFor(method));
25220
25395
  } catch (e) {
25221
25396
  const retryable = e instanceof BridgeError && (e.code === "NO_SESSION" || e.code === "OFFLINE");
25222
25397
  if (!retryable || Date.now() > deadline)
@@ -25225,10 +25400,17 @@ class Bridge {
25225
25400
  }
25226
25401
  }
25227
25402
  }
25228
- send(method, params, timeoutMs) {
25403
+ async selectionFor(method) {
25404
+ const file = method === "$sessions" ? undefined : callTarget.getStore()?.file;
25405
+ if (!file)
25406
+ return this.selection;
25407
+ const s = await this.find(file);
25408
+ return { id: s.id, fileName: s.fileName };
25409
+ }
25410
+ send(method, params, timeoutMs, sel) {
25229
25411
  return new Promise((resolve, reject) => {
25230
25412
  const id = randomUUID();
25231
- const msg = { t: "req", id, method, params, sel: this.selection, timeoutMs };
25413
+ const msg = { t: "req", id, method, params, sel, timeoutMs };
25232
25414
  const timer = setTimeout(() => {
25233
25415
  this.pending.delete(id);
25234
25416
  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 +25445,18 @@ class Bridge {
25263
25445
  return this.request("$sessions", {}, 5000, 1500);
25264
25446
  }
25265
25447
  async select(name) {
25448
+ const hit = await this.find(name);
25449
+ this.selection = { id: hit.id, fileName: hit.fileName };
25450
+ return hit;
25451
+ }
25452
+ async current() {
25453
+ const file = callTarget.getStore()?.file;
25454
+ if (file)
25455
+ return this.find(file);
25456
+ const list = await this.sessions().catch(() => []);
25457
+ return list.find((s) => s.id === this.selection.id) ?? (list.length === 1 ? list[0] : null);
25458
+ }
25459
+ async find(name) {
25266
25460
  const list = await this.sessions();
25267
25461
  const q = name.trim().toLowerCase();
25268
25462
  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 +25464,15 @@ class Bridge {
25270
25464
  const names = list.map((s) => `"${s.fileName}"`).join(", ") || "none";
25271
25465
  throw new BridgeError(`No unique connected file matches "${name}". Connected: ${names}.`, "NOT_FOUND");
25272
25466
  }
25273
- this.selection = { id: hit.id, fileName: hit.fileName };
25274
25467
  return hit;
25275
25468
  }
25276
25469
  }
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;
25470
+ 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
25471
  var init_bridge = __esm(() => {
25279
25472
  init_wrapper();
25280
25473
  CHUNK_CHARS = 1 << 20;
25281
25474
  MAX_MESSAGE_CHARS = 256 << 20;
25475
+ callTarget = new AsyncLocalStorage;
25282
25476
  BridgeError = class BridgeError extends Error {
25283
25477
  code;
25284
25478
  details;
@@ -26799,113 +26993,1751 @@ var init_codegen = __esm(() => {
26799
26993
  RADII = { 2: "rounded-sm", 4: "rounded", 6: "rounded-md", 8: "rounded-lg", 12: "rounded-xl", 16: "rounded-2xl", 24: "rounded-3xl" };
26800
26994
  });
26801
26995
 
26802
- // 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";
26805
- function listFiles(root, filter, max = 4000) {
26806
- const out = [];
26807
- const walk = (dir, depth) => {
26808
- if (out.length >= max || depth > 8)
26809
- return;
26810
- let entries;
26811
- try {
26812
- entries = readdirSync(dir);
26813
- } catch {
26814
- return;
26996
+ // src/diagram.ts
26997
+ function parseDiagram(source) {
26998
+ const lines = source.replace(/%%.*$/gm, "").split(/\n|;/).map((l) => l.trim()).filter(Boolean);
26999
+ const d = { direction: "TB", nodes: [], edges: [], subgraphs: [] };
27000
+ const byId = new Map;
27001
+ const stack = [];
27002
+ const node = (ref) => {
27003
+ const m = /^([A-Za-z0-9_\-.$]+)\s*(.*)$/s.exec(ref.trim());
27004
+ if (!m)
27005
+ return null;
27006
+ const id = m[1];
27007
+ const rest = m[2].trim();
27008
+ let n = byId.get(id);
27009
+ if (!n) {
27010
+ n = { id, label: id, shape: "rounded" };
27011
+ byId.set(id, n);
27012
+ d.nodes.push(n);
26815
27013
  }
26816
- for (const name of entries.sort()) {
26817
- if (IGNORE.has(name) || name.startsWith(".") && name !== ".storybook")
26818
- continue;
26819
- const full = join2(dir, name);
26820
- let st;
26821
- try {
26822
- st = statSync(full);
26823
- } catch {
26824
- continue;
26825
- }
26826
- if (st.isDirectory())
26827
- walk(full, depth + 1);
26828
- else {
26829
- const rel = relative(root, full).split(sep).join("/");
26830
- if (filter(rel))
26831
- out.push(rel);
27014
+ if (rest) {
27015
+ for (const [re, shape] of SHAPES) {
27016
+ const s = re.exec(rest);
27017
+ if (s) {
27018
+ n.label = unquote(s[1]);
27019
+ n.shape = shape;
27020
+ break;
27021
+ }
26832
27022
  }
26833
- if (out.length >= max)
26834
- return;
26835
27023
  }
27024
+ for (const g of stack)
27025
+ if (!g.nodes.includes(id))
27026
+ g.nodes.push(id);
27027
+ return id;
26836
27028
  };
26837
- walk(root, 0);
26838
- return out;
27029
+ for (const line of lines) {
27030
+ const header = /^(?:flowchart|graph)\s*(TB|TD|BT|LR|RL)?\b/i.exec(line);
27031
+ if (header) {
27032
+ const dir = (header[1] ?? "TB").toUpperCase();
27033
+ d.direction = dir === "TD" ? "TB" : dir;
27034
+ continue;
27035
+ }
27036
+ const sub = /^subgraph\s+([^\s[]+)(?:\s*\[(.*)\])?\s*(.*)$/i.exec(line);
27037
+ if (sub) {
27038
+ const g = { id: sub[1], label: unquote(sub[2] ?? (sub[3] || sub[1])), nodes: [] };
27039
+ d.subgraphs.push(g);
27040
+ stack.push(g);
27041
+ continue;
27042
+ }
27043
+ if (/^end$/i.test(line)) {
27044
+ stack.pop();
27045
+ continue;
27046
+ }
27047
+ if (/^(direction|classDef|class|style|linkStyle|click)\b/.test(line))
27048
+ continue;
27049
+ const parts = [];
27050
+ const links = [];
27051
+ let rest = line;
27052
+ for (;; ) {
27053
+ const m = EDGE.exec(rest);
27054
+ if (!m) {
27055
+ parts.push(rest);
27056
+ break;
27057
+ }
27058
+ parts.push(rest.slice(0, m.index));
27059
+ let op = m[1];
27060
+ let label = m[2];
27061
+ const inline = /^(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->)$/.exec(op);
27062
+ if (inline) {
27063
+ label = inline[2];
27064
+ op = inline[1] === "--" ? "-->" : inline[1] === "==" ? "==>" : "-.->";
27065
+ }
27066
+ links.push({ op, label: label?.trim() || undefined });
27067
+ rest = rest.slice(m.index + m[0].length);
27068
+ }
27069
+ const groups = parts.map((p) => p.split(/\s+&\s+/).map(node).filter((x) => !!x));
27070
+ links.forEach((l, i) => {
27071
+ for (const from of groups[i] ?? []) {
27072
+ for (const to of groups[i + 1] ?? []) {
27073
+ d.edges.push({ from, to, label: l.label, dashed: l.op.includes("."), thick: l.op.includes("="), arrow: />$/.test(l.op) });
27074
+ }
27075
+ }
27076
+ });
27077
+ }
27078
+ return d;
26839
27079
  }
26840
- function readJsonc(path) {
26841
- try {
26842
- const text = readFileSync2(path, "utf8").replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, str) => str ?? "").replace(/,(\s*[}\]])/g, "$1");
26843
- return JSON.parse(text);
26844
- } catch {
26845
- return null;
27080
+ function layoutDiagram(d, opts = {}) {
27081
+ const size = opts.size ?? defaultSize;
27082
+ const ids = d.nodes.map((n) => n.id);
27083
+ const out = new Map(ids.map((i) => [i, []]));
27084
+ const inn = new Map(ids.map((i) => [i, []]));
27085
+ const state = new Map;
27086
+ const forward = [];
27087
+ const adj = new Map(ids.map((i) => [i, []]));
27088
+ for (const e of d.edges)
27089
+ if (e.from !== e.to)
27090
+ adj.get(e.from).push(e.to);
27091
+ const dfs = (v) => {
27092
+ state.set(v, 1);
27093
+ for (const w of adj.get(v)) {
27094
+ if (state.get(w) === 1)
27095
+ continue;
27096
+ forward.push([v, w]);
27097
+ if (!state.get(w))
27098
+ dfs(w);
27099
+ }
27100
+ state.set(v, 2);
27101
+ };
27102
+ for (const v of ids)
27103
+ if (!state.get(v))
27104
+ dfs(v);
27105
+ for (const [a, b] of forward) {
27106
+ out.get(a).push(b);
27107
+ inn.get(b).push(a);
27108
+ }
27109
+ const layer = new Map;
27110
+ const visit = (v) => {
27111
+ if (layer.has(v))
27112
+ return layer.get(v);
27113
+ layer.set(v, 0);
27114
+ const l = Math.max(0, ...inn.get(v).map((u) => visit(u) + 1));
27115
+ layer.set(v, l);
27116
+ return l;
27117
+ };
27118
+ for (const v of ids)
27119
+ visit(v);
27120
+ const layers = [];
27121
+ for (const v of ids)
27122
+ (layers[layer.get(v)] ??= []).push(v);
27123
+ const pos = new Map;
27124
+ const index = () => layers.forEach((l) => l.forEach((v, i) => pos.set(v, i)));
27125
+ index();
27126
+ for (let sweep = 0;sweep < 4; sweep++) {
27127
+ const down = sweep % 2 === 0;
27128
+ const order = down ? layers.slice(1) : layers.slice(0, -1).reverse();
27129
+ for (const l of order) {
27130
+ const bary = (v) => {
27131
+ const ns = down ? inn.get(v) : out.get(v);
27132
+ return ns.length ? ns.reduce((a, u) => a + pos.get(u), 0) / ns.length : pos.get(v);
27133
+ };
27134
+ l.sort((a, b) => bary(a) - bary(b));
27135
+ l.forEach((v, i) => pos.set(v, i));
27136
+ }
27137
+ }
27138
+ const horizontal = d.direction === "LR" || d.direction === "RL";
27139
+ const gap = opts.gap ?? 60;
27140
+ const layerGap = opts.layerGap ?? 100;
27141
+ const sizes = new Map(d.nodes.map((n) => [n.id, size(n)]));
27142
+ const along = (v) => horizontal ? sizes.get(v).h : sizes.get(v).w;
27143
+ const across = (v) => horizontal ? sizes.get(v).w : sizes.get(v).h;
27144
+ const spans = layers.map((l) => l.reduce((a, v) => a + along(v), 0) + gap * Math.max(0, l.length - 1));
27145
+ const widest = Math.max(0, ...spans);
27146
+ const positions = new Map;
27147
+ let depth = 0;
27148
+ layers.forEach((l, li) => {
27149
+ const thickness = Math.max(...l.map(across));
27150
+ let cursor = (widest - spans[li]) / 2;
27151
+ for (const v of l) {
27152
+ const s = sizes.get(v);
27153
+ const main = cursor;
27154
+ const cross = depth + (thickness - across(v)) / 2;
27155
+ positions.set(v, horizontal ? { x: cross, y: main, w: s.w, h: s.h } : { x: main, y: cross, w: s.w, h: s.h });
27156
+ cursor += along(v) + gap;
27157
+ }
27158
+ depth += thickness + layerGap;
27159
+ });
27160
+ const total = Math.max(0, depth - layerGap);
27161
+ if (d.direction === "BT" || d.direction === "RL") {
27162
+ for (const p of positions.values()) {
27163
+ if (horizontal)
27164
+ p.x = total - p.x - p.w;
27165
+ else
27166
+ p.y = total - p.y - p.h;
27167
+ }
26846
27168
  }
27169
+ return { positions, width: horizontal ? total : widest, height: horizontal ? widest : total };
26847
27170
  }
26848
- function detectStack(root) {
26849
- const pkg = readJsonc(join2(root, "package.json"));
26850
- if (!pkg)
26851
- throw new Error(`No package.json in ${root}: give the root folder of a JavaScript project as projectPath.`);
26852
- const deps = { ...pkg.peerDependencies, ...pkg.devDependencies, ...pkg.dependencies };
26853
- const has = (d) => deps[d] !== undefined;
26854
- const at = (...p) => existsSync2(join2(root, ...p));
26855
- const evidence = [];
26856
- let kind = "html";
26857
- let framework = "html";
26858
- if (has("react-native") || has("expo"))
26859
- [kind, framework] = [has("expo") ? "expo" : "react-native", "react-native"];
26860
- else if (has("next"))
26861
- [kind, framework] = ["next", "react"];
26862
- else if (has("nuxt"))
26863
- [kind, framework] = ["nuxt", "vue"];
26864
- else if (has("vue"))
26865
- [kind, framework] = ["vue", "vue"];
26866
- else if (has("@sveltejs/kit"))
26867
- [kind, framework] = ["sveltekit", "svelte"];
26868
- else if (has("svelte"))
26869
- [kind, framework] = ["svelte", "svelte"];
26870
- else if (has("react"))
26871
- [kind, framework] = [has("vite") ? "vite" : "react", "react"];
26872
- evidence.push(`framework: ${kind}`);
26873
- const tailwindConfig = ["tailwind.config.ts", "tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs"].find((f) => at(f));
26874
- let tailwind;
26875
- if (has("tailwindcss") || has("nativewind")) {
26876
- const v = major(deps.tailwindcss);
26877
- tailwind = has("@tailwindcss/postcss") || has("@tailwindcss/vite") || v !== undefined && v >= 4 ? 4 : 3;
26878
- evidence.push(`tailwindcss ${tailwind}${tailwindConfig ? ` (${tailwindConfig})` : ""}`);
27171
+ function defaultSize(n) {
27172
+ const lines = n.label.split(`
27173
+ `);
27174
+ const longest = Math.max(...lines.map((l) => l.length));
27175
+ const w = Math.min(320, Math.max(n.shape === "diamond" ? 180 : 160, longest * 9 + 48));
27176
+ const h = Math.max(n.shape === "diamond" ? 120 : 80, lines.length * 22 + 40);
27177
+ return n.shape === "ellipse" ? { w, h: Math.max(h, 100) } : { w, h };
27178
+ }
27179
+ function diagramSpec(spec) {
27180
+ const d = parseDiagram(String(spec.source ?? spec.mermaid ?? ""));
27181
+ if (!d.nodes.length)
27182
+ throw new Error('diagram: no nodes found. Example: source:"flowchart LR\\n A[Start] --> B{OK?}\\n B -->|yes| C(Done)"');
27183
+ const stickies = spec.as === "stickies";
27184
+ const STICKY = { w: 240, h: 240 };
27185
+ const lay = layoutDiagram(d, stickies ? { size: () => STICKY, gap: 80, layerGap: 120 } : {});
27186
+ const pad = 80;
27187
+ const head = 60;
27188
+ const inGroup = new Map;
27189
+ for (const g of d.subgraphs)
27190
+ for (const id of g.nodes)
27191
+ if (!inGroup.has(id))
27192
+ inGroup.set(id, g);
27193
+ const nodeSpec = (n, dx, dy) => {
27194
+ const p = lay.positions.get(n.id);
27195
+ const base = { name: n.label.split(`
27196
+ `)[0], key: `${spec.key ?? "diagram"}:${n.id}`, x: Math.round(p.x + dx), y: Math.round(p.y + dy) };
27197
+ 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 } : {} };
27198
+ };
27199
+ const children = [];
27200
+ for (const g of d.subgraphs) {
27201
+ const members = g.nodes.filter((id) => inGroup.get(id) === g).map((id) => d.nodes.find((n) => n.id === id));
27202
+ if (!members.length)
27203
+ continue;
27204
+ const ps = members.map((n) => lay.positions.get(n.id));
27205
+ const x0 = Math.min(...ps.map((p) => p.x)) - 40;
27206
+ const y0 = Math.min(...ps.map((p) => p.y)) - head;
27207
+ const x1 = Math.max(...ps.map((p) => p.x + p.w)) + 40;
27208
+ const y1 = Math.max(...ps.map((p) => p.y + p.h)) + 40;
27209
+ children.push({
27210
+ type: "section",
27211
+ name: g.label,
27212
+ x: Math.round(x0 + pad),
27213
+ y: Math.round(y0 + pad + head),
27214
+ w: Math.round(x1 - x0),
27215
+ h: Math.round(y1 - y0),
27216
+ children: members.map((n) => nodeSpec(n, -x0, -y0))
27217
+ });
26879
27218
  }
26880
- const sourceFiles = listFiles(root, (f) => /\.(module\.(css|scss)|css|tsx|jsx|ts|js|vue|svelte)$/.test(f), 3000);
26881
- let styling = "css";
26882
- if (tailwind && (framework !== "react-native" || has("nativewind")))
26883
- styling = "tailwind";
26884
- else if (has("styled-components") || has("@emotion/styled"))
26885
- styling = "styled-components";
26886
- else if (sourceFiles.some((f) => /\.module\.(css|scss)$/.test(f)))
26887
- styling = "css-modules";
26888
- evidence.push(`styling: ${styling}`);
26889
- const ui = [];
26890
- const shadcn = readJsonc(join2(root, "components.json"));
26891
- if (shadcn?.aliases || has("class-variance-authority") && sourceFiles.some((f) => /components\/ui\/button\.(t|j)sx$/.test(f)))
26892
- ui.push("shadcn");
26893
- if (has("@mui/material"))
26894
- ui.push("mui");
26895
- if (has("@chakra-ui/react"))
26896
- ui.push("chakra");
26897
- if (ui.length)
26898
- evidence.push(`ui: ${ui.join(", ")}`);
26899
- const tsconfig = readJsonc(join2(root, "tsconfig.json")) ?? readJsonc(join2(root, "jsconfig.json"));
26900
- const typescript = at("tsconfig.json") || has("typescript");
26901
- const aliases = [];
26902
- const paths = tsconfig?.compilerOptions?.paths ?? {};
26903
- const baseUrl = String(tsconfig?.compilerOptions?.baseUrl ?? ".");
26904
- for (const [key, targets] of Object.entries(paths)) {
27219
+ for (const n of d.nodes)
27220
+ if (!inGroup.has(n.id))
27221
+ children.push(nodeSpec(n, pad, pad + head));
27222
+ for (const e of d.edges) {
27223
+ children.push({
27224
+ type: "connector",
27225
+ from: `${spec.key ?? "diagram"}:${e.from}`,
27226
+ to: `${spec.key ?? "diagram"}:${e.to}`,
27227
+ ...e.label ? { label: e.label } : {},
27228
+ ...e.dashed ? { dashed: true } : {},
27229
+ ...e.thick ? { strokeWidth: 4 } : {},
27230
+ ...e.arrow ? {} : { endArrow: "none" },
27231
+ line: spec.line ?? "elbowed"
27232
+ });
27233
+ }
27234
+ return {
27235
+ type: "section",
27236
+ name: spec.name ?? "Diagram",
27237
+ ...spec.x !== undefined ? { x: spec.x } : {},
27238
+ ...spec.y !== undefined ? { y: spec.y } : {},
27239
+ w: Math.round(lay.width + pad * 2),
27240
+ h: Math.round(lay.height + pad * 2 + head),
27241
+ children
27242
+ };
27243
+ }
27244
+ var SHAPES, unquote = (s) => s.trim().replace(/^"(.*)"$/s, "$1").replace(/<br\s*\/?>/gi, `
27245
+ `), EDGE;
27246
+ var init_diagram = __esm(() => {
27247
+ SHAPES = [
27248
+ [/^\(\((.*)\)\)$/s, "ellipse"],
27249
+ [/^\(\[(.*)\]\)$/s, "rounded"],
27250
+ [/^\[\[(.*)\]\]$/s, "process"],
27251
+ [/^\[\((.*)\)\]$/s, "database"],
27252
+ [/^\{\{(.*)\}\}$/s, "hexagon"],
27253
+ [/^\[\/(.*)\/\]$/s, "parallelogram"],
27254
+ [/^\[\\(.*)\\\]$/s, "parallelogramleft"],
27255
+ [/^\[\/(.*)\\\]$/s, "trapezoid"],
27256
+ [/^>(.*)\]$/s, "chevron"],
27257
+ [/^\((.*)\)$/s, "rounded"],
27258
+ [/^\{(.*)\}$/s, "diamond"],
27259
+ [/^\[(.*)\]$/s, "square"]
27260
+ ];
27261
+ EDGE = /\s*(<?-{2,}>|<?-\.+->|<?={2,}>|-{3,}|={3,}|-\.+-|--\s+[^-]+?\s+-->|==\s+[^=]+?\s+==>|-\.\s+[^.]+?\s+\.->)\s*(?:\|([^|]*)\|)?\s*/;
27262
+ });
27263
+
27264
+ // src/guides.ts
27265
+ function registerGuides(server, docs) {
27266
+ for (const p of PROMPTS) {
27267
+ const argsSchema = {};
27268
+ for (const [k, d] of Object.entries(p.args))
27269
+ argsSchema[k] = string2().optional().describe(d);
27270
+ server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema }, (args) => text(p.render(args ?? {})));
27271
+ }
27272
+ 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) }] }));
27273
+ 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()) }] }));
27274
+ }
27275
+ function buildSpecMarkdown(spec) {
27276
+ const lines = spec.split(`
27277
+ `);
27278
+ const out = ["# build spec reference", "", lines[0], ""];
27279
+ for (const line of lines.slice(1)) {
27280
+ const m = /^([A-Z]{2,}(?: [A-Z]{2,})*)((?: \([^)]*\))?(?: files)?):\s*(.*)$/.exec(line);
27281
+ if (m && !/^Example/.test(line)) {
27282
+ const title = m[1] + m[2];
27283
+ out.push(`## ${title[0]}${title.slice(1).toLowerCase()}`, "", m[3], "");
27284
+ } else if (/^Example:/.test(line))
27285
+ out.push("## Example", "", "```json", line.replace(/^Example:\s*/, ""), "```", "");
27286
+ else
27287
+ out.push(line, "");
27288
+ }
27289
+ return out.join(`
27290
+ `).replace(/\n{3,}/g, `
27291
+
27292
+ `);
27293
+ }
27294
+ function workflowMarkdown(instructions, tools) {
27295
+ const first = (d) => (/^(.*?[.!?])(\s|$)/s.exec(d.replace(/\s+/g, " "))?.[1] ?? d).slice(0, 220);
27296
+ return [
27297
+ "# Working with Figma Bridge",
27298
+ "",
27299
+ ...instructions.map((l) => /^\d\./.test(l) ? l : `- ${l}`),
27300
+ "",
27301
+ "## Tools",
27302
+ "",
27303
+ ...tools.map((t) => `- **${t.name}**${t.title ? ` (${t.title})` : ""}: ${first(t.description)}`),
27304
+ "",
27305
+ "## Prompts",
27306
+ "",
27307
+ ...PROMPTS.map((p) => `- **${p.name}**: ${p.description}`),
27308
+ "",
27309
+ `Full build spec: ${RESOURCES.build}`,
27310
+ ""
27311
+ ].join(`
27312
+ `);
27313
+ }
27314
+ var RESOURCES, text = (t) => ({ messages: [{ role: "user", content: { type: "text", text: t.trim() } }] }), opt = (v, fallback) => v && v.trim() ? v.trim() : fallback, PROMPTS;
27315
+ var init_guides = __esm(() => {
27316
+ init_zod();
27317
+ RESOURCES = {
27318
+ build: "figma-bridge://docs/build-spec",
27319
+ workflow: "figma-bridge://docs/workflow"
27320
+ };
27321
+ PROMPTS = [
27322
+ {
27323
+ name: "new-screen",
27324
+ title: "Design a new screen",
27325
+ description: "Design a screen from a description, reusing the file's design system.",
27326
+ args: { description: "What the screen is for and what it contains", platform: "desktop (1440), tablet (834) or mobile (390); default desktop" },
27327
+ render: (a) => `
27328
+ Design a new ${opt(a.platform, "desktop")} screen in the connected Figma file: ${opt(a.description, "(ask me what the screen is for)")}.
27329
+ 1. get_context, then get_design_system. Reuse its color and text styles, variables and components (style:, var:, {component}) instead of raw values.
27330
+ 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}.
27331
+ 3. screenshot {returnImage:true} the result and look at it critically: spacing rhythm, alignment, hierarchy, contrast.
27332
+ 4. audit the frame and fix every error and warning.
27333
+ 5. Tell me what you built and what you would improve next.`
27334
+ },
27335
+ {
27336
+ name: "apply-design-system",
27337
+ title: "Apply the design system",
27338
+ description: "Make a screen use the file's variables, styles and components.",
27339
+ args: { target: "Layer name or id (default: the selection)" },
27340
+ render: (a) => `
27341
+ Apply the file's design system to ${opt(a.target, "the selected layers (use wait_for_selection if nothing is selected)")}.
27342
+ 1. get_design_system to see the variables, styles and components available, and describe the target.
27343
+ 2. checkpoint {action:"save"} on the target first.
27344
+ 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.
27345
+ 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.
27346
+ 5. audit {scope:"design-system", nodeId} before and after, and report the score change and anything you could not map.`
27347
+ },
27348
+ {
27349
+ name: "reproduce-screenshot",
27350
+ title: "Reproduce a screenshot",
27351
+ description: "Rebuild a mockup or screenshot as an editable, auto-layout frame and check it pixel by pixel.",
27352
+ args: { image: "Local path or URL of the image", width: "Width of the frame in px (default: the image's width)" },
27353
+ render: (a) => `
27354
+ Reproduce ${opt(a.image, "the image I give you")} as an editable Figma frame${a.width ? ` ${a.width} px wide` : ""}.
27355
+ 1. Look at the image carefully. get_design_system and reuse what matches.
27356
+ 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"}.
27357
+ 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.
27358
+ 4. Stop when mismatchPercent no longer drops; report the final number and what still differs.`
27359
+ },
27360
+ {
27361
+ name: "import-website",
27362
+ title: "Import a website",
27363
+ description: "Turn a web page into editable Figma frames at several widths, then clean them up.",
27364
+ args: { url: "The page URL, or a local .html path", viewports: "Comma-separated widths, default 1440,390" },
27365
+ render: (a) => {
27366
+ const viewports = opt(a.viewports, "1440,390").split(",").map((v) => Number(v.trim())).filter((n) => n > 0);
27367
+ return `
27368
+ Import ${opt(a.url, "(ask me for the URL)")} into the connected Figma file.
27369
+ 1. import_web {${/^https?:/.test(a.url ?? "") ? "url" : "path"}:"${opt(a.url, "<url>")}", viewports:${JSON.stringify(viewports)}}.
27370
+ 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.
27371
+ 3. Report fontSubstitutions: suggest installing the missing fonts or switching to the file's fonts.
27372
+ 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.`;
27373
+ }
27374
+ },
27375
+ {
27376
+ name: "figma-to-code",
27377
+ title: "Figma to code",
27378
+ description: "Export a frame as code that uses the project's own components and tokens.",
27379
+ args: { projectPath: "Root folder of the app (where package.json is)", target: "Layer name or id (default: the selection)" },
27380
+ render: (a) => `
27381
+ Turn ${opt(a.target, "the selected frame")} into code for the project at ${opt(a.projectPath, "(ask me for the project folder)")}.
27382
+ 1. export_code {projectPath, nodeId} without writing. Read the plan: the detected stack, the components used and the unmatched instances, the token sources.
27383
+ 2. If important instances are unmatched, add figma-bridge.map.json entries for them (Figma component → code component and props) and export again.
27384
+ 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.
27385
+ 4. When it looks right, export_code with write:true, then run the project's type-check or linter and fix what they report.`
27386
+ },
27387
+ {
27388
+ name: "figma-to-roblox",
27389
+ title: "Figma to Roblox",
27390
+ description: "Turn a frame into Roblox UI and place it in Studio with a Roblox Studio MCP.",
27391
+ args: { target: "Layer name or id (default: the selection)", mode: "scale, offset or hybrid (default hybrid)" },
27392
+ render: (a) => `
27393
+ Turn ${opt(a.target, "the selected frame")} into Roblox UI that looks the same, and put it in the open Roblox Studio place.
27394
+ 1. export_roblox {${a.target ? `nodeId:"<id of ${a.target}>", ` : ""}mode:"${opt(a.mode, "hybrid")}"}. Read the warnings (approximations, font substitutions).
27395
+ 2. If assets are not uploaded (placeholders rbxassetid://PENDING_n): upload each file in assets[] with the Roblox Studio MCP (e.g. upload_image), and replace each placeholder in the Luau with the returned id.
27396
+ 3. Run the Luau with execute_luau: it creates the ScreenGui under StarterGui (replacing an earlier copy) and returns it.
27397
+ 4. Take a Studio screenshot and compare it with screenshot {returnImage:true} of the Figma frame. Fix differences in the Luau (or in Figma, then export again).
27398
+ 5. Report the result and anything Roblox could not reproduce.`
27399
+ },
27400
+ {
27401
+ name: "audit-and-fix",
27402
+ title: "Audit and fix",
27403
+ description: "Score the design system's health, apply safe fixes, and report what changed.",
27404
+ args: { target: "Layer name or id to limit the fixes (default: the current page)" },
27405
+ render: (a) => `
27406
+ Audit and improve this file${a.target ? ` (fixes limited to ${a.target})` : ""}.
27407
+ 1. audit {scope:"design-system"} and summarize the score per category with the most important issues.
27408
+ 2. checkpoint {action:"save"} on what you are going to change.
27409
+ 3. audit {fix:true${a.target ? `, nodeId:"<id of ${a.target}>"` : ""}} and read the changes.
27410
+ 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.
27411
+ 5. audit {scope:"design-system"} again and report the score before and after.`
27412
+ }
27413
+ ];
27414
+ });
27415
+
27416
+ // src/roblox/fonts.ts
27417
+ function robloxFamily(family, overrides = {}) {
27418
+ const key = family.trim().toLowerCase();
27419
+ const custom = Object.entries(overrides).find(([k]) => k.trim().toLowerCase() === key)?.[1];
27420
+ const name = custom ?? ROBLOX_FONTS[key] ?? DEFAULT_FONT;
27421
+ const url = /^rbxasset(id)?:\/\//.test(name) ? name : `rbxasset://fonts/families/${name.replace(/\s+/g, "")}.json`;
27422
+ const exact = custom !== undefined || name.toLowerCase() === key.replace(/\s+/g, "") || key === "gotham" && name === "GothamSSm";
27423
+ return { url, substituted: !exact };
27424
+ }
27425
+ function robloxWeight(weight) {
27426
+ let best = WEIGHTS2[3];
27427
+ for (const w of WEIGHTS2)
27428
+ if (Math.abs(w[0] - weight) < Math.abs(best[0] - weight))
27429
+ best = w;
27430
+ return { value: best[0], name: best[1] };
27431
+ }
27432
+ var ROBLOX_FONTS, DEFAULT_FONT = "BuilderSans", WEIGHTS2;
27433
+ var init_fonts = __esm(() => {
27434
+ ROBLOX_FONTS = {
27435
+ "builder sans": "BuilderSans",
27436
+ montserrat: "Montserrat",
27437
+ roboto: "Roboto",
27438
+ "roboto condensed": "RobotoCondensed",
27439
+ "roboto mono": "RobotoMono",
27440
+ "source sans pro": "SourceSansPro",
27441
+ "source sans 3": "SourceSansPro",
27442
+ nunito: "Nunito",
27443
+ oswald: "Oswald",
27444
+ merriweather: "Merriweather",
27445
+ ubuntu: "Ubuntu",
27446
+ "titillium web": "TitilliumWeb",
27447
+ "press start 2p": "PressStart2P",
27448
+ "fredoka one": "FredokaOne",
27449
+ fredoka: "FredokaOne",
27450
+ bangers: "Bangers",
27451
+ creepster: "Creepster",
27452
+ "luckiest guy": "LuckiestGuy",
27453
+ "permanent marker": "PermanentMarker",
27454
+ "patrick hand": "PatrickHand",
27455
+ "indie flower": "IndieFlower",
27456
+ "amatic sc": "AmaticSC",
27457
+ kalam: "Kalam",
27458
+ michroma: "Michroma",
27459
+ jura: "Jura",
27460
+ sarpanch: "Sarpanch",
27461
+ "special elite": "SpecialElite",
27462
+ "denk one": "DenkOne",
27463
+ inconsolata: "Inconsolata",
27464
+ arimo: "Arimo",
27465
+ gotham: "GothamSSm",
27466
+ "gotham ssm": "GothamSSm",
27467
+ inter: "BuilderSans",
27468
+ "sf pro": "BuilderSans",
27469
+ "sf pro text": "BuilderSans",
27470
+ "sf pro display": "BuilderSans",
27471
+ "open sans": "BuilderSans",
27472
+ lato: "BuilderSans",
27473
+ poppins: "Montserrat",
27474
+ "dm sans": "BuilderSans",
27475
+ manrope: "BuilderSans",
27476
+ "plus jakarta sans": "BuilderSans",
27477
+ "work sans": "BuilderSans",
27478
+ "ibm plex sans": "SourceSansPro",
27479
+ arial: "Arimo",
27480
+ helvetica: "Arimo",
27481
+ "helvetica neue": "Arimo",
27482
+ "segoe ui": "BuilderSans",
27483
+ "jetbrains mono": "RobotoMono",
27484
+ "fira code": "RobotoMono",
27485
+ "source code pro": "RobotoMono",
27486
+ "sf mono": "RobotoMono",
27487
+ "courier new": "RobotoMono",
27488
+ georgia: "Merriweather",
27489
+ "times new roman": "Merriweather",
27490
+ "playfair display": "Merriweather",
27491
+ "bebas neue": "Oswald",
27492
+ anton: "Oswald",
27493
+ "comic sans ms": "PatrickHand"
27494
+ };
27495
+ WEIGHTS2 = [
27496
+ [100, "Thin"],
27497
+ [200, "ExtraLight"],
27498
+ [300, "Light"],
27499
+ [400, "Regular"],
27500
+ [500, "Medium"],
27501
+ [600, "SemiBold"],
27502
+ [700, "Bold"],
27503
+ [800, "ExtraBold"],
27504
+ [900, "Heavy"]
27505
+ ];
27506
+ });
27507
+
27508
+ // src/roblox/map.ts
27509
+ function color3(hex) {
27510
+ const h = hex.replace("#", "");
27511
+ const c = (i) => num(parseInt(h.slice(i, i + 2), 16) / 255);
27512
+ return { r: c(0), g: c(2), b: c(4) };
27513
+ }
27514
+ function visible(list) {
27515
+ return (list ?? []).filter((p) => p.opacity > 0);
27516
+ }
27517
+ function decide(n, opts) {
27518
+ const approximations = [];
27519
+ if (n.text)
27520
+ return { kind: "text", shadow: false, approximations };
27521
+ const raster = opts.rasterize;
27522
+ const fills = visible(n.fills);
27523
+ const strokes = visible(n.strokes);
27524
+ const effects = n.effects ?? [];
27525
+ const drop = effects.filter((e) => e.type === "DROP_SHADOW");
27526
+ const other = effects.filter((e) => e.type !== "DROP_SHADOW");
27527
+ const hasChildren = !!n.children?.some((c) => !c.hidden);
27528
+ if (n.vector || ["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON", "LINE"].includes(n.type)) {
27529
+ return raster === "none" ? { kind: "frame", shadow: false, approximations: ["vector shape skipped (rasterize is none)"] } : { kind: "picture", shadow: false, approximations };
27530
+ }
27531
+ const image = fills.length === 1 && fills[0].type === "IMAGE";
27532
+ if (image && !hasChildren && !strokes.length && !effects.length && raster !== "all")
27533
+ return { kind: "image", shadow: false, approximations };
27534
+ const reasons = [];
27535
+ if (fills.length > 1)
27536
+ reasons.push("several fills");
27537
+ if (fills.length === 1 && !["SOLID", "GRADIENT_LINEAR", "IMAGE"].includes(fills[0].type))
27538
+ reasons.push(`${fills[0].type.toLowerCase().replace("_", " ")} fill`);
27539
+ if (image && (strokes.length || effects.length))
27540
+ reasons.push("image with stroke or effects");
27541
+ if (strokes.length > 1 || strokes[0] && strokes[0].type !== "SOLID")
27542
+ reasons.push("complex stroke");
27543
+ if (n.dashed)
27544
+ reasons.push("dashed stroke");
27545
+ if (other.length)
27546
+ reasons.push(other.map((e) => e.type.toLowerCase().replace(/_/g, " ")).join(", "));
27547
+ if (n.type === "ELLIPSE" && Math.abs(n.w - n.h) > 0.5)
27548
+ reasons.push("oval");
27549
+ const styled = fills.length + strokes.length + effects.length > 0;
27550
+ if ((reasons.length || raster === "all" && styled) && raster !== "none") {
27551
+ return { kind: hasChildren ? "panel" : "picture", shadow: false, approximations };
27552
+ }
27553
+ if (reasons.length)
27554
+ approximations.push(`${reasons.join(", ")}: approximated (rasterize is none)`);
27555
+ const r = n.radius ?? [];
27556
+ if (r.length && r.some((v) => Math.abs(v - r[0]) > 0.5))
27557
+ approximations.push("different corner radii: used the largest");
27558
+ if (drop.length && raster === "none")
27559
+ approximations.push("drop shadow skipped (rasterize is none)");
27560
+ return { kind: "frame", shadow: drop.length > 0 && raster !== "none", approximations };
27561
+ }
27562
+ function pictureRequests(root, opts) {
27563
+ const out = [];
27564
+ const walk = (n) => {
27565
+ if (n.hidden)
27566
+ return;
27567
+ const d = decide(n, opts);
27568
+ if (d.kind === "picture")
27569
+ out.push({ id: n.id, mode: "full" });
27570
+ if (d.kind === "panel")
27571
+ out.push({ id: n.id, mode: "panel" });
27572
+ if (d.shadow)
27573
+ out.push({ id: n.id, mode: "shadow" });
27574
+ if (d.kind !== "picture")
27575
+ for (const c of n.children ?? [])
27576
+ walk(c);
27577
+ };
27578
+ walk(root);
27579
+ return out;
27580
+ }
27581
+ function imageHashes(root) {
27582
+ const out = new Set;
27583
+ const walk = (n) => {
27584
+ for (const f of n.fills ?? [])
27585
+ if (f.imageHash)
27586
+ out.add(f.imageHash);
27587
+ for (const c of n.children ?? [])
27588
+ walk(c);
27589
+ };
27590
+ walk(root);
27591
+ return [...out];
27592
+ }
27593
+
27594
+ class Mapper {
27595
+ opts;
27596
+ assets;
27597
+ warnings = new Set;
27598
+ fontSubstitutions = new Map;
27599
+ counts = { instances: 0, pictures: 0, skippedHidden: 0 };
27600
+ textFactor = 1;
27601
+ constructor(opts, assets) {
27602
+ this.opts = opts;
27603
+ this.assets = assets;
27604
+ }
27605
+ warn(n, msg) {
27606
+ if (this.warnings.size < 60)
27607
+ this.warnings.add(n ? `${n.name}: ${msg}` : msg);
27608
+ }
27609
+ map(root) {
27610
+ const [tw, th] = this.opts.targetResolution;
27611
+ const fullScreen = Math.abs(root.w / root.h - tw / th) < 0.1 * (tw / th) && root.w >= tw * 0.4;
27612
+ if (fullScreen && this.opts.mode !== "offset")
27613
+ this.textFactor = tw / root.w;
27614
+ const top = this.node(root, null, 0);
27615
+ const geo = this.opts.mode === "offset" ? [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(0, root.w, 0, root.h)]] : fullScreen ? [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(1, 0, 1, 0)]] : [["AnchorPoint", V2(0.5, 0.5)], ["Position", U2(0.5, 0, 0.5, 0)], ["Size", U2(root.w / tw, 0, root.h / th, 0)]];
27616
+ top.props = top.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "ZIndex"].includes(k));
27617
+ top.props.splice(1, 0, ...geo);
27618
+ top.children.push(this.inst("UIAspectRatioConstraint", "AspectRatio", [["AspectRatio", F(root.w / root.h)]]));
27619
+ if (this.opts.asRootFrame)
27620
+ return top;
27621
+ return this.inst("ScreenGui", root.name, [["ResetOnSpawn", B(false)], ["ZIndexBehavior", E("ZIndexBehavior", "Sibling")], ["IgnoreGuiInset", B(true)]], [top]);
27622
+ }
27623
+ inst(className, name, props = [], children = [], source) {
27624
+ this.counts.instances++;
27625
+ return { className, name, props: [["Name", S(name)], ...props], children, ...source ? { source } : {} };
27626
+ }
27627
+ geometry(n, parent, index, d) {
27628
+ const out = [];
27629
+ if (!parent)
27630
+ return out;
27631
+ const inFlow = !!parent.layout && !n.absolute;
27632
+ const useScale = this.opts.mode === "scale" || this.opts.mode === "hybrid" && !inFlow;
27633
+ const hugW = n.sizing?.h === "HUG" && (d.kind === "text" || !!n.layout);
27634
+ const hugH = n.sizing?.v === "HUG" && (d.kind === "text" || !!n.layout);
27635
+ if (inFlow) {
27636
+ const l = parent.layout;
27637
+ const horizontal = l.mode === "HORIZONTAL";
27638
+ const pw = parent.w - (l.padding[1] ?? 0) - (l.padding[3] ?? 0);
27639
+ const ph = parent.h - (l.padding[0] ?? 0) - (l.padding[2] ?? 0);
27640
+ const crossFill = horizontal ? n.sizing?.v === "FILL" || n.stretch : n.sizing?.h === "FILL" || n.stretch;
27641
+ const w = (horizontal ? false : crossFill) ? U(1, 0) : hugW ? U(0, 0) : useScale && l.mode !== "GRID" ? U(n.w / Math.max(1, pw), 0) : U(0, n.w);
27642
+ const h = (horizontal ? crossFill : false) ? U(1, 0) : hugH ? U(0, 0) : useScale && l.mode !== "GRID" ? U(n.h / Math.max(1, ph), 0) : U(0, n.h);
27643
+ out.push(["Size", { t: "UDim2", xs: w.s, xo: w.o, ys: h.s, yo: h.o }]);
27644
+ out.push(["LayoutOrder", I(index + 1)]);
27645
+ return out;
27646
+ }
27647
+ const c = n.constraints ?? { h: "MIN", v: "MIN" };
27648
+ const axis = (pos, size, total, k) => {
27649
+ const end = total - pos - size;
27650
+ if (k === "SCALE" || useScale) {
27651
+ if (k === "MAX")
27652
+ return { anchor: 1, p: [(pos + size) / total, 0], s: [size / total, 0] };
27653
+ if (k === "CENTER")
27654
+ return { anchor: 0.5, p: [(pos + size / 2) / total, 0], s: [size / total, 0] };
27655
+ return { anchor: 0, p: [pos / total, 0], s: [size / total, 0] };
27656
+ }
27657
+ if (k === "MAX")
27658
+ return { anchor: 1, p: [1, -end], s: [0, size] };
27659
+ if (k === "CENTER")
27660
+ return { anchor: 0.5, p: [0.5, pos + size / 2 - total / 2], s: [0, size] };
27661
+ if (k === "STRETCH")
27662
+ return { anchor: 0, p: [0, pos], s: [1, -(pos + end)] };
27663
+ return { anchor: 0, p: [0, pos], s: [0, size] };
27664
+ };
27665
+ const x = axis(n.x, n.w, Math.max(1, parent.w), c.h);
27666
+ const y = axis(n.y, n.h, Math.max(1, parent.h), c.v);
27667
+ if (x.anchor || y.anchor)
27668
+ out.push(["AnchorPoint", V2(x.anchor, y.anchor)]);
27669
+ out.push(["Position", U2(x.p[0], x.p[1], y.p[0], y.p[1])]);
27670
+ out.push(["Size", U2(hugW ? 0 : x.s[0], hugW ? 0 : x.s[1], hugH ? 0 : y.s[0], hugH ? 0 : y.s[1])]);
27671
+ out.push(["ZIndex", I(index + 1)]);
27672
+ return out;
27673
+ }
27674
+ automaticSize(n, d) {
27675
+ const hugW = n.sizing?.h === "HUG" && (d.kind === "text" || !!n.layout);
27676
+ const hugH = n.sizing?.v === "HUG" && (d.kind === "text" || !!n.layout);
27677
+ if (!hugW && !hugH)
27678
+ return [];
27679
+ return [["AutomaticSize", E("AutomaticSize", hugW && hugH ? "XY" : hugW ? "X" : "Y")]];
27680
+ }
27681
+ corner(n) {
27682
+ const r = Math.max(0, ...n.radius ?? []);
27683
+ if (!r && !(n.type === "ELLIPSE"))
27684
+ return null;
27685
+ const pill = n.type === "ELLIPSE" || r >= Math.min(n.w, n.h) / 2 - 0.5;
27686
+ return this.inst("UICorner", "UICorner", [["CornerRadius", pill ? U(0.5, 0) : U(0, r)]]);
27687
+ }
27688
+ stroke(n) {
27689
+ const s = visible(n.strokes)[0];
27690
+ if (!s || s.type !== "SOLID" || !n.strokeWeight)
27691
+ return null;
27692
+ return this.inst("UIStroke", "UIStroke", [
27693
+ ["ApplyStrokeMode", E("ApplyStrokeMode", "Border")],
27694
+ ["Color", C3(s.color)],
27695
+ ["Thickness", F(n.strokeWeight)],
27696
+ ["Transparency", F(1 - s.opacity)],
27697
+ ["LineJoinMode", E("LineJoinMode", "Round")]
27698
+ ]);
27699
+ }
27700
+ gradient(p) {
27701
+ let stops = [...p.stops ?? []].sort((a, b) => a.at - b.at);
27702
+ if (stops.length > 20) {
27703
+ this.warn(null, "a gradient has more than 20 stops: kept 20");
27704
+ stops = stops.filter((_, i) => i % Math.ceil(stops.length / 20) === 0);
27705
+ }
27706
+ if (stops[0].at > 0)
27707
+ stops.unshift({ ...stops[0], at: 0 });
27708
+ if (stops[stops.length - 1].at < 1)
27709
+ stops.push({ ...stops[stops.length - 1], at: 1 });
27710
+ stops[0] = { ...stops[0], at: 0 };
27711
+ stops[stops.length - 1] = { ...stops[stops.length - 1], at: 1 };
27712
+ return this.inst("UIGradient", "UIGradient", [
27713
+ ["Color", { t: "ColorSequence", keys: stops.map((s) => ({ time: num(s.at), ...color3(s.color) })) }],
27714
+ ["Transparency", { t: "NumberSequence", keys: stops.map((s) => ({ time: num(s.at), value: num(1 - s.alpha * p.opacity) })) }],
27715
+ ["Rotation", F(p.angle ?? 0)]
27716
+ ]);
27717
+ }
27718
+ layoutChildren(n) {
27719
+ const l = n.layout;
27720
+ const out = [];
27721
+ const [t, r, b, lft] = l.padding;
27722
+ if (t || r || b || lft)
27723
+ out.push(this.inst("UIPadding", "UIPadding", [["PaddingTop", U(0, t)], ["PaddingRight", U(0, r)], ["PaddingBottom", U(0, b)], ["PaddingLeft", U(0, lft)]]));
27724
+ if (l.mode === "GRID") {
27725
+ const kids = (n.children ?? []).filter((c) => !c.hidden && !c.absolute);
27726
+ const first = kids[0];
27727
+ if (kids.some((k) => Math.abs(k.w - (first?.w ?? 0)) > 1 || Math.abs(k.h - (first?.h ?? 0)) > 1))
27728
+ this.warn(n, "grid cells of different sizes: UIGridLayout uses the first cell's size");
27729
+ out.push(this.inst("UIGridLayout", "UIGridLayout", [
27730
+ ["CellSize", U2(0, first?.w ?? 100, 0, first?.h ?? 100)],
27731
+ ["CellPadding", U2(0, l.columnGap ?? 0, 0, l.rowGap ?? 0)],
27732
+ ["FillDirectionMaxCells", I(l.columns ?? 0)],
27733
+ ["SortOrder", E("SortOrder", "LayoutOrder")]
27734
+ ]));
27735
+ return out;
27736
+ }
27737
+ const horizontal = l.mode === "HORIZONTAL";
27738
+ const main = l.primary === "CENTER" ? "Center" : l.primary === "MAX" ? horizontal ? "Right" : "Bottom" : horizontal ? "Left" : "Top";
27739
+ const cross = l.counter === "CENTER" ? "Center" : l.counter === "MAX" ? horizontal ? "Bottom" : "Right" : horizontal ? "Top" : "Left";
27740
+ const props = [
27741
+ ["FillDirection", E("FillDirection", horizontal ? "Horizontal" : "Vertical")],
27742
+ ["SortOrder", E("SortOrder", "LayoutOrder")],
27743
+ ["Padding", U(0, l.primary === "SPACE_BETWEEN" ? 0 : l.gap)],
27744
+ ["HorizontalAlignment", E("HorizontalAlignment", horizontal ? main === "Right" ? "Right" : main === "Center" ? "Center" : "Left" : cross === "Right" ? "Right" : cross === "Center" ? "Center" : "Left")],
27745
+ ["VerticalAlignment", E("VerticalAlignment", horizontal ? cross === "Bottom" ? "Bottom" : cross === "Center" ? "Center" : "Top" : main === "Bottom" ? "Bottom" : main === "Center" ? "Center" : "Top")]
27746
+ ];
27747
+ if (l.primary === "SPACE_BETWEEN")
27748
+ props.push([horizontal ? "HorizontalFlex" : "VerticalFlex", E("UIFlexAlignment", "SpaceBetween")]);
27749
+ if (l.wrap)
27750
+ props.push(["Wraps", B(true)]);
27751
+ if (l.counter === "BASELINE")
27752
+ this.warn(n, "baseline alignment: aligned to the top");
27753
+ out.push(this.inst("UIListLayout", "UIListLayout", props));
27754
+ return out;
27755
+ }
27756
+ assetUrl(key, n) {
27757
+ const a = this.assets(key);
27758
+ if (!a) {
27759
+ this.warn(n, "picture missing: left empty");
27760
+ return { url: "" };
27761
+ }
27762
+ return a;
27763
+ }
27764
+ textProps(n) {
27765
+ const t = n.text;
27766
+ const segs = t.segments.length ? t.segments : [{ text: t.characters, family: "Inter", style: "Regular", weight: 400, size: 14 }];
27767
+ const base = segs.reduce((a, s) => s.text.length > a.text.length ? s : a, segs[0]);
27768
+ const family = robloxFamily(base.family, this.opts.fonts);
27769
+ if (family.substituted)
27770
+ this.fontSubstitutions.set(base.family, family.url.replace(/^.*\/(\w+)\.json$/, "$1"));
27771
+ const weight = robloxWeight(base.weight);
27772
+ const italic = /italic|oblique/i.test(base.style);
27773
+ const size = Math.max(1, Math.round(base.size * this.textFactor));
27774
+ const props = [
27775
+ ["BackgroundTransparency", F(1)],
27776
+ ["FontFace", { t: "Font", family: family.url, weight: weight.value, weightName: weight.name, style: italic ? "Italic" : "Normal" }],
27777
+ ["TextSize", I(size)],
27778
+ ["TextColor3", C3(base.color ?? "#000000")]
27779
+ ];
27780
+ const alpha = (base.alpha ?? 1) * (n.opacity ?? 1);
27781
+ if (alpha < 1)
27782
+ props.push(["TextTransparency", F(1 - alpha)]);
27783
+ 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);
27784
+ const text = segs.map((s) => s.text).join("");
27785
+ if (rich) {
27786
+ props.push(["RichText", B(true)], ["Text", S(segs.map((s) => this.richRun(s, base)).join(""))]);
27787
+ } else {
27788
+ let value = text;
27789
+ if (base.textCase === "UPPER")
27790
+ value = value.toUpperCase();
27791
+ else if (base.textCase === "LOWER")
27792
+ value = value.toLowerCase();
27793
+ if (base.decoration)
27794
+ props.push(["RichText", B(true)], ["Text", S(this.richRun({ ...base, text: value, textCase: undefined }, { ...base, decoration: undefined }))]);
27795
+ else
27796
+ props.push(["Text", S(value)]);
27797
+ }
27798
+ props.push(["TextXAlignment", E("TextXAlignment", t.align === "CENTER" ? "Center" : t.align === "RIGHT" ? "Right" : "Left")]);
27799
+ props.push(["TextYAlignment", E("TextYAlignment", t.valign === "CENTER" ? "Center" : t.valign === "BOTTOM" ? "Bottom" : "Top")]);
27800
+ if (t.autoResize !== "WIDTH_AND_HEIGHT")
27801
+ props.push(["TextWrapped", B(true)]);
27802
+ if (base.lineHeight)
27803
+ props.push(["LineHeight", F(Math.max(1, Math.min(3, base.lineHeight / (base.size * 1.2))))]);
27804
+ if (t.maxLines)
27805
+ props.push(["TextTruncate", E("TextTruncate", "AtEnd")]);
27806
+ if (this.opts.textScaled)
27807
+ props.push(["TextScaled", B(true)]);
27808
+ if (segs.some((s) => s.letterSpacing))
27809
+ this.warn(n, "letter spacing has no Roblox equivalent: ignored");
27810
+ return props;
27811
+ }
27812
+ richRun(s, base) {
27813
+ let text = s.text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
27814
+ const attrs = [];
27815
+ if (s.color && s.color !== base.color)
27816
+ attrs.push(`color="${s.color}"`);
27817
+ if (s.size !== base.size)
27818
+ attrs.push(`size="${Math.round(s.size * this.textFactor)}"`);
27819
+ if (s.weight !== base.weight)
27820
+ attrs.push(`weight="${robloxWeight(s.weight).value}"`);
27821
+ if (s.family !== base.family)
27822
+ attrs.push(`family="${robloxFamily(s.family, this.opts.fonts).url}"`);
27823
+ if (s.alpha !== undefined && s.alpha < 1)
27824
+ attrs.push(`transparency="${num(1 - s.alpha)}"`);
27825
+ if (attrs.length)
27826
+ text = `<font ${attrs.join(" ")}>${text}</font>`;
27827
+ if (/italic/i.test(s.style) && !/italic/i.test(base.style))
27828
+ text = `<i>${text}</i>`;
27829
+ if (s.decoration === "UNDERLINE")
27830
+ text = `<u>${text}</u>`;
27831
+ if (s.decoration === "STRIKETHROUGH")
27832
+ text = `<s>${text}</s>`;
27833
+ if (s.textCase === "UPPER")
27834
+ text = `<uc>${text}</uc>`;
27835
+ if (s.textCase === "SMALL_CAPS" || s.textCase === "SMALL_CAPS_FORCED")
27836
+ text = `<sc>${text}</sc>`;
27837
+ return text;
27838
+ }
27839
+ node(n, parent, index) {
27840
+ if (n.hidden) {
27841
+ this.counts.skippedHidden++;
27842
+ return null;
27843
+ }
27844
+ const d = decide(n, this.opts);
27845
+ for (const a of d.approximations)
27846
+ this.warn(n, a);
27847
+ const button = !!parent && (n.clickable || BUTTON_NAME.test(n.name)) && d.kind !== "text";
27848
+ const geo = this.geometry(n, parent, index, d);
27849
+ const common = [...geo, ...this.automaticSize(n, d)];
27850
+ if (n.rotation)
27851
+ common.push(["Rotation", F(n.rotation)]);
27852
+ const children = [];
27853
+ let className = "Frame";
27854
+ let props = [];
27855
+ if (d.kind === "text") {
27856
+ className = n.clickable || BUTTON_NAME.test(n.name) ? "TextButton" : "TextLabel";
27857
+ props = this.textProps(n);
27858
+ if (className === "TextButton")
27859
+ props.push(["AutoButtonColor", B(false)]);
27860
+ const size = props.find(([k]) => k === "TextSize")[1].v;
27861
+ children.push(this.inst("UITextSizeConstraint", "UITextSizeConstraint", [["MaxTextSize", I(size)], ["MinTextSize", I(Math.max(1, Math.round(size * 0.5)))]]));
27862
+ return this.inst(className, n.name, [...common, ...props], children, n.id);
27863
+ }
27864
+ if (d.kind === "picture") {
27865
+ this.counts.pictures++;
27866
+ const a = this.assetUrl(`${n.id}:full`, n);
27867
+ className = button ? "ImageButton" : "ImageLabel";
27868
+ const pic = this.pictureGeometry(n, a, geo);
27869
+ props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", "Stretch")]];
27870
+ if (n.opacity !== undefined && n.opacity < 1)
27871
+ props.push(["ImageTransparency", F(1 - n.opacity)]);
27872
+ if (button)
27873
+ props.push(["AutoButtonColor", B(false)]);
27874
+ children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F((a.size?.w ?? n.w) / Math.max(1, a.size?.h ?? n.h))]]));
27875
+ return this.inst(className, n.name, [...pic, ...props], children, n.id);
27876
+ }
27877
+ const fills = visible(n.fills);
27878
+ const opacity = n.opacity ?? 1;
27879
+ const hasKids = !!n.children?.some((c) => !c.hidden);
27880
+ if (d.kind === "image") {
27881
+ const f = fills[0];
27882
+ const a = this.assetUrl(`image:${f.imageHash}`, n);
27883
+ className = button ? "ImageButton" : "ImageLabel";
27884
+ const scaleType = f.scaleMode === "FIT" ? "Fit" : f.scaleMode === "TILE" ? "Tile" : "Crop";
27885
+ props = [["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", scaleType)]];
27886
+ if (f.opacity * opacity < 1)
27887
+ props.push(["ImageTransparency", F(1 - f.opacity * opacity)]);
27888
+ if (button)
27889
+ props.push(["AutoButtonColor", B(false)]);
27890
+ const corner = this.corner(n);
27891
+ if (corner)
27892
+ children.push(corner);
27893
+ children.push(this.inst("UIAspectRatioConstraint", "UIAspectRatioConstraint", [["AspectRatio", F(n.w / Math.max(1, n.h))]]));
27894
+ return this.inst(className, n.name, [...common, ...props], children, n.id);
27895
+ }
27896
+ if (d.kind === "panel") {
27897
+ this.counts.pictures++;
27898
+ const a = this.assetUrl(`${n.id}:panel`, n);
27899
+ const scale = this.opts.scale ?? 2;
27900
+ const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + (n.strokeWeight ?? 0) + 2) * scale);
27901
+ const pw = Math.round((a.size?.w ?? n.w) * scale);
27902
+ const ph = Math.round((a.size?.h ?? n.h) * scale);
27903
+ const ix = Math.min(inset, Math.floor(pw / 2) - 1);
27904
+ const iy = Math.min(inset, Math.floor(ph / 2) - 1);
27905
+ className = button ? "ImageButton" : "ImageLabel";
27906
+ props = [
27907
+ ["BackgroundTransparency", F(1)],
27908
+ ["Image", { t: "Content", url: a.url }],
27909
+ ["ScaleType", E("ScaleType", "Slice")],
27910
+ ["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
27911
+ ["SliceScale", F(1 / scale)]
27912
+ ];
27913
+ if (opacity < 1)
27914
+ props.push(["ImageTransparency", F(1 - opacity)]);
27915
+ if (button)
27916
+ props.push(["AutoButtonColor", B(false)]);
27917
+ } else {
27918
+ className = button ? "TextButton" : hasKids && opacity < 1 ? "CanvasGroup" : "Frame";
27919
+ const f = fills[0];
27920
+ if (!f)
27921
+ props.push(["BackgroundTransparency", F(1)]);
27922
+ else if (f.type === "SOLID") {
27923
+ props.push(["BackgroundColor3", C3(f.color)]);
27924
+ const t = 1 - f.opacity * (className === "CanvasGroup" ? 1 : opacity);
27925
+ if (t > 0)
27926
+ props.push(["BackgroundTransparency", F(t)]);
27927
+ } else if (f.type === "GRADIENT_LINEAR") {
27928
+ props.push(["BackgroundColor3", C3("#FFFFFF")]);
27929
+ children.push(this.gradient(f));
27930
+ } else if (f.type === "IMAGE") {
27931
+ props.push(["BackgroundTransparency", F(1)]);
27932
+ const a = this.assetUrl(`image:${f.imageHash}`, n);
27933
+ const bg = this.inst("ImageLabel", "Background", [["Size", U2(1, 0, 1, 0)], ["ZIndex", I(0)], ["BackgroundTransparency", F(1)], ["Image", { t: "Content", url: a.url }], ["ScaleType", E("ScaleType", f.scaleMode === "FIT" ? "Fit" : "Crop")]]);
27934
+ const corner = this.corner(n);
27935
+ if (corner)
27936
+ bg.children.push(corner);
27937
+ children.push(bg);
27938
+ }
27939
+ if (className === "CanvasGroup")
27940
+ props.push(["GroupTransparency", F(1 - opacity)]);
27941
+ if (button)
27942
+ props.push(["Text", S("")], ["AutoButtonColor", B(false)]);
27943
+ const corner = this.corner(n);
27944
+ if (corner)
27945
+ children.push(corner);
27946
+ const stroke = this.stroke(n);
27947
+ if (stroke)
27948
+ children.push(stroke);
27949
+ }
27950
+ props.push(["BorderSizePixel", I(0)]);
27951
+ if (n.clip)
27952
+ props.push(["ClipsDescendants", B(true)]);
27953
+ const kids = n.children ?? [];
27954
+ const flow = n.layout ? kids.filter((k) => !k.absolute) : kids;
27955
+ const loose = n.layout ? kids.filter((k) => k.absolute) : [];
27956
+ const me = { w: n.w, h: n.h, layout: n.layout };
27957
+ const mapped = (list, p) => list.map((k, i) => this.node(k, p, i)).filter((x) => !!x);
27958
+ const flowMapped = mapped(flow, me);
27959
+ if (n.layout && n.layout.mode !== "GRID") {
27960
+ for (const k of flow) {
27961
+ const target = k.grow && !k.hidden ? flowMapped.find((c) => c.source === k.id) : undefined;
27962
+ if (target)
27963
+ target.children.push(this.inst("UIFlexItem", "UIFlexItem", [["FlexMode", E("UIFlexMode", "Fill")]]));
27964
+ }
27965
+ }
27966
+ if (n.layout && loose.length) {
27967
+ const contentZ = kids.findIndex((k) => !k.absolute) + 1;
27968
+ 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]);
27969
+ const overlay = loose.map((k) => this.node(k, { w: n.w, h: n.h }, kids.indexOf(k))).filter((x) => !!x);
27970
+ children.push(content, ...overlay);
27971
+ } else {
27972
+ if (n.layout)
27973
+ children.push(...this.layoutChildren(n));
27974
+ children.push(...flowMapped);
27975
+ }
27976
+ const self = this.inst(className, n.name, [...common, ...props], children, n.id);
27977
+ if (!d.shadow)
27978
+ return self;
27979
+ this.counts.pictures++;
27980
+ const a = this.assetUrl(`${n.id}:shadow`, n);
27981
+ const scale = this.opts.scale ?? 2;
27982
+ const sh = (n.effects ?? []).filter((e) => e.type === "DROP_SHADOW");
27983
+ const reach = Math.max(...sh.map((e) => e.blur + (e.spread ?? 0) + Math.max(Math.abs(e.x ?? 0), Math.abs(e.y ?? 0))));
27984
+ const off = a.offset ?? { x: 0, y: 0 };
27985
+ const size = a.size ?? { w: n.w, h: n.h };
27986
+ const inset = Math.ceil((Math.max(0, ...n.radius ?? [0]) + reach) * scale);
27987
+ const pw = Math.round(size.w * scale);
27988
+ const ph = Math.round(size.h * scale);
27989
+ const ix = Math.min(inset, Math.floor(pw / 2) - 1);
27990
+ const iy = Math.min(inset, Math.floor(ph / 2) - 1);
27991
+ const shadow = this.inst("ImageLabel", "Shadow", [
27992
+ ["Position", U2(0, off.x, 0, off.y)],
27993
+ ["Size", U2(1, size.w - n.w, 1, size.h - n.h)],
27994
+ ["ZIndex", I(1)],
27995
+ ["BackgroundTransparency", F(1)],
27996
+ ["Image", { t: "Content", url: a.url }],
27997
+ ["ScaleType", E("ScaleType", "Slice")],
27998
+ ["SliceCenter", { t: "Rect", x0: ix, y0: iy, x1: pw - ix, y1: ph - iy }],
27999
+ ["SliceScale", F(1 / scale)]
28000
+ ]);
28001
+ self.props = self.props.filter(([k]) => !["AnchorPoint", "Position", "Size", "LayoutOrder", "AutomaticSize"].includes(k));
28002
+ self.props.splice(1, 0, ["Size", U2(1, 0, 1, 0)], ["ZIndex", I(2)]);
28003
+ return this.inst("Frame", n.name, [...common.filter(([k]) => k !== "AutomaticSize"), ["BackgroundTransparency", F(1)], ["BorderSizePixel", I(0)]], [shadow, self], n.id);
28004
+ }
28005
+ pictureGeometry(n, a, geo) {
28006
+ if (!a.offset || !a.size || Math.abs(a.offset.x) < 0.5 && Math.abs(a.offset.y) < 0.5 && Math.abs(a.size.w - n.w) < 0.5 && Math.abs(a.size.h - n.h) < 0.5)
28007
+ return geo;
28008
+ return geo.map(([k, v]) => {
28009
+ if (k === "Size" && v.t === "UDim2")
28010
+ return [k, { ...v, xo: Math.round(v.xo + a.size.w - n.w), yo: Math.round(v.yo + a.size.h - n.h) }];
28011
+ if (k === "Position" && v.t === "UDim2")
28012
+ return [k, { ...v, xo: Math.round(v.xo + a.offset.x), yo: Math.round(v.yo + a.offset.y) }];
28013
+ return [k, v];
28014
+ });
28015
+ }
28016
+ }
28017
+ function mapToRoblox(root, opts, assets) {
28018
+ const m = new Mapper(opts, assets);
28019
+ const out = m.map(root);
28020
+ if (m.counts.skippedHidden)
28021
+ m.warn(null, `${m.counts.skippedHidden} hidden layer(s) skipped`);
28022
+ return { root: out, warnings: [...m.warnings], fontSubstitutions: Object.fromEntries(m.fontSubstitutions), counts: m.counts };
28023
+ }
28024
+ var num = (v) => Math.round(v * 1000) / 1000, S = (v) => ({ t: "string", v }), B = (v) => ({ t: "bool", v }), I = (v) => ({ t: "int", v: Math.round(v) }), F = (v) => ({ t: "float", v: num(v) }), E = (e, v) => ({ t: "enum", e, v }), U = (s, o) => ({ t: "UDim", s: num(s), o: Math.round(o) }), U2 = (xs, xo, ys, yo) => ({ t: "UDim2", xs: num(xs), xo: Math.round(xo), ys: num(ys), yo: Math.round(yo) }), V2 = (x, y) => ({ t: "Vector2", x: num(x), y: num(y) }), C3 = (hex) => ({ t: "Color3", ...color3(hex) }), BUTTON_NAME;
28025
+ var init_map2 = __esm(() => {
28026
+ init_fonts();
28027
+ BUTTON_NAME = /\b(button|btn|cta)\b/i;
28028
+ });
28029
+
28030
+ // src/roblox/output.ts
28031
+ function xmlProp(name, v) {
28032
+ switch (v.t) {
28033
+ case "string":
28034
+ return `<string name="${name}">${xml(v.v)}</string>`;
28035
+ case "bool":
28036
+ return `<bool name="${name}">${v.v}</bool>`;
28037
+ case "int":
28038
+ return `<int name="${name}">${v.v}</int>`;
28039
+ case "float":
28040
+ return `<float name="${name}">${n(v.v)}</float>`;
28041
+ case "Color3":
28042
+ return `<Color3 name="${name}"><R>${n(v.r)}</R><G>${n(v.g)}</G><B>${n(v.b)}</B></Color3>`;
28043
+ case "UDim":
28044
+ return `<UDim name="${name}"><S>${n(v.s)}</S><O>${v.o}</O></UDim>`;
28045
+ case "UDim2":
28046
+ return `<UDim2 name="${name}"><XS>${n(v.xs)}</XS><XO>${v.xo}</XO><YS>${n(v.ys)}</YS><YO>${v.yo}</YO></UDim2>`;
28047
+ case "Vector2":
28048
+ return `<Vector2 name="${name}"><X>${n(v.x)}</X><Y>${n(v.y)}</Y></Vector2>`;
28049
+ case "Rect":
28050
+ 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>`;
28051
+ case "enum": {
28052
+ const value = ENUMS[v.e]?.[v.v];
28053
+ if (value === undefined)
28054
+ throw new Error(`No enum value for ${v.e}.${v.v}`);
28055
+ return `<token name="${name}">${value}</token>`;
28056
+ }
28057
+ case "Content":
28058
+ return v.url ? `<Content name="${name}"><url>${xml(v.url)}</url></Content>` : `<Content name="${name}"><null></null></Content>`;
28059
+ case "ColorSequence":
28060
+ return `<ColorSequence name="${name}">${v.keys.map((k) => `${n(k.time)} ${n(k.r)} ${n(k.g)} ${n(k.b)} 0 `).join("")}</ColorSequence>`;
28061
+ case "NumberSequence":
28062
+ return `<NumberSequence name="${name}">${v.keys.map((k) => `${n(k.time)} ${n(k.value)} 0 `).join("")}</NumberSequence>`;
28063
+ case "Font":
28064
+ return `<Font name="${name}"><Family><url>${xml(v.family)}</url></Family><Weight>${v.weight}</Weight><Style>${v.style}</Style></Font>`;
28065
+ }
28066
+ }
28067
+ function toRbxmx(root) {
28068
+ let ref = 0;
28069
+ const item = (i, depth) => {
28070
+ const pad = "\t".repeat(depth);
28071
+ const props = i.props.map(([k, v]) => `${pad} ${xmlProp(k, v)}`).join(`
28072
+ `);
28073
+ const kids = i.children.map((c) => item(c, depth + 1)).join(`
28074
+ `);
28075
+ return `${pad}<Item class="${i.className}" referent="RBX${(ref++).toString(16).toUpperCase().padStart(8, "0")}">
28076
+ ${pad} <Properties>
28077
+ ${props}
28078
+ ${pad} </Properties>${kids ? `
28079
+ ${kids}` : ""}
28080
+ ${pad}</Item>`;
28081
+ };
28082
+ 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">
28083
+ ` + ` <External>null</External>
28084
+ <External>nil</External>
28085
+ ` + item(root, 1) + `
28086
+ </roblox>
28087
+ `;
28088
+ }
28089
+ function luauString(s) {
28090
+ 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)}`)}"`;
28091
+ }
28092
+ function luauValue(v) {
28093
+ switch (v.t) {
28094
+ case "string":
28095
+ return luauString(v.v);
28096
+ case "bool":
28097
+ return String(v.v);
28098
+ case "int":
28099
+ return String(v.v);
28100
+ case "float":
28101
+ return n(v.v);
28102
+ case "Color3":
28103
+ return `Color3.fromRGB(${rgb(v.r)}, ${rgb(v.g)}, ${rgb(v.b)})`;
28104
+ case "UDim":
28105
+ return `UDim.new(${n(v.s)}, ${v.o})`;
28106
+ case "UDim2":
28107
+ if (!v.xo && !v.yo)
28108
+ return `UDim2.fromScale(${n(v.xs)}, ${n(v.ys)})`;
28109
+ if (!v.xs && !v.ys)
28110
+ return `UDim2.fromOffset(${v.xo}, ${v.yo})`;
28111
+ return `UDim2.new(${n(v.xs)}, ${v.xo}, ${n(v.ys)}, ${v.yo})`;
28112
+ case "Vector2":
28113
+ return `Vector2.new(${n(v.x)}, ${n(v.y)})`;
28114
+ case "Rect":
28115
+ return `Rect.new(${n(v.x0)}, ${n(v.y0)}, ${n(v.x1)}, ${n(v.y1)})`;
28116
+ case "enum":
28117
+ return `Enum.${v.e}.${v.v}`;
28118
+ case "Content":
28119
+ return luauString(v.url);
28120
+ case "ColorSequence":
28121
+ return `ColorSequence.new({
28122
+ ${v.keys.map((k) => ` ColorSequenceKeypoint.new(${n(k.time)}, Color3.fromRGB(${rgb(k.r)}, ${rgb(k.g)}, ${rgb(k.b)})),`).join(`
28123
+ `)}
28124
+ })`;
28125
+ case "NumberSequence":
28126
+ return `NumberSequence.new({
28127
+ ${v.keys.map((k) => ` NumberSequenceKeypoint.new(${n(k.time)}, ${n(k.value)}),`).join(`
28128
+ `)}
28129
+ })`;
28130
+ case "Font":
28131
+ return `Font.new(${luauString(v.family)}, Enum.FontWeight.${v.weightName}, Enum.FontStyle.${v.style})`;
28132
+ }
28133
+ }
28134
+ function identifier(name, className, used) {
28135
+ 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);
28136
+ let id = words.map((w, i) => i ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w.toLowerCase()).join("");
28137
+ if (!id || /^\d/.test(id))
28138
+ id = className[0].toLowerCase() + className.slice(1) + (id ? id[0].toUpperCase() + id.slice(1) : "");
28139
+ if (KEYWORDS.has(id))
28140
+ id += "Gui";
28141
+ let unique = id;
28142
+ for (let k = 2;used.has(unique); k++)
28143
+ unique = `${id}${k}`;
28144
+ used.add(unique);
28145
+ return unique;
28146
+ }
28147
+ function toLuau(root, opts = {}) {
28148
+ const used = new Set;
28149
+ const lines = [];
28150
+ const emit = (i, parentVar, depth) => {
28151
+ const v = identifier(i.name, i.className, used);
28152
+ if (!/^UI/.test(i.className) && depth <= 2 && parentVar)
28153
+ lines.push("", `-- ${i.name.replace(/\n/g, " ")}`);
28154
+ lines.push(`local ${v} = Instance.new(${luauString(i.className)})`);
28155
+ for (const [k, val] of i.props)
28156
+ lines.push(`${v}.${k} = ${luauValue(val)}`);
28157
+ for (const c of i.children)
28158
+ emit(c, v, depth + 1);
28159
+ if (parentVar)
28160
+ lines.push(`${v}.Parent = ${parentVar}`);
28161
+ return v;
28162
+ };
28163
+ const header = [
28164
+ `-- ${root.name}: generated by Figma Bridge${opts.source ? ` from "${opts.source.replace(/\n/g, " ")}"` : ""} (export_roblox).`,
28165
+ "-- Creates the UI under PARENT and returns it. Running it again replaces the previous copy.",
28166
+ `local PARENT = ${opts.parent ?? 'game:GetService("StarterGui")'}`,
28167
+ "",
28168
+ `local existing = PARENT:FindFirstChild(${luauString(root.name)})`,
28169
+ "if existing then",
28170
+ "\texisting:Destroy()",
28171
+ "end",
28172
+ ""
28173
+ ];
28174
+ const rootVar = emit(root, null, 0);
28175
+ return `${header.join(`
28176
+ `)}
28177
+ ${lines.join(`
28178
+ `)}
28179
+
28180
+ ${rootVar}.Parent = PARENT
28181
+ return ${rootVar}
28182
+ `.replace(/\n{3,}/g, `
28183
+
28184
+ `);
28185
+ }
28186
+ function substituteAssets(text, ids) {
28187
+ return text.replace(/rbxassetid:\/\/PENDING_\d+/g, (m) => ids[m] ? `rbxassetid://${ids[m]}` : m);
28188
+ }
28189
+ var ENUMS, n = (v) => String(Math.round(v * 1000) / 1000), xml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"), KEYWORDS, rgb = (v) => Math.round(v * 255);
28190
+ var init_output = __esm(() => {
28191
+ ENUMS = {
28192
+ ZIndexBehavior: { Global: 0, Sibling: 1 },
28193
+ FillDirection: { Horizontal: 0, Vertical: 1 },
28194
+ HorizontalAlignment: { Center: 0, Left: 1, Right: 2 },
28195
+ VerticalAlignment: { Center: 0, Top: 1, Bottom: 2 },
28196
+ SortOrder: { Name: 0, Custom: 1, LayoutOrder: 2 },
28197
+ UIFlexAlignment: { None: 0, Fill: 1, SpaceAround: 2, SpaceBetween: 3, SpaceEvenly: 4 },
28198
+ UIFlexMode: { None: 0, Grow: 1, Shrink: 2, Fill: 3, Custom: 4 },
28199
+ AutomaticSize: { None: 0, X: 1, Y: 2, XY: 3 },
28200
+ TextXAlignment: { Left: 0, Right: 1, Center: 2 },
28201
+ TextYAlignment: { Top: 0, Center: 1, Bottom: 2 },
28202
+ ScaleType: { Stretch: 0, Slice: 1, Tile: 2, Fit: 3, Crop: 4 },
28203
+ ApplyStrokeMode: { Contextual: 0, Border: 1 },
28204
+ LineJoinMode: { Round: 0, Bevel: 1, Miter: 2 },
28205
+ TextTruncate: { None: 0, AtEnd: 1, SplitWord: 2 }
28206
+ };
28207
+ KEYWORDS = new Set(["and", "break", "do", "else", "elseif", "end", "false", "for", "function", "if", "in", "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while", "continue", "export", "type", "game", "workspace", "script", "PARENT", "existing"]);
28208
+ });
28209
+
28210
+ // src/roblox/upload.ts
28211
+ import { createHash } from "node:crypto";
28212
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
28213
+ import { dirname as dirname2 } from "node:path";
28214
+ function creatorFrom(id) {
28215
+ const m = /^(?:(user|group)[:/])?\s*(\d+)$/i.exec(id.trim());
28216
+ if (!m)
28217
+ throw new RobloxUploadError(`ROBLOX_CREATOR_ID must be a user id, "user:<id>" or "group:<id>", not "${id}".`, "BAD_CREATOR");
28218
+ return m[1]?.toLowerCase() === "group" ? { groupId: m[2] } : { userId: m[2] };
28219
+ }
28220
+
28221
+ class RobloxUploader {
28222
+ opts;
28223
+ cache = {};
28224
+ fetchImpl;
28225
+ sleep;
28226
+ base;
28227
+ constructor(opts) {
28228
+ this.opts = opts;
28229
+ this.fetchImpl = opts.fetchImpl ?? fetch;
28230
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
28231
+ this.base = opts.base ?? process.env.ROBLOX_API_URL ?? "https://apis.roblox.com";
28232
+ try {
28233
+ this.cache = JSON.parse(readFileSync2(opts.cacheFile, "utf8"));
28234
+ } catch {}
28235
+ }
28236
+ static hash(bytes) {
28237
+ return createHash("sha256").update(bytes).digest("hex");
28238
+ }
28239
+ async request(url, init) {
28240
+ let delay = 1000;
28241
+ for (let attempt = 0;; attempt++) {
28242
+ const res = await this.fetchImpl(url, { ...init, headers: { ...init.headers, "x-api-key": this.opts.apiKey }, signal: AbortSignal.timeout(60000) });
28243
+ if (res.ok)
28244
+ return res.json();
28245
+ const retryAfter = Number(res.headers.get("retry-after"));
28246
+ if ((res.status === 429 || res.status >= 500) && attempt < 3) {
28247
+ await this.sleep(retryAfter > 0 ? retryAfter * 1000 : delay);
28248
+ delay *= 2;
28249
+ continue;
28250
+ }
28251
+ let detail = "";
28252
+ try {
28253
+ const body = await res.json();
28254
+ detail = body.message ?? body.errors?.[0]?.message ?? "";
28255
+ } catch {}
28256
+ if (res.status === 401 || res.status === 403)
28257
+ 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");
28258
+ throw new RobloxUploadError(`Roblox Open Cloud error ${res.status}${detail ? `: ${detail}` : ""}`, "UPLOAD_FAILED");
28259
+ }
28260
+ }
28261
+ async upload(bytes, name) {
28262
+ const hash = RobloxUploader.hash(bytes);
28263
+ if (this.cache[hash])
28264
+ return { assetId: this.cache[hash], cached: true };
28265
+ const form = new FormData;
28266
+ form.append("request", JSON.stringify({
28267
+ assetType: "Image",
28268
+ displayName: name.slice(0, 50) || "Figma Bridge image",
28269
+ description: "Uploaded by Figma Bridge (export_roblox)",
28270
+ creationContext: { creator: creatorFrom(this.opts.creatorId) }
28271
+ }));
28272
+ form.append("fileContent", new Blob([new Uint8Array(bytes)], { type: "image/png" }), `${name.replace(/[^\w.-]+/g, "_") || "image"}.png`);
28273
+ let op = await this.request(`${this.base}/assets/v1/assets`, { method: "POST", body: form });
28274
+ for (let i = 0;!op.done; i++) {
28275
+ if (i >= 20)
28276
+ throw new RobloxUploadError(`Roblox is still processing "${name}": try again in a minute.`, "UPLOAD_PENDING");
28277
+ await this.sleep(Math.min(500 * 2 ** i, 4000));
28278
+ const id = String(op.operationId ?? op.path ?? "").replace(/^operations\//, "");
28279
+ if (!id)
28280
+ throw new RobloxUploadError("Roblox returned no operation to wait for.", "UPLOAD_FAILED");
28281
+ op = await this.request(`${this.base}/assets/v1/operations/${id}`, { method: "GET" });
28282
+ }
28283
+ const assetId = String(op.response?.assetId ?? "");
28284
+ if (!assetId)
28285
+ throw new RobloxUploadError(`Upload of "${name}" finished without an asset id${op.error?.message ? `: ${op.error.message}` : ""}.`, "UPLOAD_FAILED");
28286
+ this.cache[hash] = assetId;
28287
+ mkdirSync2(dirname2(this.opts.cacheFile), { recursive: true });
28288
+ writeFileSync2(this.opts.cacheFile, JSON.stringify(this.cache, null, 2));
28289
+ return { assetId, cached: false };
28290
+ }
28291
+ }
28292
+ var RobloxUploadError, UPLOAD_HELP;
28293
+ var init_upload = __esm(() => {
28294
+ RobloxUploadError = class RobloxUploadError extends Error {
28295
+ code;
28296
+ constructor(message, code) {
28297
+ super(message);
28298
+ this.code = code;
28299
+ }
28300
+ };
28301
+ 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.';
28302
+ });
28303
+
28304
+ // src/roblox/export.ts
28305
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
28306
+ import { homedir } from "node:os";
28307
+ import { join as join2 } from "node:path";
28308
+ async function exportRoblox(args, deps) {
28309
+ const env = deps.env ?? process.env;
28310
+ const opts = {
28311
+ mode: args.mode ?? "hybrid",
28312
+ targetResolution: args.targetResolution ?? [1920, 1080],
28313
+ rasterize: args.rasterize ?? "auto",
28314
+ asRootFrame: args.asRootFrame,
28315
+ textScaled: args.textScaled,
28316
+ fonts: args.fonts,
28317
+ scale: 2
28318
+ };
28319
+ const t = await deps.request("roblox_tree", { nodeId: args.nodeId }, 120000);
28320
+ const warnings = [];
28321
+ if (t.truncated)
28322
+ warnings.push(`Stopped after ${t.nodes} layers: export a smaller frame for the rest.`);
28323
+ const wanted = pictureRequests(t.tree, opts);
28324
+ const pictures = new Map;
28325
+ for (let i = 0;i < wanted.length; i += 20) {
28326
+ const r = await deps.request("roblox_images", { items: wanted.slice(i, i + 20).map((w) => ({ ...w, scale: opts.scale })) }, 120000);
28327
+ for (const img of r.images) {
28328
+ if (img.b64)
28329
+ pictures.set(`${img.id}:${img.mode}`, { b64: img.b64, offset: img.offset, size: img.size });
28330
+ else
28331
+ warnings.push(`Picture of ${img.id} failed: ${img.error}`);
28332
+ }
28333
+ }
28334
+ const assets = [];
28335
+ const files = new Map;
28336
+ const refs = new Map;
28337
+ const names = new Map;
28338
+ const layerNames = (n) => {
28339
+ names.set(n.id, n.name);
28340
+ for (const c of n.children ?? [])
28341
+ layerNames(c);
28342
+ };
28343
+ layerNames(t.tree);
28344
+ const add = (key, bytes, a, ext, ref) => {
28345
+ const placeholder = `rbxassetid://PENDING_${assets.length + 1}`;
28346
+ const file = `${String(assets.length + 1).padStart(2, "0")}-${safe(a.layer)}${a.mode && a.mode !== "full" ? `-${a.mode}` : ""}.${ext}`;
28347
+ assets.push({ placeholder, file, ...a });
28348
+ files.set(file, bytes);
28349
+ refs.set(key, { url: placeholder, ...ref });
28350
+ };
28351
+ for (const w of wanted) {
28352
+ const p = pictures.get(`${w.id}:${w.mode}`);
28353
+ if (!p)
28354
+ continue;
28355
+ add(`${w.id}:${w.mode}`, new Uint8Array(Buffer.from(p.b64, "base64")), { nodeId: w.id, layer: names.get(w.id) ?? w.id, kind: "picture", mode: w.mode }, "png", { offset: p.offset, size: p.size });
28356
+ }
28357
+ for (const hash of imageHashes(t.tree)) {
28358
+ const b64 = t.images[hash];
28359
+ if (!b64)
28360
+ continue;
28361
+ const bytes = new Uint8Array(Buffer.from(b64, "base64"));
28362
+ const info = imageInfo(bytes);
28363
+ if (info && info.format !== "png" && info.format !== "jpeg")
28364
+ warnings.push(`An image is ${info.format.toUpperCase()}: Roblox takes PNG, JPG, BMP or TGA, convert it before uploading.`);
28365
+ const owner = findImageOwner(t.tree, hash);
28366
+ add(`image:${hash}`, bytes, { nodeId: owner?.id ?? "", layer: owner?.name ?? "image", kind: "image" }, info?.format === "jpeg" ? "jpg" : info?.format ?? "png", {});
28367
+ }
28368
+ const mapped = mapToRoblox(t.tree, opts, (key) => refs.get(key));
28369
+ warnings.push(...mapped.warnings);
28370
+ let rbxmx = toRbxmx(mapped.root);
28371
+ let luau = toLuau(mapped.root, { parent: args.parent, source: `${t.tree.name}" in "${t.fileName}` });
28372
+ let uploaded = false;
28373
+ if (args.upload) {
28374
+ if (!env.ROBLOX_API_KEY || !env.ROBLOX_CREATOR_ID)
28375
+ warnings.push(`upload skipped: ROBLOX_API_KEY and ROBLOX_CREATOR_ID are not set. ${UPLOAD_HELP}`);
28376
+ else if (assets.length) {
28377
+ const uploader = new RobloxUploader({
28378
+ apiKey: env.ROBLOX_API_KEY,
28379
+ creatorId: env.ROBLOX_CREATOR_ID,
28380
+ cacheFile: join2(env.FIGMA_BRIDGE_HOME ?? join2(homedir(), ".figma-bridge"), "roblox-assets.json"),
28381
+ fetchImpl: deps.fetchImpl,
28382
+ sleep: deps.sleep
28383
+ });
28384
+ const ids = {};
28385
+ for (const a of assets) {
28386
+ const r = await uploader.upload(files.get(a.file), `${a.layer}${a.mode && a.mode !== "full" ? ` ${a.mode}` : ""}`);
28387
+ a.assetId = r.assetId;
28388
+ a.cached = r.cached;
28389
+ ids[a.placeholder] = r.assetId;
28390
+ }
28391
+ rbxmx = substituteAssets(rbxmx, ids);
28392
+ luau = substituteAssets(luau, ids);
28393
+ uploaded = true;
28394
+ }
28395
+ }
28396
+ const dir = args.outDir ?? join2(deps.outDir, `roblox-${safe(t.tree.name)}-${Date.now()}`);
28397
+ mkdirSync3(dir, { recursive: true });
28398
+ const base = safe(mapped.root.name);
28399
+ writeFileSync3(join2(dir, `${base}.rbxmx`), rbxmx);
28400
+ writeFileSync3(join2(dir, `${base}.luau`), luau);
28401
+ for (const [file, bytes] of files)
28402
+ writeFileSync3(join2(dir, file), bytes);
28403
+ writeFileSync3(join2(dir, "assets.json"), JSON.stringify(assets, null, 2));
28404
+ const pending = assets.filter((a) => !a.assetId);
28405
+ 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.";
28406
+ return {
28407
+ result: {
28408
+ root: mapped.root.name,
28409
+ dir,
28410
+ rbxmx: join2(dir, `${base}.rbxmx`),
28411
+ luau: join2(dir, `${base}.luau`),
28412
+ assets: assets.map((a) => ({ ...a, file: join2(dir, a.file) })),
28413
+ uploaded,
28414
+ instances: mapped.counts.instances,
28415
+ layers: t.nodes,
28416
+ options: { mode: opts.mode, targetResolution: opts.targetResolution, rasterize: opts.rasterize },
28417
+ ...Object.keys(mapped.fontSubstitutions).length ? { fontSubstitutions: mapped.fontSubstitutions } : {},
28418
+ ...warnings.length ? { warnings: warnings.slice(0, 60) } : {},
28419
+ next
28420
+ },
28421
+ luau,
28422
+ rbxmx
28423
+ };
28424
+ }
28425
+ function findImageOwner(n, hash) {
28426
+ if ((n.fills ?? []).some((f) => f.imageHash === hash))
28427
+ return n;
28428
+ for (const c of n.children ?? []) {
28429
+ const hit = findImageOwner(c, hash);
28430
+ if (hit)
28431
+ return hit;
28432
+ }
28433
+ return null;
28434
+ }
28435
+ var safe = (s) => s.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "ui";
28436
+ var init_export = __esm(() => {
28437
+ init_image();
28438
+ init_map2();
28439
+ init_output();
28440
+ init_upload();
28441
+ });
28442
+
28443
+ // src/rest.ts
28444
+ function fileKeyFrom(input) {
28445
+ const m = /figma\.com\/(?:file|design|board|slides|proto|deck)\/([A-Za-z0-9]{10,})/.exec(input);
28446
+ if (m)
28447
+ return m[1];
28448
+ return /^[A-Za-z0-9]{10,}$/.test(input.trim()) ? input.trim() : null;
28449
+ }
28450
+
28451
+ class FigmaRest {
28452
+ token;
28453
+ fetchImpl;
28454
+ sleep;
28455
+ base;
28456
+ constructor(token, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), base = process.env.FIGMA_API_URL || "https://api.figma.com") {
28457
+ this.token = token;
28458
+ this.fetchImpl = fetchImpl;
28459
+ this.sleep = sleep;
28460
+ this.base = base;
28461
+ }
28462
+ async call(method, path, body) {
28463
+ let delay = 1000;
28464
+ for (let attempt = 0;; attempt++) {
28465
+ let res;
28466
+ try {
28467
+ res = await this.fetchImpl(this.base + path, {
28468
+ method,
28469
+ headers: { "X-Figma-Token": this.token, ...body !== undefined ? { "Content-Type": "application/json" } : {} },
28470
+ body: body !== undefined ? JSON.stringify(body) : undefined,
28471
+ signal: AbortSignal.timeout(60000)
28472
+ });
28473
+ } catch (e) {
28474
+ if (attempt >= 3)
28475
+ throw new RestError(`Figma API unreachable: ${e.message}`, "NETWORK");
28476
+ await this.sleep(delay);
28477
+ delay *= 2;
28478
+ continue;
28479
+ }
28480
+ if (res.ok)
28481
+ return res.status === 204 ? {} : await res.json();
28482
+ const retryAfter = Number(res.headers.get("retry-after"));
28483
+ if ((res.status === 429 || res.status >= 500) && attempt < 3) {
28484
+ await this.sleep(res.status === 429 && retryAfter > 0 ? Math.min(retryAfter, 120) * 1000 : delay);
28485
+ delay *= 2;
28486
+ continue;
28487
+ }
28488
+ let detail = "";
28489
+ try {
28490
+ const j = await res.json();
28491
+ detail = j.err ?? j.message ?? "";
28492
+ } catch {}
28493
+ if (res.status === 403)
28494
+ 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);
28495
+ if (res.status === 404)
28496
+ throw new RestError(`Not found (404${detail ? `: ${detail}` : ""}): check the file key or id.`, "NOT_FOUND", 404);
28497
+ if (res.status === 429)
28498
+ throw new RestError("Figma API rate limit reached: try again in a minute.", "RATE_LIMITED", 429);
28499
+ throw new RestError(`Figma API error ${res.status}${detail ? `: ${detail}` : ""}`, "REST_ERROR", res.status);
28500
+ }
28501
+ }
28502
+ async comments(key) {
28503
+ const r = await this.call("GET", `/v1/files/${key}/comments?as_md=true`);
28504
+ return r.comments ?? [];
28505
+ }
28506
+ postComment(key, message, opts = {}) {
28507
+ const body = { message };
28508
+ if (opts.replyTo)
28509
+ body.comment_id = opts.replyTo;
28510
+ else if (opts.nodeId)
28511
+ body.client_meta = { node_id: opts.nodeId, node_offset: opts.offset ?? { x: 0, y: 0 } };
28512
+ return this.call("POST", `/v1/files/${key}/comments`, body);
28513
+ }
28514
+ deleteComment(key, id) {
28515
+ return this.call("DELETE", `/v1/files/${key}/comments/${id}`);
28516
+ }
28517
+ async versions(key, limit = 30) {
28518
+ const out = [];
28519
+ let before;
28520
+ while (out.length < limit) {
28521
+ const r = await this.call("GET", `/v1/files/${key}/versions?page_size=${Math.min(50, limit)}${before ? `&before=${before}` : ""}`);
28522
+ out.push(...r.versions ?? []);
28523
+ const next = r.pagination?.prev_page ? new URL(r.pagination.prev_page).searchParams.get("before") : null;
28524
+ if (!next || !r.versions?.length)
28525
+ break;
28526
+ before = next;
28527
+ }
28528
+ return out.slice(0, limit);
28529
+ }
28530
+ async nodes(key, ids, version) {
28531
+ const q = `ids=${encodeURIComponent(ids.join(","))}${version ? `&version=${encodeURIComponent(version)}` : ""}`;
28532
+ const r = await this.call("GET", `/v1/files/${key}/nodes?${q}`);
28533
+ const out = {};
28534
+ for (const [id, n] of Object.entries(r.nodes ?? {}))
28535
+ if (n?.document)
28536
+ out[id] = n.document;
28537
+ return out;
28538
+ }
28539
+ async pages(key, version) {
28540
+ const r = await this.call("GET", `/v1/files/${key}?depth=1${version ? `&version=${encodeURIComponent(version)}` : ""}`);
28541
+ return r.document?.children ?? [];
28542
+ }
28543
+ }
28544
+ function threadComments(list, includeResolved) {
28545
+ const replies = new Map;
28546
+ for (const c of list)
28547
+ if (c.parent_id)
28548
+ replies.set(c.parent_id, [...replies.get(c.parent_id) ?? [], c]);
28549
+ const brief = (c) => ({ id: c.id, author: c.user?.handle, message: c.message, createdAt: c.created_at });
28550
+ return list.filter((c) => !c.parent_id && (includeResolved || !c.resolved_at)).sort((a, b) => a.created_at.localeCompare(b.created_at)).map((c) => ({
28551
+ ...brief(c),
28552
+ ...c.order_id ? { number: Number(c.order_id) } : {},
28553
+ ...c.resolved_at ? { resolvedAt: c.resolved_at } : {},
28554
+ ...c.client_meta?.node_id ? { nodeId: c.client_meta.node_id } : {},
28555
+ replies: (replies.get(c.id) ?? []).sort((a, b) => a.created_at.localeCompare(b.created_at)).map(brief)
28556
+ }));
28557
+ }
28558
+ function flatten(root, prefix, out) {
28559
+ const seen = new Map;
28560
+ for (const c of root.children ?? []) {
28561
+ const n = (seen.get(c.name) ?? 0) + 1;
28562
+ seen.set(c.name, n);
28563
+ const path = `${prefix}${c.name}${n > 1 ? ` #${n}` : ""}`;
28564
+ out.set(path, c);
28565
+ flatten(c, `${path}/`, out);
28566
+ }
28567
+ }
28568
+ function diffTrees(before, after, max = 200) {
28569
+ const a = new Map;
28570
+ const b = new Map;
28571
+ flatten(before, "", a);
28572
+ flatten(after, "", b);
28573
+ const added = [];
28574
+ const removed = [];
28575
+ const changed = [];
28576
+ for (const [path, n] of b)
28577
+ if (!a.has(path))
28578
+ added.push({ path, type: n.type, id: n.id });
28579
+ for (const [path, n] of a)
28580
+ if (!b.has(path))
28581
+ removed.push({ path, type: n.type, id: n.id });
28582
+ for (const [path, now] of b) {
28583
+ const old = a.get(path);
28584
+ if (!old)
28585
+ continue;
28586
+ const c = [];
28587
+ if (old.type !== now.type)
28588
+ c.push(`type ${old.type} → ${now.type}`);
28589
+ if ((old.visible ?? true) !== (now.visible ?? true))
28590
+ c.push(now.visible === false ? "hidden" : "shown");
28591
+ if ((old.characters ?? "") !== (now.characters ?? ""))
28592
+ c.push(`text "${(old.characters ?? "").slice(0, 60)}" → "${(now.characters ?? "").slice(0, 60)}"`);
28593
+ const ob = old.absoluteBoundingBox;
28594
+ const nb = now.absoluteBoundingBox;
28595
+ if (ob && nb) {
28596
+ if (Math.abs(ob.width - nb.width) > 0.5 || Math.abs(ob.height - nb.height) > 0.5)
28597
+ c.push(`size ${Math.round(ob.width)}×${Math.round(ob.height)} → ${Math.round(nb.width)}×${Math.round(nb.height)}`);
28598
+ if (Math.abs(ob.x - nb.x) > 0.5 || Math.abs(ob.y - nb.y) > 0.5)
28599
+ c.push(`moved ${Math.round(nb.x - ob.x)}, ${Math.round(nb.y - ob.y)}`);
28600
+ }
28601
+ if (fillsOf(old) !== fillsOf(now))
28602
+ c.push(`fill ${fillsOf(old) || "none"} → ${fillsOf(now) || "none"}`);
28603
+ if (c.length)
28604
+ changed.push({ path, type: now.type, id: now.id, changes: c });
28605
+ }
28606
+ const movedOnly = (x) => x.changes.length === 1 && x.changes[0].startsWith("moved");
28607
+ const movedPaths = new Set(changed.filter(movedOnly).map((x) => x.path));
28608
+ const cleaned = changed.filter((x) => !(movedOnly(x) && [...movedPaths].some((p) => p !== x.path && x.path.startsWith(`${p}/`))));
28609
+ const top = (list) => list.filter((x) => !list.some((y) => y !== x && x.path.startsWith(`${y.path}/`)));
28610
+ const addedTop = top(added);
28611
+ const removedTop = top(removed);
28612
+ return {
28613
+ summary: { added: addedTop.length, removed: removedTop.length, changed: cleaned.length, layersBefore: a.size, layersAfter: b.size },
28614
+ added: addedTop.slice(0, max),
28615
+ removed: removedTop.slice(0, max),
28616
+ changed: cleaned.slice(0, max),
28617
+ truncated: addedTop.length > max || removedTop.length > max || cleaned.length > max
28618
+ };
28619
+ }
28620
+ 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(",");
28621
+ var init_rest = __esm(() => {
28622
+ 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.';
28623
+ RestError = class RestError extends Error {
28624
+ code;
28625
+ status;
28626
+ constructor(message, code, status) {
28627
+ super(message);
28628
+ this.code = code;
28629
+ this.status = status;
28630
+ }
28631
+ };
28632
+ });
28633
+
28634
+ // src/project/detect.ts
28635
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3, statSync } from "node:fs";
28636
+ import { join as join3, relative, sep } from "node:path";
28637
+ function listFiles(root, filter, max = 4000) {
28638
+ const out = [];
28639
+ const walk = (dir, depth) => {
28640
+ if (out.length >= max || depth > 8)
28641
+ return;
28642
+ let entries;
28643
+ try {
28644
+ entries = readdirSync(dir);
28645
+ } catch {
28646
+ return;
28647
+ }
28648
+ for (const name of entries.sort()) {
28649
+ if (IGNORE.has(name) || name.startsWith(".") && name !== ".storybook")
28650
+ continue;
28651
+ const full = join3(dir, name);
28652
+ let st;
28653
+ try {
28654
+ st = statSync(full);
28655
+ } catch {
28656
+ continue;
28657
+ }
28658
+ if (st.isDirectory())
28659
+ walk(full, depth + 1);
28660
+ else {
28661
+ const rel = relative(root, full).split(sep).join("/");
28662
+ if (filter(rel))
28663
+ out.push(rel);
28664
+ }
28665
+ if (out.length >= max)
28666
+ return;
28667
+ }
28668
+ };
28669
+ walk(root, 0);
28670
+ return out;
28671
+ }
28672
+ function readJsonc(path) {
28673
+ try {
28674
+ const text = readFileSync3(path, "utf8").replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, str) => str ?? "").replace(/,(\s*[}\]])/g, "$1");
28675
+ return JSON.parse(text);
28676
+ } catch {
28677
+ return null;
28678
+ }
28679
+ }
28680
+ function detectStack(root) {
28681
+ const pkg = readJsonc(join3(root, "package.json"));
28682
+ if (!pkg)
28683
+ throw new Error(`No package.json in ${root}: give the root folder of a JavaScript project as projectPath.`);
28684
+ const deps = { ...pkg.peerDependencies, ...pkg.devDependencies, ...pkg.dependencies };
28685
+ const has = (d) => deps[d] !== undefined;
28686
+ const at = (...p) => existsSync2(join3(root, ...p));
28687
+ const evidence = [];
28688
+ let kind = "html";
28689
+ let framework = "html";
28690
+ if (has("react-native") || has("expo"))
28691
+ [kind, framework] = [has("expo") ? "expo" : "react-native", "react-native"];
28692
+ else if (has("next"))
28693
+ [kind, framework] = ["next", "react"];
28694
+ else if (has("nuxt"))
28695
+ [kind, framework] = ["nuxt", "vue"];
28696
+ else if (has("vue"))
28697
+ [kind, framework] = ["vue", "vue"];
28698
+ else if (has("@sveltejs/kit"))
28699
+ [kind, framework] = ["sveltekit", "svelte"];
28700
+ else if (has("svelte"))
28701
+ [kind, framework] = ["svelte", "svelte"];
28702
+ else if (has("react"))
28703
+ [kind, framework] = [has("vite") ? "vite" : "react", "react"];
28704
+ evidence.push(`framework: ${kind}`);
28705
+ const tailwindConfig = ["tailwind.config.ts", "tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs"].find((f) => at(f));
28706
+ let tailwind;
28707
+ if (has("tailwindcss") || has("nativewind")) {
28708
+ const v = major(deps.tailwindcss);
28709
+ tailwind = has("@tailwindcss/postcss") || has("@tailwindcss/vite") || v !== undefined && v >= 4 ? 4 : 3;
28710
+ evidence.push(`tailwindcss ${tailwind}${tailwindConfig ? ` (${tailwindConfig})` : ""}`);
28711
+ }
28712
+ const sourceFiles = listFiles(root, (f) => /\.(module\.(css|scss)|css|tsx|jsx|ts|js|vue|svelte)$/.test(f), 3000);
28713
+ let styling = "css";
28714
+ if (tailwind && (framework !== "react-native" || has("nativewind")))
28715
+ styling = "tailwind";
28716
+ else if (has("styled-components") || has("@emotion/styled"))
28717
+ styling = "styled-components";
28718
+ else if (sourceFiles.some((f) => /\.module\.(css|scss)$/.test(f)))
28719
+ styling = "css-modules";
28720
+ evidence.push(`styling: ${styling}`);
28721
+ const ui = [];
28722
+ const shadcn = readJsonc(join3(root, "components.json"));
28723
+ if (shadcn?.aliases || has("class-variance-authority") && sourceFiles.some((f) => /components\/ui\/button\.(t|j)sx$/.test(f)))
28724
+ ui.push("shadcn");
28725
+ if (has("@mui/material"))
28726
+ ui.push("mui");
28727
+ if (has("@chakra-ui/react"))
28728
+ ui.push("chakra");
28729
+ if (ui.length)
28730
+ evidence.push(`ui: ${ui.join(", ")}`);
28731
+ const tsconfig = readJsonc(join3(root, "tsconfig.json")) ?? readJsonc(join3(root, "jsconfig.json"));
28732
+ const typescript = at("tsconfig.json") || has("typescript");
28733
+ const aliases = [];
28734
+ const paths = tsconfig?.compilerOptions?.paths ?? {};
28735
+ const baseUrl = String(tsconfig?.compilerOptions?.baseUrl ?? ".");
28736
+ for (const [key, targets] of Object.entries(paths)) {
26905
28737
  const target = Array.isArray(targets) ? String(targets[0]) : "";
26906
28738
  if (!key.endsWith("/*") || !target.endsWith("/*"))
26907
28739
  continue;
26908
- const t = join2(baseUrl, target.slice(0, -2)).split(sep).join("/").replace(/^\.\//, "");
28740
+ const t = join3(baseUrl, target.slice(0, -2)).split(sep).join("/").replace(/^\.\//, "");
26909
28741
  aliases.push({ prefix: key.slice(0, -1), target: t === "." ? "" : `${t}/` });
26910
28742
  }
26911
28743
  if (kind === "nuxt" && !aliases.length)
@@ -26942,8 +28774,8 @@ var init_detect = __esm(() => {
26942
28774
  });
26943
28775
 
26944
28776
  // src/project/components.ts
26945
- import { readFileSync as readFileSync3 } from "node:fs";
26946
- import { basename, join as join3 } from "node:path";
28777
+ import { readFileSync as readFileSync4 } from "node:fs";
28778
+ import { basename, join as join4 } from "node:path";
26947
28779
  function block(text, start, open = "{", close = "}") {
26948
28780
  let depth = 0;
26949
28781
  for (let i = start;i < text.length; i++) {
@@ -27123,7 +28955,7 @@ function indexComponents(stack, max = 400) {
27123
28955
  for (const file of files) {
27124
28956
  let src;
27125
28957
  try {
27126
- src = readFileSync3(join3(stack.root, file), "utf8");
28958
+ src = readFileSync4(join4(stack.root, file), "utf8");
27127
28959
  } catch {
27128
28960
  continue;
27129
28961
  }
@@ -27174,14 +29006,14 @@ var init_components = __esm(() => {
27174
29006
  });
27175
29007
 
27176
29008
  // src/project/mapping.ts
27177
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "node:fs";
27178
- import { join as join4 } from "node:path";
29009
+ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "node:fs";
29010
+ import { join as join5 } from "node:path";
27179
29011
  function loadMapFile(root) {
27180
- const path = join4(root, MAP_FILE);
29012
+ const path = join5(root, MAP_FILE);
27181
29013
  if (!existsSync3(path))
27182
29014
  return { map: {} };
27183
29015
  try {
27184
- return { map: JSON.parse(readFileSync4(path, "utf8")), path };
29016
+ return { map: JSON.parse(readFileSync5(path, "utf8")), path };
27185
29017
  } catch (e) {
27186
29018
  return { map: {}, error: `${MAP_FILE} is not valid JSON: ${e.message}` };
27187
29019
  }
@@ -27338,8 +29170,8 @@ var init_mapping = __esm(() => {
27338
29170
  });
27339
29171
 
27340
29172
  // src/project/tokens.ts
27341
- import { readFileSync as readFileSync5 } from "node:fs";
27342
- import { join as join5 } from "node:path";
29173
+ import { readFileSync as readFileSync6 } from "node:fs";
29174
+ import { join as join6 } from "node:path";
27343
29175
  function oklch(l, c, h, a) {
27344
29176
  const hr = h * Math.PI / 180;
27345
29177
  return oklab(l, c * Math.cos(hr), c * Math.sin(hr), a);
@@ -27426,7 +29258,7 @@ function loadTokens(stack) {
27426
29258
  for (const file of stack.cssFiles) {
27427
29259
  let css;
27428
29260
  try {
27429
- css = readFileSync5(join5(stack.root, file), "utf8");
29261
+ css = readFileSync6(join6(stack.root, file), "utf8");
27430
29262
  } catch {
27431
29263
  continue;
27432
29264
  }
@@ -27470,7 +29302,7 @@ function loadTokens(stack) {
27470
29302
  }
27471
29303
  if (stack.tailwind === 3 && stack.tailwindConfig) {
27472
29304
  try {
27473
- for (const t of tailwind3Colors(readFileSync5(join5(stack.root, stack.tailwindConfig), "utf8")))
29305
+ for (const t of tailwind3Colors(readFileSync6(join6(stack.root, stack.tailwindConfig), "utf8")))
27474
29306
  index.add(t);
27475
29307
  index.sources.push(stack.tailwindConfig);
27476
29308
  } catch {}
@@ -27534,8 +29366,8 @@ var init_tokens2 = __esm(() => {
27534
29366
  });
27535
29367
 
27536
29368
  // 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";
29369
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "node:fs";
29370
+ import { dirname as dirname3, join as join7, posix } from "node:path";
27539
29371
  function planProjectExport(root, tree, assets, opts = {}) {
27540
29372
  const stack = detectStack(root);
27541
29373
  if (opts.framework)
@@ -27607,19 +29439,19 @@ function planProjectExport(root, tree, assets, opts = {}) {
27607
29439
  }
27608
29440
  function writePlan(root, files, overwrite) {
27609
29441
  return files.map((f) => {
27610
- const target = join6(root, f.path);
29442
+ const target = join7(root, f.path);
27611
29443
  const next = typeof f.content === "string" ? Buffer.from(f.content) : Buffer.from(f.content);
27612
29444
  if (existsSync4(target)) {
27613
- const before = readFileSync6(target);
29445
+ const before = readFileSync7(target);
27614
29446
  if (before.equals(next))
27615
29447
  return { path: f.path, status: "unchanged" };
27616
29448
  if (!overwrite)
27617
29449
  return { path: f.path, status: "skipped (exists)" };
27618
- writeFileSync2(target, next);
29450
+ writeFileSync4(target, next);
27619
29451
  return { path: f.path, status: "updated" };
27620
29452
  }
27621
- mkdirSync2(dirname2(target), { recursive: true });
27622
- writeFileSync2(target, next);
29453
+ mkdirSync4(dirname3(target), { recursive: true });
29454
+ writeFileSync4(target, next);
27623
29455
  return { path: f.path, status: "created" };
27624
29456
  });
27625
29457
  }
@@ -27688,7 +29520,7 @@ class FontIndex {
27688
29520
  }
27689
29521
  }
27690
29522
  var GENERIC, STYLE_WEIGHTS;
27691
- var init_fonts = __esm(() => {
29523
+ var init_fonts2 = __esm(() => {
27692
29524
  GENERIC = {
27693
29525
  "sans-serif": ["Inter", "Roboto", "Arial", "Helvetica"],
27694
29526
  "system-ui": ["Inter", "SF Pro Text", "Segoe UI", "Roboto", "Arial"],
@@ -27838,7 +29670,7 @@ function cssLiteral(v, value) {
27838
29670
  if (typeof value === "boolean")
27839
29671
  return null;
27840
29672
  if (typeof value === "number")
27841
- return unitless(v) ? num(value) : `${num(value)}px`;
29673
+ return unitless(v) ? num2(value) : `${num2(value)}px`;
27842
29674
  if (v.type === "string" && category(v) === "fontFamily")
27843
29675
  return /^[\w-]+$/.test(String(value)) ? String(value) : quote(String(value));
27844
29676
  if (v.type === "string")
@@ -27846,12 +29678,12 @@ function cssLiteral(v, value) {
27846
29678
  return String(value);
27847
29679
  }
27848
29680
  function gradientCss(g) {
27849
- const stops = g.gradient.map((s) => `${s.color} ${num(s.at * 100)}%`).join(", ");
29681
+ const stops = g.gradient.map((s) => `${s.color} ${num2(s.at * 100)}%`).join(", ");
27850
29682
  if (g.type === "radial")
27851
29683
  return `radial-gradient(circle, ${stops})`;
27852
29684
  if (g.type)
27853
29685
  return `conic-gradient(${stops})`;
27854
- return `linear-gradient(${num((g.angle ?? 0) + 90)}deg, ${stops})`;
29686
+ return `linear-gradient(${num2((g.angle ?? 0) + 90)}deg, ${stops})`;
27855
29687
  }
27856
29688
  function paintCss(value, ref) {
27857
29689
  const one = (p) => typeof p === "string" ? p : p && p.alias ? ref(p.alias) : p && p.gradient ? gradientCss(p) : "transparent";
@@ -27860,7 +29692,7 @@ function paintCss(value, ref) {
27860
29692
  return value.slice().reverse().map((p) => typeof p === "string" || p && p.alias ? `linear-gradient(${one(p)}, ${one(p)})` : one(p)).join(", ");
27861
29693
  }
27862
29694
  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}`);
29695
+ 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
29696
  return list.length ? list.join(", ") : null;
27865
29697
  }
27866
29698
  function textDecls(t, ref) {
@@ -27869,7 +29701,7 @@ function textDecls(t, ref) {
27869
29701
  const d = [
27870
29702
  ["font-family", bound.fontFamily ? ref(bound.fontFamily) : `${/^[\w-]+$/.test(t.font) ? t.font : quote(t.font)}, sans-serif`],
27871
29703
  ["font-weight", bound.fontWeight ? ref(bound.fontWeight) : String(w.weight)],
27872
- ["font-size", bound.fontSize ? ref(bound.fontSize) : `${num(t.size)}px`]
29704
+ ["font-size", bound.fontSize ? ref(bound.fontSize) : `${num2(t.size)}px`]
27873
29705
  ];
27874
29706
  if (w.italic)
27875
29707
  d.push(["font-style", "italic"]);
@@ -28146,7 +29978,7 @@ ${textDecls(t, ref).map(([k, v]) => ` ${k}: ${v};`).join(`
28146
29978
  }
28147
29979
  function tsLiteral(value) {
28148
29980
  if (typeof value === "number")
28149
- return num(value);
29981
+ return num2(value);
28150
29982
  if (typeof value === "boolean")
28151
29983
  return String(value);
28152
29984
  return quote(String(value));
@@ -28266,7 +30098,7 @@ function dtcgType(v) {
28266
30098
  function toDtcg(model) {
28267
30099
  const out = {};
28268
30100
  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;
30101
+ const literal = (v, x) => isAlias(x) ? dtcgRef(x.alias) : dtcgType(v) === "dimension" ? `${num2(Number(x))}px` : x;
28270
30102
  for (const c of model.data.collections) {
28271
30103
  const group = { $extensions: { "figma-bridge": { collection: true, modes: c.modes } } };
28272
30104
  for (const v of c.variables) {
@@ -28297,9 +30129,9 @@ function toDtcg(model) {
28297
30129
  }
28298
30130
  for (const t of s.text) {
28299
30131
  const w = styleWeight(t.style);
28300
- const value = { fontFamily: t.font, fontWeight: w.weight, fontSize: `${num(t.size)}px` };
30132
+ const value = { fontFamily: t.font, fontWeight: w.weight, fontSize: `${num2(t.size)}px` };
28301
30133
  if (t.lineHeight !== undefined)
28302
- value.lineHeight = typeof t.lineHeight === "number" ? `${num(t.lineHeight)}px` : t.lineHeight;
30134
+ value.lineHeight = typeof t.lineHeight === "number" ? `${num2(t.lineHeight)}px` : t.lineHeight;
28303
30135
  if (t.letterSpacing !== undefined)
28304
30136
  value.letterSpacing = letterSpacingCss(t.letterSpacing);
28305
30137
  const token = { $type: "typography", $value: value, $extensions: { "figma-bridge": { style: t.style } } };
@@ -28313,7 +30145,7 @@ function toDtcg(model) {
28313
30145
  continue;
28314
30146
  const token = {
28315
30147
  $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 } : {} }))
30148
+ $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
30149
  };
28318
30150
  if (e.description)
28319
30151
  token.$description = e.description;
@@ -28360,9 +30192,9 @@ function exportTokenFiles(data, formats, opts = {}) {
28360
30192
  }
28361
30193
  return { files, warnings: [...data.warnings ?? [], ...model.warnings] };
28362
30194
  }
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;
30195
+ 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
30196
  var init_tokens_export = __esm(() => {
28365
- init_fonts();
30197
+ init_fonts2();
28366
30198
  FORMATS = ["dtcg", "css", "tailwind", "tailwind4", "scss", "ts", "json"];
28367
30199
  FILE_NAMES = {
28368
30200
  dtcg: "tokens.json",
@@ -28920,6 +30752,8 @@ function snapshot(opts) {
28920
30752
  }
28921
30753
 
28922
30754
  // src/web/browser.ts
30755
+ import { existsSync as existsSync5 } from "node:fs";
30756
+ import { join as join8 } from "node:path";
28923
30757
  import { pathToFileURL } from "node:url";
28924
30758
  async function loadPlaywright() {
28925
30759
  try {
@@ -28928,12 +30762,39 @@ async function loadPlaywright() {
28928
30762
  throw new WebImportError("import_web needs the optional package playwright-core, which is not installed. Reinstall without --no-optional, or run `npm install -g playwright-core` (it uses your installed Chrome or Edge; no browser is downloaded).", "MISSING_DEPENDENCY");
28929
30763
  }
28930
30764
  }
30765
+ function otherChromiumBrowsers() {
30766
+ const env = process.env;
30767
+ const roots = process.platform === "win32" ? [env.ProgramFiles, env["ProgramFiles(x86)"], env.LOCALAPPDATA, "C:\\Program Files", "C:\\Program Files (x86)"].filter(Boolean) : [];
30768
+ const candidates = process.platform === "win32" ? [
30769
+ { label: "Brave", paths: roots.map((r) => join8(r, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")) },
30770
+ { label: "Chromium", paths: roots.map((r) => join8(r, "Chromium", "Application", "chrome.exe")) },
30771
+ { label: "Vivaldi", paths: roots.map((r) => join8(r, "Vivaldi", "Application", "vivaldi.exe")) },
30772
+ { label: "Opera", paths: roots.map((r) => join8(r, "Programs", "Opera", "opera.exe")) }
30773
+ ] : process.platform === "darwin" ? [
30774
+ { label: "Brave", paths: ["/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"] },
30775
+ { label: "Chromium", paths: ["/Applications/Chromium.app/Contents/MacOS/Chromium"] },
30776
+ { label: "Arc", paths: ["/Applications/Arc.app/Contents/MacOS/Arc"] }
30777
+ ] : [
30778
+ { label: "Brave", paths: ["/usr/bin/brave-browser", "/usr/bin/brave"] },
30779
+ { label: "Chromium", paths: ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium"] }
30780
+ ];
30781
+ const found = [];
30782
+ for (const c of candidates) {
30783
+ const path = c.paths.find((p) => existsSync5(p));
30784
+ if (path)
30785
+ found.push({ label: c.label, path });
30786
+ }
30787
+ return found;
30788
+ }
28931
30789
  async function launchBrowser() {
28932
30790
  const { chromium } = await loadPlaywright();
28933
30791
  const tries = [];
28934
30792
  if (process.env.FIGMA_BRIDGE_BROWSER)
28935
30793
  tries.push({ label: process.env.FIGMA_BRIDGE_BROWSER, opts: { executablePath: process.env.FIGMA_BRIDGE_BROWSER } });
28936
- tries.push({ label: "Chrome", opts: { channel: "chrome" } }, { label: "Edge", opts: { channel: "msedge" } }, { label: "Playwright Chromium", opts: {} });
30794
+ tries.push({ label: "Chrome", opts: { channel: "chrome" } }, { label: "Edge", opts: { channel: "msedge" } });
30795
+ for (const b of otherChromiumBrowsers())
30796
+ tries.push({ label: b.label, opts: { executablePath: b.path } });
30797
+ tries.push({ label: "Playwright Chromium", opts: {} });
28937
30798
  const errors = [];
28938
30799
  for (const t of tries) {
28939
30800
  try {
@@ -28943,7 +30804,7 @@ async function launchBrowser() {
28943
30804
  `)[0]}`);
28944
30805
  }
28945
30806
  }
28946
- throw new WebImportError(`No browser found to render the page. Install Google Chrome or Microsoft Edge, or set FIGMA_BRIDGE_BROWSER to a Chromium-based browser. (${errors.join("; ")})`, "NO_BROWSER");
30807
+ throw new WebImportError(`No browser found to render the page. Install Chrome, Edge or Brave, or set FIGMA_BRIDGE_BROWSER to a Chromium-based browser. (${errors.join("; ")})`, "NO_BROWSER");
28947
30808
  }
28948
30809
  async function render(source, opts) {
28949
30810
  const given = [source.url, source.html, source.path].filter((v) => v !== undefined).length;
@@ -29975,16 +31836,16 @@ var BUILD_LIMIT = 2800, isSvg = (bytes) => /^\s*(<\?xml[^>]*>\s*)?(<!--[\s\S]*?-
29975
31836
  var init_import = __esm(() => {
29976
31837
  init_browser();
29977
31838
  init_convert();
29978
- init_fonts();
31839
+ init_fonts2();
29979
31840
  });
29980
31841
 
29981
31842
  // 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";
31843
+ import { createHash as createHash2 } from "node:crypto";
31844
+ import { mkdirSync as mkdirSync5, readdirSync as readdirSync2, readFileSync as readFileSync8, rmSync, statSync as statSync2, writeFileSync as writeFileSync5 } from "node:fs";
31845
+ import { homedir as homedir2 } from "node:os";
31846
+ import { join as join9 } from "node:path";
29986
31847
  function ensureDir() {
29987
- mkdirSync3(SNIPPETS_DIR, { recursive: true });
31848
+ mkdirSync5(SNIPPETS_DIR, { recursive: true });
29988
31849
  }
29989
31850
  function checkName(name) {
29990
31851
  if (!NAME.test(name))
@@ -29999,14 +31860,14 @@ function files() {
29999
31860
  }
30000
31861
  function listSnippets() {
30001
31862
  return files().map((f) => {
30002
- const source = readFileSync7(join7(SNIPPETS_DIR, f), "utf8");
31863
+ const source = readFileSync8(join9(SNIPPETS_DIR, f), "utf8");
30003
31864
  return { name: f.slice(0, -3), description: header2(source, "description"), usage: header2(source, "usage") };
30004
31865
  });
30005
31866
  }
30006
31867
  function getSnippet(name) {
30007
31868
  checkName(name);
30008
31869
  try {
30009
- return readFileSync7(join7(SNIPPETS_DIR, `${name}.js`), "utf8");
31870
+ return readFileSync8(join9(SNIPPETS_DIR, `${name}.js`), "utf8");
30010
31871
  } catch {
30011
31872
  throw new BridgeError(`No snippet named "${name}".`, "NOT_FOUND");
30012
31873
  }
@@ -30019,37 +31880,37 @@ function saveSnippet(name, code, description = "", usage = "") {
30019
31880
  // @usage ${usage.replace(/\n/g, " ") || `await lib.${name}(args)`}
30020
31881
  ${body}
30021
31882
  `;
30022
- const path = join7(SNIPPETS_DIR, `${name}.js`);
30023
- writeFileSync3(path, text);
31883
+ const path = join9(SNIPPETS_DIR, `${name}.js`);
31884
+ writeFileSync5(path, text);
30024
31885
  return path;
30025
31886
  }
30026
31887
  function deleteSnippet(name) {
30027
31888
  checkName(name);
30028
- rmSync(join7(SNIPPETS_DIR, `${name}.js`), { force: true });
31889
+ rmSync(join9(SNIPPETS_DIR, `${name}.js`), { force: true });
30029
31890
  }
30030
31891
  function loadLibrary() {
30031
31892
  const list = files();
30032
- const stamp = list.map((f) => `${f}:${statSync2(join7(SNIPPETS_DIR, f)).mtimeMs}`).join("|");
31893
+ const stamp = list.map((f) => `${f}:${statSync2(join9(SNIPPETS_DIR, f)).mtimeMs}`).join("|");
30033
31894
  if (cached2 && cached2.stamp === stamp)
30034
31895
  return cached2;
30035
31896
  const lib = {};
30036
31897
  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 };
31898
+ lib[f.slice(0, -3)] = readFileSync8(join9(SNIPPETS_DIR, f), "utf8");
31899
+ cached2 = { stamp, hash: stamp ? createHash2("sha1").update(stamp + JSON.stringify(lib)).digest("base64url").slice(0, 12) : "empty", lib };
30039
31900
  return cached2;
30040
31901
  }
30041
31902
  var SNIPPETS_DIR, NAME, cached2 = null;
30042
31903
  var init_snippets = __esm(() => {
30043
31904
  init_bridge();
30044
- SNIPPETS_DIR = process.env.FIGMA_BRIDGE_SNIPPETS || join7(homedir(), ".figma-bridge", "snippets");
31905
+ SNIPPETS_DIR = process.env.FIGMA_BRIDGE_SNIPPETS || join9(homedir2(), ".figma-bridge", "snippets");
30045
31906
  NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
30046
31907
  });
30047
31908
 
30048
31909
  // src/server.ts
30049
- import { appendFileSync, mkdirSync as mkdirSync4, statSync as statSync3, truncateSync, writeFileSync as writeFileSync4 } from "node:fs";
31910
+ import { appendFileSync, mkdirSync as mkdirSync6, statSync as statSync3, truncateSync, writeFileSync as writeFileSync6 } from "node:fs";
30050
31911
  import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
30051
- import { tmpdir } from "node:os";
30052
- import { dirname as dirname3, join as join8 } from "node:path";
31912
+ import { homedir as homedir3, tmpdir } from "node:os";
31913
+ import { dirname as dirname4, join as join10 } from "node:path";
30053
31914
  import { fileURLToPath } from "node:url";
30054
31915
  function log(line) {
30055
31916
  const text = `[${new Date().toISOString().slice(11, 23)}] ${line}`;
@@ -30140,6 +32001,12 @@ async function prepareSpec(spec, defaultColor) {
30140
32001
  return;
30141
32002
  if (Array.isArray(node))
30142
32003
  return node.forEach(walk);
32004
+ if (String(node.type ?? "").toLowerCase() === "diagram") {
32005
+ const expanded = diagramSpec(node);
32006
+ for (const k of Object.keys(node))
32007
+ delete node[k];
32008
+ Object.assign(node, expanded);
32009
+ }
30143
32010
  if (node.icon && !node.svg) {
30144
32011
  const size = typeof node.size === "number" ? node.size : 24;
30145
32012
  jobs.push(iconSvg(String(node.icon), size, typeof node.color === "string" ? node.color : defaultColor).then((svg) => void (node.svg = svg)));
@@ -30164,6 +32031,8 @@ async function prepareSpec(spec, defaultColor) {
30164
32031
  node.children.forEach(walk);
30165
32032
  if (Array.isArray(node.variants))
30166
32033
  node.variants.forEach(walk);
32034
+ if (Array.isArray(node.slides))
32035
+ node.slides.forEach(walk);
30167
32036
  if (node.base)
30168
32037
  walk(node.base);
30169
32038
  };
@@ -30175,7 +32044,7 @@ async function writeIfChanged(path, content) {
30175
32044
  const before = await readFile(path, "utf8").catch(() => null);
30176
32045
  if (before === content)
30177
32046
  return "unchanged";
30178
- await mkdir(dirname3(path), { recursive: true });
32047
+ await mkdir(dirname4(path), { recursive: true });
30179
32048
  await writeFile(path, content);
30180
32049
  return before === null ? "created" : "updated";
30181
32050
  }
@@ -30189,7 +32058,46 @@ function renameAssets(n, names) {
30189
32058
  out.children = n.children.map((c) => renameAssets(c, names));
30190
32059
  return out;
30191
32060
  }
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);
32061
+ async function restContext(fileUrl) {
32062
+ const token = process.env.FIGMA_TOKEN;
32063
+ if (!token)
32064
+ throw new BridgeError(TOKEN_HELP, "NO_TOKEN");
32065
+ const session = await bridge.current().catch(() => null);
32066
+ let key = fileUrl ? fileKeyFrom(fileUrl) : null;
32067
+ if (fileUrl && !key)
32068
+ throw new BridgeError(`Not a Figma file link or key: ${fileUrl}`, "BAD_ARGS");
32069
+ key ??= session?.fileKey ?? null;
32070
+ if (!key) {
32071
+ 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");
32072
+ }
32073
+ return { rest: new FigmaRest(token), key, session: session?.fileKey === key ? session : null };
32074
+ }
32075
+ async function layerNames(session, ids) {
32076
+ if (!session || !ids.length)
32077
+ return {};
32078
+ return bridge.request("node_info", { ids: [...new Set(ids)] }, 20000).catch(() => ({}));
32079
+ }
32080
+ var PORT, CHANNEL, OUT_DIR, LOG_FILE, MAX_OUTPUT_CHARS = 60000, MAX_IMAGE_BYTES, FIGMA_MAX_IMAGE_DIM = 4096, VIEW_MAX_DIM = 1568, bridge, ok = (value) => ({ content: [{ type: "text", text: json(value) }] }), plain = (text) => ({ content: [{ type: "text", text }] }), oneLine = (s, max = 120) => s.replace(/\s+/g, " ").trim().slice(0, max), safeName = (s) => s.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "node", INSTRUCTIONS, BUILD_DESCRIPTION = `Create a whole layout in ONE call from a declarative spec. Fonts load automatically, icons are fetched, images loaded. Much faster and safer than run_script for new UI.
32081
+ Node: {type?, name?, ...props, children?: Node[]}. type is inferred (text/spans→text, icon→icon, src→image, component→instance, variants→componentSet, else frame). Types: frame, component, componentSet, text, rect, ellipse, line, icon, image, svg, instance.
32082
+ FRAME: layout "row"|"column"|"grid" (auto-layout; omit for free positioning), gap (number|"auto"), padding (n | [v,h] | [t,r,b,l]), align (cross axis: start|center|end|baseline), justify (main axis: start|center|end|between), wrap, rowGap, clip. Frames have no fill unless set.
32083
+ GRID: layout:"grid", columns (count | tracks like [200,"1fr","2fr","hug"]), rows (same; default: enough rows, hugging), columnGap, rowGap (or gap). Children: span:[rows,cols] or colSpan/rowSpan, cellAlign/cellValign. Give the grid a w for "fr" columns.
32084
+ 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.
32085
+ 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.
32086
+ 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.
32087
+ 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].
32088
+ EFFECTS: radius (n | [tl,tr,br,bl] | "var:x"), opacity, shadow (true | {x,y,blur,spread,color} | [...] | "style:Name"), blur, backgroundBlur, rotation, visible.
32089
+ 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…>"}.
32090
+ INSTANCE: {component:"Button" | node id | library key, props:{Variant:"Primary", Label:"Buy"}, text:{"Label layer name":"Buy"}}.
32091
+ 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.
32092
+ 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.
32093
+ PROTOTYPE: reactions:[{trigger:"click", action:"navigate", to:"Details", transition:"smart", duration:300}] on any layer (see the prototype tool).
32094
+ SECTION: {type:"section", name, children (placed with x/y), w?, h?} (wraps its children when no size).
32095
+ 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"}.
32096
+ 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).
32097
+ SLIDES files: {slides:[{name, fill?, children:[…]}, …]} or {type:"slide", …}: one 1920×1080 slide per entry, children placed with x/y or auto-layout.
32098
+ gap/padding/radius accept "var:<number variable>". MODES: modes:{"Theme":"Dark"} sets a collection's variable mode on a frame and its children.
32099
+ 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"}]}]}
32100
+ 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
32101
  var init_server3 = __esm(async () => {
30194
32102
  init_mcp();
30195
32103
  init_stdio2();
@@ -30198,46 +32106,81 @@ var init_server3 = __esm(async () => {
30198
32106
  init_icons();
30199
32107
  init_image();
30200
32108
  init_codegen();
32109
+ init_diagram();
32110
+ init_guides();
32111
+ init_export();
32112
+ init_rest();
30201
32113
  init_plan();
30202
32114
  init_tokens();
30203
32115
  init_tokens_export();
30204
32116
  init_import();
32117
+ init_setup();
30205
32118
  init_snippets();
30206
32119
  init_package();
30207
32120
  PORT = Number(process.env.FIGMA_BRIDGE_PORT) || 3055;
30208
32121
  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");
32122
+ OUT_DIR = process.env.FIGMA_BRIDGE_OUT || join10(tmpdir(), "figma-bridge");
32123
+ LOG_FILE = join10(OUT_DIR, "bridge.log");
30211
32124
  MAX_IMAGE_BYTES = 50 << 20;
30212
- mkdirSync4(OUT_DIR, { recursive: true });
32125
+ mkdirSync6(OUT_DIR, { recursive: true });
30213
32126
  try {
30214
32127
  if (statSync3(LOG_FILE).size > 5 << 20)
30215
32128
  truncateSync(LOG_FILE, 0);
30216
32129
  } catch {}
30217
32130
  bridge = new Bridge({ port: PORT, channel: CHANNEL, version, log });
30218
32131
  bridge.start();
32132
+ if (process.env.FIGMA_BRIDGE_PLUGIN_SYNC !== "0") {
32133
+ try {
32134
+ const target = process.env.FIGMA_BRIDGE_PLUGIN_DIR || join10(homedir3(), ".figma-bridge", "plugin");
32135
+ const status = syncPlugin(join10(dirname4(fileURLToPath(import.meta.url)), "..", "plugin"), target, version);
32136
+ if (status === "installed" || status === "updated")
32137
+ log(`Figma plugin ${status} to v${version} in ${target}; reopen it in Figma`);
32138
+ } catch (e) {
32139
+ log(`Could not update the Figma plugin copy: ${e.message}`);
32140
+ }
32141
+ }
32142
+ INSTRUCTIONS = [
32143
+ "Figma Bridge drives the user's Figma desktop app through a local plugin. No rate limits: iterate freely.",
32144
+ "Workflow:",
32145
+ "1. get_context, then get_design_system when the file has styles, variables or components: reuse them instead of raw values. find locates layers by name, text, type, style or component across pages.",
32146
+ "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.",
32147
+ "3. Design system → design_tokens writes variables (with Light/Dark modes) and styles from simple JSON, W3C tokens or a Tailwind theme; build uses them via var:, style: and modes.",
32148
+ "4. Verify → screenshot with returnImage:true, and audit to catch contrast, overflow and naming issues. Fix, then check again. audit {scope:'design-system'} scores the file's design system; audit {fix:true} binds raw values to variables and styles.",
32149
+ "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.",
32150
+ "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.",
32151
+ "Existing website or HTML → import_web turns it into editable auto-layout frames (one per viewport); then compare against the returned reference screenshot.",
32152
+ "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.",
32153
+ "run_script: `figma` global, top-level await, `return` a small JSON value (nodes come back as {id,name,type}).",
32154
+ "Script helpers: utils.loadFonts('Inter:Bold', …), utils.node(id), utils.page(name), utils.hex('#hex'), utils.solid('#hex', opacity?),",
32155
+ "utils.build(spec, {parentId}), utils.describe(nodeOrId, depth); lib.<name>(args) runs a saved snippet; console.log is returned as `logs`.",
32156
+ "Save helpers you will reuse with the snippets tool. Icons: search_icons, then insert_icon or {icon:'set:name'} in build.",
32157
+ "Rules: load fonts before editing text; pages load on demand (await figma.setCurrentPageAsync / getNodeByIdAsync); keep results small.",
32158
+ "The user can cancel a command from the plugin (code CANCELLED): build stops, a script may still finish, so check the file before retrying.",
32159
+ "With FIGMA_TOKEN set: comments reads and posts file comments pinned to layers, versions lists the history and diffs a version against now.",
32160
+ "Roblox: export_roblox turns a frame into a ScreenGui (.rbxmx + Luau for execute_luau + PNG assets); with a Roblox Studio MCP, upload the assets, run the Luau and check a Studio screenshot.",
32161
+ "Prompts new-screen, apply-design-system, reproduce-screenshot, import-website, figma-to-code, audit-and-fix and figma-to-roblox are ready-made workflows; the resources figma-bridge://docs/build-spec and figma-bridge://docs/workflow are the full reference.",
32162
+ "If no file is connected, tell the user to run Plugins → Development → Figma Bridge in Figma (Ctrl+Alt+P re-runs it). If list_sessions reports versionMismatch, ask them to reopen or update the plugin."
32163
+ ];
30219
32164
  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(`
32165
+ instructions: INSTRUCTIONS.join(`
30239
32166
  `)
30240
32167
  });
32168
+ SESSION_TOOLS = new Set(["list_sessions", "select_session"]);
32169
+ registerTool = server.registerTool.bind(server);
32170
+ toolDocs = [];
32171
+ server.registerTool = (name, config, handler) => {
32172
+ toolDocs.push({ name, title: config.title, description: config.description ?? "" });
32173
+ if (SESSION_TOOLS.has(name))
32174
+ return registerTool(name, config, handler);
32175
+ const inputSchema = {
32176
+ ...config.inputSchema,
32177
+ file: string2().optional().describe("Target file name or session id when several files are connected (default: the selected file)")
32178
+ };
32179
+ return registerTool(name, { ...config, inputSchema }, (args, extra) => {
32180
+ const { file, ...rest } = args ?? {};
32181
+ return callTarget.run({ file: typeof file === "string" && file ? file : undefined }, () => handler(rest, extra));
32182
+ });
32183
+ };
30241
32184
  server.registerTool("run_script", {
30242
32185
  title: "Run Figma Plugin API script",
30243
32186
  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 +32196,7 @@ var init_server3 = __esm(async () => {
30253
32196
  }));
30254
32197
  server.registerTool("build", {
30255
32198
  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.`,
32199
+ description: BUILD_DESCRIPTION,
30273
32200
  inputSchema: {
30274
32201
  spec: object2({}).passthrough().describe("Root node: {name, layout, children:[...], ...}. Several roots: wrap them in a frame or call build again."),
30275
32202
  parentId: string2().optional().describe("Parent frame (default: current page)"),
@@ -30356,7 +32283,7 @@ ${f.content}`).join(`
30356
32283
  const single = files.length === 1 && /\.[a-z0-9]+$/i.test(args.path);
30357
32284
  const written = [];
30358
32285
  for (const f of files) {
30359
- const target = single ? args.path : join8(args.path, f.path);
32286
+ const target = single ? args.path : join10(args.path, f.path);
30360
32287
  written.push({ format: f.format, path: target, status: await writeIfChanged(target, f.content) });
30361
32288
  }
30362
32289
  return ok({ counts, files: written, ...warnings.length ? { warnings } : {} });
@@ -30394,7 +32321,7 @@ ${f.content}`).join(`
30394
32321
  const bytes = Buffer.from(r.bytesB64, "base64");
30395
32322
  const info = imageInfo(bytes);
30396
32323
  const ext = r.format === "JPG" ? "jpg" : "png";
30397
- const path = join8(OUT_DIR, `${safeName(r.name)}-${safeName(r.nodeId)}-${Date.now()}.${ext}`);
32324
+ const path = join10(OUT_DIR, `${safeName(r.name)}-${safeName(r.nodeId)}-${Date.now()}.${ext}`);
30398
32325
  await writeFile(path, bytes);
30399
32326
  const meta = { path, nodeId: r.nodeId, name: r.name, width: info?.width, height: info?.height, bytes: bytes.length };
30400
32327
  const content = [{ type: "text", text: json(meta) }];
@@ -30421,7 +32348,7 @@ ${f.content}`).join(`
30421
32348
  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
32349
  const pct = (v) => Math.round(v * 1e4) / 100;
30423
32350
  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`);
32351
+ const heatmapPath = join10(OUT_DIR, `compare-${safeName(shot.name)}-${Date.now()}.png`);
30425
32352
  await writeFile(heatmapPath, Buffer.from(diff.heatmap, "base64"));
30426
32353
  const refRatio = diff.reference.width / diff.reference.height;
30427
32354
  const ratio = diff.width / diff.height;
@@ -30495,7 +32422,7 @@ Example: {"url":"https://example.com","viewports":[1440,390]}`,
30495
32422
  const built = await bridge.request("build", { spec: v.spec, images, parentId: args.parentId, x, y, select: false, label: "Web import" }, 120000);
30496
32423
  let reference;
30497
32424
  if (v.screenshot) {
30498
- reference = join8(OUT_DIR, `import-${safeName(v.title || "page")}-${v.viewport}-${Date.now()}.png`);
32425
+ reference = join10(OUT_DIR, `import-${safeName(v.title || "page")}-${v.viewport}-${Date.now()}.png`);
30499
32426
  await writeFile(reference, v.screenshot);
30500
32427
  }
30501
32428
  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 +32492,7 @@ Links are added to existing interactions unless replace:true; clear:[ids] remove
30565
32492
  }
30566
32493
  }, ({ nodeId, projectPath, framework, styling, name, outDir, write, overwrite }) => track("export_code", `${nodeId ?? "(selection)"} ${projectPath ?? `${framework ?? "html"}/${styling ?? "css"}`}`, async () => {
30567
32494
  const r = await bridge.request("export_tree", { nodeId }, 120000);
30568
- const dir = join8(OUT_DIR, `export-${safeName(r.tree.name)}-${Date.now()}`);
32495
+ const dir = join10(OUT_DIR, `export-${safeName(r.tree.name)}-${Date.now()}`);
30569
32496
  const finalName = {};
30570
32497
  const assetBytes = {};
30571
32498
  for (const [file, a] of Object.entries(r.assets)) {
@@ -30581,8 +32508,8 @@ Links are added to existing interactions unless replace:true; clear:[ids] remove
30581
32508
  if (projectPath) {
30582
32509
  const plan = planProjectExport(projectPath, tree, assetBytes, { framework, styling, name, outDir });
30583
32510
  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));
32511
+ mkdirSync6(dirname4(join10(dir, f.path)), { recursive: true });
32512
+ writeFileSync6(join10(dir, f.path), typeof f.content === "string" ? f.content : Buffer.from(f.content));
30586
32513
  }
30587
32514
  const written = write ? writePlan(projectPath, plan.files, !!overwrite) : null;
30588
32515
  const s = plan.stack;
@@ -30607,13 +32534,13 @@ ${f.content}`).join(`
30607
32534
  … truncated: the full code is in ${dir}`;
30608
32535
  return { content: [{ type: "text", text: json(meta) }, { type: "text", text }] };
30609
32536
  }
30610
- mkdirSync4(join8(dir, "assets"), { recursive: true });
32537
+ mkdirSync6(join10(dir, "assets"), { recursive: true });
30611
32538
  for (const [file, bytes] of Object.entries(assetBytes))
30612
- writeFileSync4(join8(dir, "assets", file), bytes);
32539
+ writeFileSync6(join10(dir, "assets", file), bytes);
30613
32540
  const fw = framework ?? "html";
30614
32541
  const code = generateCode(tree, { framework: fw, styling: styling ?? "css", assetPath: (f) => `assets/${f}` });
30615
32542
  for (const f of code.files)
30616
- writeFileSync4(join8(dir, f.path), f.content);
32543
+ writeFileSync6(join10(dir, f.path), f.content);
30617
32544
  warnings.push(...code.warnings);
30618
32545
  const meta = {
30619
32546
  dir,
@@ -30762,9 +32689,119 @@ ${f.content}`).join(`
30762
32689
  return ok({ deleted: args.name });
30763
32690
  }
30764
32691
  }));
32692
+ server.registerTool("export_roblox", {
32693
+ title: "Export a frame to Roblox UI",
32694
+ description: "Turn a Figma frame (default: selection) into Roblox UI that looks the same: a .rbxmx model (a ScreenGui, or a Frame with asRootFrame), an equivalent Luau builder " + `script for execute_luau (creates the UI under PARENT, default StarterGui, replaces a previous copy with the same name, returns the root), and the PNG assets.
32695
+ ` + "Frames become Frames with UICorner, UIStroke, UIGradient, UIPadding; auto-layout becomes UIListLayout (UIFlexItem for fill, AutomaticSize for hug) or UIGridLayout; " + "constraints become AnchorPoint + UDim2; text becomes TextLabel with FontFace (Inter → Builder Sans…), RichText for mixed styles, UITextSizeConstraint; clickable or " + "*Button layers become TextButton/ImageButton. What Roblox can't draw (vectors, icons, blurs, several fills, radial gradients) is rasterized at 2×; panels whose background " + `can't be native become 9-slice images with their content on top; drop shadows become a 9-slice image behind the panel.
32696
+ ` + "mode: scale (UDim2 scale everywhere), offset (pixels), hybrid (default: scale for free-positioned layers, pixels inside auto-layout). " + "Assets: with upload:true and ROBLOX_API_KEY + ROBLOX_CREATOR_ID set, pictures are uploaded through Open Cloud and their ids written in; otherwise each asset has a " + "placeholder (rbxassetid://PENDING_n): upload the files with your Roblox Studio tools (e.g. upload_image), replace the placeholders in the Luau, run it with execute_luau and check a Studio screenshot. " + 'Example: {"nodeId":"12:34","mode":"hybrid"}',
32697
+ inputSchema: {
32698
+ nodeId: string2().optional(),
32699
+ mode: _enum(["scale", "offset", "hybrid"]).optional().describe("Default hybrid"),
32700
+ targetResolution: array(number2().int().min(100).max(8000)).length(2).optional().describe("Screen size the UI is designed for, default [1920, 1080]"),
32701
+ rasterize: _enum(["auto", "none", "all"]).optional().describe("auto (default): pictures only where Roblox can't draw it; none: approximate instead; all: every styled layer"),
32702
+ outDir: string2().optional().describe("Folder for the files (default: a new temp folder)"),
32703
+ upload: boolean2().optional().describe("Upload the pictures through Open Cloud (needs ROBLOX_API_KEY and ROBLOX_CREATOR_ID)"),
32704
+ asRootFrame: boolean2().optional().describe("Root is a Frame instead of a ScreenGui"),
32705
+ textScaled: boolean2().optional().describe("TextScaled on labels (UITextSizeConstraint keeps the design size as the maximum)"),
32706
+ fonts: object2({}).passthrough().optional().describe('Figma family → Roblox family or rbxasset URL, e.g. {"Inter":"GothamSSm"}'),
32707
+ parent: string2().optional().describe('Luau expression for the parent, default game:GetService("StarterGui")')
32708
+ }
32709
+ }, (args) => track("export_roblox", `${args.nodeId ?? "(selection)"} ${args.mode ?? "hybrid"}`, async () => {
32710
+ const r = await exportRoblox({ ...args, targetResolution: args.targetResolution, fonts: args.fonts }, { request: (m, p, t) => bridge.request(m, p, t), outDir: OUT_DIR });
32711
+ let luau = r.luau;
32712
+ if (luau.length > MAX_OUTPUT_CHARS)
32713
+ luau = luau.slice(0, MAX_OUTPUT_CHARS) + `
32714
+ -- … truncated: the full script is in ${r.result.luau}`;
32715
+ return { content: [{ type: "text", text: json(r.result) }, { type: "text", text: luau }] };
32716
+ }));
32717
+ server.registerTool("comments", {
32718
+ title: "Read and write file comments",
32719
+ 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"}',
32720
+ inputSchema: {
32721
+ action: _enum(["list", "post", "reply", "resolve", "delete"]).optional().describe("Default list"),
32722
+ fileUrl: string2().optional().describe("File link or key (default: the connected file)"),
32723
+ nodeId: string2().optional().describe("post: layer to pin the comment to"),
32724
+ x: number2().optional().describe("post: offset inside the layer, px"),
32725
+ y: number2().optional(),
32726
+ message: string2().max(5000).optional(),
32727
+ commentId: string2().optional().describe("reply, resolve, delete"),
32728
+ includeResolved: boolean2().optional().describe("list: include resolved threads")
32729
+ }
32730
+ }, (args) => track("comments", `${args.action ?? "list"} ${args.commentId ?? args.nodeId ?? ""}`, async () => {
32731
+ const { rest, key, session } = await restContext(args.fileUrl);
32732
+ const action = args.action ?? "list";
32733
+ const need = (v, what) => {
32734
+ if (!v)
32735
+ throw new BridgeError(`\`${what}\` is required for ${action}.`, "BAD_ARGS");
32736
+ return v;
32737
+ };
32738
+ if (action === "list") {
32739
+ const threads = threadComments(await rest.comments(key), !!args.includeResolved);
32740
+ const names = await layerNames(session, threads.map((t) => t.nodeId).filter((x) => !!x));
32741
+ return ok({
32742
+ fileKey: key,
32743
+ open: threads.filter((t) => !t.resolvedAt).length,
32744
+ threads: threads.map((t) => t.nodeId && names[t.nodeId] ? { ...t, layer: names[t.nodeId] } : t)
32745
+ });
32746
+ }
32747
+ if (action === "post") {
32748
+ 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 });
32749
+ return ok({ posted: c.id, nodeId: args.nodeId ?? null });
32750
+ }
32751
+ if (action === "reply") {
32752
+ const c = await rest.postComment(key, need(args.message, "message"), { replyTo: need(args.commentId, "commentId") });
32753
+ return ok({ replied: c.id, to: args.commentId });
32754
+ }
32755
+ if (action === "resolve") {
32756
+ const c = await rest.postComment(key, args.message ?? "✓ Resolved", { replyTo: need(args.commentId, "commentId") });
32757
+ 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." });
32758
+ }
32759
+ await rest.deleteComment(key, need(args.commentId, "commentId"));
32760
+ return ok({ deleted: args.commentId });
32761
+ }));
32762
+ server.registerTool("versions", {
32763
+ title: "File versions and what changed",
32764
+ 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"}',
32765
+ inputSchema: {
32766
+ action: _enum(["list", "diff"]).optional().describe("Default list"),
32767
+ fileUrl: string2().optional().describe("File link or key (default: the connected file)"),
32768
+ versionId: string2().optional().describe("diff: the older version"),
32769
+ toVersionId: string2().optional().describe("diff: the newer version (default: now)"),
32770
+ nodeId: string2().optional().describe("diff: only this layer and its children"),
32771
+ limit: number2().int().min(1).max(200).optional().describe("list: max versions, default 30; diff: max items per list, default 200")
32772
+ }
32773
+ }, (args) => track("versions", `${args.action ?? "list"} ${args.versionId ?? ""}`, async () => {
32774
+ const { rest, key, session } = await restContext(args.fileUrl);
32775
+ if ((args.action ?? "list") === "list") {
32776
+ const list = await rest.versions(key, args.limit ?? 30);
32777
+ return ok({
32778
+ fileKey: key,
32779
+ 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 }))
32780
+ });
32781
+ }
32782
+ if (!args.versionId)
32783
+ throw new BridgeError('`versionId` is required for diff: call versions {action:"list"} first.', "BAD_ARGS");
32784
+ let ids;
32785
+ if (args.nodeId)
32786
+ ids = [args.nodeId];
32787
+ else if (session?.pageId)
32788
+ ids = [session.pageId];
32789
+ else
32790
+ ids = [...new Set([...await rest.pages(key, args.versionId), ...await rest.pages(key, args.toVersionId)].map((p) => p.id))];
32791
+ const [before, after] = await Promise.all([rest.nodes(key, ids, args.versionId), rest.nodes(key, ids, args.toVersionId)]);
32792
+ const empty = (id, name) => ({ id, name, type: "PAGE", children: [] });
32793
+ const results = ids.map((id) => {
32794
+ const b = before[id];
32795
+ const a = after[id];
32796
+ const name = (a ?? b)?.name ?? id;
32797
+ const d = diffTrees(b ?? empty(id, name), a ?? empty(id, name), args.limit ?? 200);
32798
+ return { id, name, ...b ? {} : { note: "did not exist in the older version" }, ...a ? {} : { note: "no longer exists" }, ...d };
32799
+ });
32800
+ return ok({ fileKey: key, from: args.versionId, to: args.toVersionId ?? "now", scopes: results });
32801
+ }));
30765
32802
  server.registerTool("list_sessions", {
30766
32803
  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.",
32804
+ 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
32805
  inputSchema: {}
30769
32806
  }, () => track("list_sessions", "", async () => {
30770
32807
  const sessions = await bridge.sessions().catch(() => []);
@@ -30776,6 +32813,7 @@ ${f.content}`).join(`
30776
32813
  fileName: s.fileName,
30777
32814
  page: s.page,
30778
32815
  pluginVersion: s.version,
32816
+ editor: s.editorType,
30779
32817
  ...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
32818
  connectedSeconds: Math.round((Date.now() - s.connectedAt) / 1000),
30781
32819
  selected: s.id === selected || undefined
@@ -30791,6 +32829,7 @@ ${f.content}`).join(`
30791
32829
  const s = await bridge.select(name);
30792
32830
  return ok({ selected: { id: s.id, fileName: s.fileName, page: s.page } });
30793
32831
  }));
32832
+ registerGuides(server, { instructions: INSTRUCTIONS, buildSpec: BUILD_DESCRIPTION, tools: () => toolDocs });
30794
32833
  await server.connect(new StdioServerTransport);
30795
32834
  log(`Figma Bridge MCP ${version} ready (port ${PORT}, channel ${CHANNEL}, files in ${OUT_DIR})`);
30796
32835
  process.stdin.on("close", shutdown);
@@ -30799,154 +32838,20 @@ ${f.content}`).join(`
30799
32838
  });
30800
32839
 
30801
32840
  // src/cli.ts
30802
- import { homedir as homedir2 } from "node:os";
30803
- import { dirname as dirname4, join as join9 } from "node:path";
30804
- import { fileURLToPath as fileURLToPath2 } from "node:url";
30805
-
30806
- // src/setup.ts
30807
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
30808
- import { dirname, join } from "node:path";
30809
- var SERVER_NAME = "FigmaBridge";
30810
- function clientList(home, appData) {
30811
- return [
30812
- { id: "claude-code", label: "Claude Code", files: [join(home, ".claude.json")], format: "json", extra: { type: "stdio" } },
30813
- { id: "claude-desktop", label: "Claude Desktop", files: [join(appData, "Claude", "claude_desktop_config.json")], format: "json" },
30814
- { id: "codex", label: "Codex", files: [join(home, ".codex", "config.toml")], format: "toml" },
30815
- {
30816
- id: "antigravity",
30817
- label: "Antigravity",
30818
- files: [join(home, ".gemini", "antigravity", "mcp_config.json"), join(home, ".gemini", "config", "mcp_config.json")],
30819
- format: "json"
30820
- },
30821
- { id: "gemini-cli", label: "Gemini CLI", files: [join(home, ".gemini", "settings.json")], format: "json" },
30822
- { id: "cursor", label: "Cursor", files: [join(home, ".cursor", "mcp.json")], format: "json" },
30823
- { id: "windsurf", label: "Windsurf", files: [join(home, ".codeium", "windsurf", "mcp_config.json")], format: "json" }
30824
- ];
30825
- }
30826
- function backup(path, text) {
30827
- writeFileSync(path + ".bak-figma-bridge", text);
30828
- }
30829
- function writeJson(path, entry) {
30830
- const text = existsSync(path) ? readFileSync(path, "utf8") : "";
30831
- const config = text.trim() ? JSON.parse(text) : {};
30832
- if (text.trim())
30833
- backup(path, text);
30834
- config.mcpServers = { ...config.mcpServers, [SERVER_NAME]: entry };
30835
- mkdirSync(dirname(path), { recursive: true });
30836
- writeFileSync(path, JSON.stringify(config, null, 2) + `
30837
- `);
30838
- }
30839
- function tomlBlock(command, args) {
30840
- return [
30841
- `[mcp_servers.${SERVER_NAME}]`,
30842
- `command = '${command}'`,
30843
- `args = [${args.map((a) => `'${a}'`).join(", ")}]`,
30844
- "startup_timeout_sec = 60",
30845
- "tool_timeout_sec = 120"
30846
- ].join(`
30847
- `);
30848
- }
30849
- function writeToml(path, command, args) {
30850
- const text = existsSync(path) ? readFileSync(path, "utf8") : "";
30851
- if (text.trim())
30852
- backup(path, text);
30853
- const kept = [];
30854
- let skipping = false;
30855
- for (const line of text.split(/\r?\n/)) {
30856
- const header = /^\s*\[([^\]]+)\]\s*$/.exec(line);
30857
- if (header)
30858
- skipping = header[1] === `mcp_servers.${SERVER_NAME}` || header[1].startsWith(`mcp_servers.${SERVER_NAME}.`);
30859
- if (!skipping)
30860
- kept.push(line);
30861
- }
30862
- const body = kept.join(`
30863
- `).trimEnd();
30864
- mkdirSync(dirname(path), { recursive: true });
30865
- writeFileSync(path, (body ? body + `
30866
-
30867
- ` : "") + tomlBlock(command, args) + `
30868
- `);
30869
- }
30870
- function runSetup(opts) {
30871
- const all = clientList(opts.home, opts.appData);
30872
- const forced = opts.clients ?? [];
30873
- const unknown = forced.filter((id) => !all.some((c) => c.id === id));
30874
- if (unknown.length)
30875
- throw new Error(`Unknown client(s): ${unknown.join(", ")}. Known: ${all.map((c) => c.id).join(", ")}`);
30876
- const result = { updated: [], failed: [], missing: [] };
30877
- for (const client of all) {
30878
- const detected = client.files.filter((f) => existsSync(dirname(f)) && (client.id !== "gemini-cli" || existsSync(f)));
30879
- const targets = forced.length ? forced.includes(client.id) ? detected.length ? detected : [client.files[0]] : [] : detected;
30880
- if (!targets.length) {
30881
- if (!forced.length)
30882
- result.missing.push(client.label);
30883
- continue;
30884
- }
30885
- for (const file of targets) {
30886
- try {
30887
- if (client.format === "toml")
30888
- writeToml(file, opts.command, opts.args);
30889
- else
30890
- writeJson(file, { ...client.extra, command: opts.command, args: opts.args });
30891
- result.updated.push({ client: client.id, label: client.label, file });
30892
- } catch (e) {
30893
- result.failed.push({ client: client.id, label: client.label, file, error: e.message });
30894
- }
30895
- }
30896
- }
30897
- return result;
30898
- }
30899
- function installPlugin(from, to) {
30900
- mkdirSync(to, { recursive: true });
30901
- for (const f of ["manifest.json", "code.js", "ui.html"])
30902
- copyFileSync(join(from, f), join(to, f));
30903
- return join(to, "manifest.json");
30904
- }
30905
- var color = (code) => (s) => process.stdout.isTTY ? `\x1B[${code}m${s}\x1B[0m` : s;
30906
- var bold = color(1);
30907
- var green = color(32);
30908
- var dim = color(2);
30909
- var yellow = color(33);
30910
- function printSetup(r, rerun) {
30911
- for (const label of r.missing)
30912
- console.log(dim(` – ${label.padEnd(15)} not found`));
30913
- for (const u of r.updated)
30914
- console.log(green(` ✔ ${u.label.padEnd(15)}`) + dim(u.file));
30915
- for (const f of r.failed)
30916
- console.log(yellow(` ! ${f.label.padEnd(15)}could not update ${f.file}: ${f.error}`));
30917
- if (!r.updated.length)
30918
- console.log(yellow(`
30919
- No AI client found. Use \`${rerun} --client <name>\` or \`${rerun} --print\` for manual config.`));
30920
- }
30921
- function printSnippets(command, args) {
30922
- console.log(bold("JSON clients") + dim(" (Claude, Antigravity, Gemini CLI, Cursor, Windsurf…)"));
30923
- console.log(JSON.stringify({ mcpServers: { [SERVER_NAME]: { command, args } } }, null, 2));
30924
- console.log(bold(`
30925
- Codex`) + dim(" (~/.codex/config.toml)"));
30926
- console.log(tomlBlock(command, args) + `
30927
- `);
30928
- }
30929
- function parseClients(argv) {
30930
- const out = [];
30931
- argv.forEach((a, i) => {
30932
- if (a === "--client" && argv[i + 1])
30933
- out.push(...argv[i + 1].split(","));
30934
- else if (a.startsWith("--client="))
30935
- out.push(...a.slice(9).split(","));
30936
- });
30937
- return out;
30938
- }
30939
-
30940
- // src/cli.ts
32841
+ init_setup();
30941
32842
  init_package();
32843
+ import { homedir as homedir4 } from "node:os";
32844
+ import { dirname as dirname5, join as join11 } from "node:path";
32845
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
30942
32846
  var PACKAGE = "@clovis500c/figma-bridge";
30943
32847
  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");
30947
- var npx = process.platform === "win32" ? { command: "cmd", args: ["/c", "npx", "-y", PACKAGE] } : { command: "npx", args: ["-y", PACKAGE] };
32848
+ var home = homedir4();
32849
+ var pluginSource = join11(dirname5(fileURLToPath2(import.meta.url)), "..", "plugin");
32850
+ var pluginTarget = join11(home, ".figma-bridge", "plugin");
32851
+ var spec = `${PACKAGE}@latest`;
32852
+ var npx = process.platform === "win32" ? { command: "cmd", args: ["/c", "npx", "-y", spec] } : { command: "npx", args: ["-y", spec] };
30948
32853
  function plugin() {
30949
- const manifest = installPlugin(pluginSource, pluginTarget);
32854
+ const manifest = installPlugin(pluginSource, pluginTarget, version);
30950
32855
  console.log(`${bold("Figma plugin")} copied to ${dim(pluginTarget)}`);
30951
32856
  return manifest;
30952
32857
  }
@@ -30955,7 +32860,7 @@ function next(manifest) {
30955
32860
  ${bold("Next")}
30956
32861
  1. Figma desktop → Plugins → Development → Import plugin from manifest…
30957
32862
  ${green(manifest)}
30958
- (once; after an update, run ${bold(`npx ${PACKAGE} plugin`)} again and reopen the plugin)
32863
+ (once: updates install themselves when your AI client restarts; then reopen the plugin)
30959
32864
  2. Restart your AI client, then run Plugins → Development → Figma Bridge in a Figma file.
30960
32865
  `);
30961
32866
  }
@@ -30973,7 +32878,7 @@ Figma Bridge setup
30973
32878
  break;
30974
32879
  }
30975
32880
  try {
30976
- const appData = process.env.APPDATA || join9(home, "AppData", "Roaming");
32881
+ const appData = process.env.APPDATA || join11(home, "AppData", "Roaming");
30977
32882
  printSetup(runSetup({ home, appData, command: npx.command, args: npx.args, clients: parseClients(rest) }), `npx ${PACKAGE} setup`);
30978
32883
  } catch (e) {
30979
32884
  console.log(yellow(e.message));