figrelay 0.0.0-stage → 0.8.1

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.
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "Figma Relay",
3
+ "id": "figrelay-dev",
4
+ "api": "1.0.0",
5
+ "main": "code.js",
6
+ "ui": "ui.html",
7
+ "editorType": ["figma"],
8
+ "documentAccess": "dynamic-page",
9
+ "networkAccess": {
10
+ "allowedDomains": ["none"],
11
+ "devAllowedDomains": ["ws://localhost:2000"],
12
+ "reasoning": "Connects to a local MCP bridge server so AI assistants can read and edit this file."
13
+ }
14
+ }
@@ -0,0 +1,559 @@
1
+ <style>
2
+ * {
3
+ box-sizing: border-box;
4
+ }
5
+
6
+ html,
7
+ body {
8
+ height: 100%;
9
+ }
10
+
11
+ body {
12
+ font:
13
+ 12px/1.5 -apple-system,
14
+ BlinkMacSystemFont,
15
+ "Segoe UI",
16
+ sans-serif;
17
+ margin: 0;
18
+ padding: 10px;
19
+ color: #2c2c34;
20
+ background: linear-gradient(160deg, #f6f7fb 0%, #eef0f7 100%);
21
+ display: flex;
22
+ flex-direction: column;
23
+ }
24
+
25
+ .card {
26
+ background: #fff;
27
+ border: 1px solid #e6e8f0;
28
+ border-radius: 9px;
29
+ padding: 7px 9px;
30
+ box-shadow: 0 1px 2px rgba(30, 34, 60, 0.04);
31
+ }
32
+
33
+ #status-row {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 8px;
37
+ cursor: pointer;
38
+ font-weight: 600;
39
+ font-size: 13px;
40
+ color: #1f2330;
41
+ }
42
+
43
+ #dot {
44
+ flex: none;
45
+ width: 9px;
46
+ height: 9px;
47
+ border-radius: 50%;
48
+ background: #e0555b;
49
+ box-shadow: 0 0 0 3px rgba(224, 85, 91, 0.15);
50
+ transition:
51
+ background 0.2s,
52
+ box-shadow 0.2s;
53
+ }
54
+
55
+ #dot.on {
56
+ background: #22a06b;
57
+ box-shadow: 0 0 0 3px rgba(34, 160, 107, 0.15);
58
+ }
59
+
60
+ #hint {
61
+ color: #6b6f7d;
62
+ margin-top: 5px;
63
+ font-size: 10px;
64
+ line-height: 1.35;
65
+ }
66
+
67
+ #result {
68
+ display: none;
69
+ margin-top: 10px;
70
+ border-top: 1px solid #eceef5;
71
+ padding-top: 9px;
72
+ }
73
+
74
+ #result.show {
75
+ display: block;
76
+ }
77
+
78
+ .res-head {
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: space-between;
82
+ margin-bottom: 6px;
83
+ }
84
+
85
+ .res-title {
86
+ font-weight: 600;
87
+ font-size: 11px;
88
+ color: #1f2330;
89
+ }
90
+
91
+ .res-list {
92
+ display: flex;
93
+ flex-direction: column;
94
+ gap: 4px;
95
+ max-height: 190px;
96
+ overflow: auto;
97
+ }
98
+
99
+ .res-row {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: 6px;
103
+ padding: 5px 7px;
104
+ border: 1px solid #e6e8f0;
105
+ border-radius: 7px;
106
+ background: #fbfcfe;
107
+ }
108
+
109
+ .res-name {
110
+ flex: 1;
111
+ min-width: 0;
112
+ overflow: hidden;
113
+ text-overflow: ellipsis;
114
+ white-space: nowrap;
115
+ font-size: 11px;
116
+ color: #2c2c34;
117
+ }
118
+
119
+ .res-type {
120
+ flex: none;
121
+ font-size: 9px;
122
+ color: #a0a4b0;
123
+ text-transform: uppercase;
124
+ letter-spacing: 0.03em;
125
+ }
126
+
127
+ .copy-btn {
128
+ flex: none;
129
+ border: 1px solid #d6d9e6;
130
+ background: #fff;
131
+ color: #4b5168;
132
+ border-radius: 6px;
133
+ padding: 2px 8px;
134
+ font-size: 10px;
135
+ cursor: pointer;
136
+ }
137
+
138
+ .copy-btn:hover {
139
+ background: #f0f2f8;
140
+ }
141
+
142
+ .copy-btn.done {
143
+ color: #22a06b;
144
+ border-color: #bfe6d3;
145
+ background: #f0faf5;
146
+ }
147
+
148
+ .copy-all {
149
+ padding: 1px 6px;
150
+ font-size: 10px;
151
+ }
152
+
153
+ .filter-row {
154
+ display: flex;
155
+ gap: 4px;
156
+ margin-bottom: 8px;
157
+ }
158
+
159
+ .filter-btn {
160
+ flex: none;
161
+ border: 1px solid #d6d9e6;
162
+ background: #fff;
163
+ color: #6b6f7d;
164
+ border-radius: 6px;
165
+ padding: 3px 9px;
166
+ font-size: 10px;
167
+ cursor: pointer;
168
+ }
169
+
170
+ .filter-btn:hover {
171
+ background: #f0f2f8;
172
+ }
173
+
174
+ .filter-btn.on {
175
+ background: #2c2c34;
176
+ border-color: #2c2c34;
177
+ color: #fff;
178
+ }
179
+
180
+ .res-empty {
181
+ font-size: 11px;
182
+ color: #a0a4b0;
183
+ padding: 6px 2px;
184
+ }
185
+
186
+ .key-prompt {
187
+ margin-bottom: 8px;
188
+ padding: 7px 8px;
189
+ border: 1px solid #ffe0a6;
190
+ background: #fff9ec;
191
+ border-radius: 7px;
192
+ }
193
+
194
+ .key-note {
195
+ font-size: 10px;
196
+ color: #8a6d3b;
197
+ line-height: 1.4;
198
+ margin-bottom: 5px;
199
+ }
200
+
201
+ .key-input-row {
202
+ display: flex;
203
+ gap: 4px;
204
+ }
205
+
206
+ .key-input {
207
+ flex: 1;
208
+ min-width: 0;
209
+ border: 1px solid #d6d9e6;
210
+ border-radius: 6px;
211
+ padding: 3px 6px;
212
+ font-size: 10px;
213
+ }
214
+
215
+ .key-save {
216
+ flex: none;
217
+ border: 1px solid #d6d9e6;
218
+ background: #fff;
219
+ border-radius: 6px;
220
+ padding: 3px 10px;
221
+ font-size: 10px;
222
+ cursor: pointer;
223
+ }
224
+
225
+ .key-save:hover {
226
+ background: #f0f2f8;
227
+ }
228
+
229
+ #credit {
230
+ margin-top: auto;
231
+ padding-top: 7px;
232
+ text-align: center;
233
+ color: #a0a4b0;
234
+ font-size: 10px;
235
+ }
236
+
237
+ #version {
238
+ color: #8288a0;
239
+ margin-right: 4px;
240
+ }
241
+
242
+ #credit a {
243
+ color: #8288a0;
244
+ text-decoration: none;
245
+ }
246
+
247
+ #credit a:hover {
248
+ text-decoration: underline;
249
+ }
250
+ </style>
251
+
252
+ <div class="card">
253
+ <div id="status-row">
254
+ <span id="dot"></span><span id="status">Connecting…</span>
255
+ </div>
256
+ <div id="hint">Keep open while AI works · run it in every file.</div>
257
+ <div id="result"></div>
258
+ </div>
259
+ <div id="credit">
260
+ <span id="version"></span>
261
+ <a href="https://github.com/quannh287/figma-relay" target="_blank">@quannh287</a>
262
+ </div>
263
+
264
+ <script>
265
+ // Bridge UI: connect to the shared MCP bridge on localhost and relay
266
+ // frames between it and the plugin main thread.
267
+ //
268
+ // One bridge serves every plugin window and every MCP session, so there is
269
+ // a single well-known port (also the only one the manifest's
270
+ // devAllowedDomains permits). While disconnected we retry on a short loop;
271
+ // background tabs throttle timers, so a visibility change and a click on
272
+ // the status line also trigger an immediate attempt.
273
+ const PORT = 2000,
274
+ RETRY = 1000,
275
+ PROTOCOL = 4; // must match bridge.ProtocolVersion in the Go server
276
+ let ws = null; // open connection, null while disconnected
277
+ let attempt = null; // socket currently trying to connect
278
+ let reg = null; // {key, name} of this file, sent by the main thread
279
+ let protoWarning = null; // set when the server reports a newer protocol
280
+
281
+ function setStatus(connected, text) {
282
+ document.getElementById("dot").className = connected ? "on" : "";
283
+ document.getElementById("status").textContent = text;
284
+ }
285
+
286
+ function label() {
287
+ if (protoWarning) return protoWarning;
288
+ return reg ? "Connected · " + reg.name : "Connected (port " + PORT + ")";
289
+ }
290
+
291
+ function sendRegister() {
292
+ if (ws && ws.readyState === WebSocket.OPEN && reg) {
293
+ ws.send(JSON.stringify({ register: reg }));
294
+ }
295
+ }
296
+
297
+ function connect() {
298
+ if (ws || attempt) return;
299
+ let sock;
300
+ try {
301
+ sock = new WebSocket("ws://localhost:" + PORT + "/ws");
302
+ } catch (e) {
303
+ schedule();
304
+ return;
305
+ }
306
+ attempt = sock;
307
+ sock.onopen = () => {
308
+ attempt = null;
309
+ ws = sock;
310
+ sendRegister();
311
+ setStatus(true, label());
312
+ };
313
+ sock.onmessage = (e) => {
314
+ const msg = JSON.parse(e.data);
315
+ if (msg.hello) {
316
+ // Show which server build we are talking to; "dev" means a local
317
+ // go build rather than a release.
318
+ if (msg.hello.version) {
319
+ document.getElementById("version").textContent = "v" + msg.hello.version;
320
+ }
321
+ // Name the side that is behind: a stale server is just as common as
322
+ // a stale plugin, and blaming the plugin sends people re-importing
323
+ // a plugin that was already up to date.
324
+ protoWarning = null;
325
+ if (msg.hello.protocol && msg.hello.protocol !== PROTOCOL) {
326
+ // npx figrelay@latest only fetches the new server when the AI client restarts it.
327
+ protoWarning =
328
+ msg.hello.protocol < PROTOCOL
329
+ ? "⚠ Server outdated — restart your AI client"
330
+ : "⚠ Plugin outdated — re-import from the latest release";
331
+ }
332
+ setStatus(true, label());
333
+ return;
334
+ }
335
+ // Relay server request to the plugin main thread.
336
+ parent.postMessage({ pluginMessage: msg }, "*");
337
+ };
338
+ sock.onclose = () => {
339
+ if (attempt === sock) attempt = null;
340
+ if (ws === sock) ws = null;
341
+ if (!ws) {
342
+ setStatus(false, "Searching for MCP server…");
343
+ schedule();
344
+ }
345
+ };
346
+ sock.onerror = () => {
347
+ try {
348
+ sock.close();
349
+ } catch (e) { }
350
+ };
351
+ }
352
+
353
+ let retryTimer = null;
354
+ function schedule() {
355
+ if (retryTimer) return;
356
+ retryTimer = setTimeout(() => {
357
+ retryTimer = null;
358
+ connect();
359
+ }, RETRY);
360
+ }
361
+
362
+ // ---- live selection list (name + copyable link, filterable by type) ----
363
+ let selItems = []; // everything currently selected in Figma
364
+ let shownItems = []; // selItems after the active type filter
365
+ let selFilter = "all"; // all | frame | vector
366
+ let hasKey = false; // whether the main thread knows this file's key
367
+
368
+ // Pull the file key out of a pasted Figma URL (…/design/<key>/…), or accept
369
+ // a bare key. Returns "" when nothing usable is found.
370
+ function parseFileKey(s) {
371
+ s = (s || "").trim();
372
+ const m = s.match(/figma\.com\/(?:file|design|board|proto)\/([A-Za-z0-9]+)/i);
373
+ if (m) return m[1];
374
+ if (/^[A-Za-z0-9]{10,}$/.test(s)) return s;
375
+ return "";
376
+ }
377
+
378
+ const FILTERS = [
379
+ { key: "all", label: "All" },
380
+ { key: "frame", label: "Frame" },
381
+ { key: "vector", label: "Vector" },
382
+ { key: "instance", label: "Instance" },
383
+ ];
384
+
385
+ function esc(s) {
386
+ return String(s)
387
+ .replace(/&/g, "&amp;")
388
+ .replace(/</g, "&lt;")
389
+ .replace(/>/g, "&gt;")
390
+ .replace(/"/g, "&quot;");
391
+ }
392
+
393
+ function matches(it) {
394
+ if (selFilter === "frame") return it.type === "FRAME";
395
+ if (selFilter === "vector") return it.type === "VECTOR";
396
+ if (selFilter === "instance") return it.type === "INSTANCE";
397
+ return true;
398
+ }
399
+
400
+ // Frames copy as a link block; every other element copies its exported SVG.
401
+ function copyLabelFor(it) {
402
+ return it.type === "FRAME" ? "Copy" : "Copy SVG";
403
+ }
404
+
405
+ // Copyable text for one item. Frames use the requested link format:
406
+ // # <name>:
407
+ // <link>
408
+ // Other elements return their SVG (or the export error, so the click still
409
+ // gives feedback instead of silently copying nothing).
410
+ function copyValueForItem(it) {
411
+ if (it.type === "FRAME") return "# " + it.name + ":\n" + (it.link || "");
412
+ return it.svg || it.svgError || "";
413
+ }
414
+
415
+ function renderSelection(items) {
416
+ if (items) selItems = items;
417
+ drawSelection();
418
+ }
419
+
420
+ function drawSelection() {
421
+ const box = document.getElementById("result");
422
+ if (!selItems.length) {
423
+ box.className = "";
424
+ box.innerHTML = "";
425
+ return;
426
+ }
427
+ shownItems = selItems.filter(matches);
428
+
429
+ // Only frames need a link (and therefore the file key); a pure vector /
430
+ // instance selection copies SVG, so don't nag for a URL then.
431
+ const needsKey = !hasKey && selItems.some((it) => it.type === "FRAME");
432
+ const keyPrompt = !needsKey
433
+ ? ""
434
+ : '<div class="key-prompt">' +
435
+ '<div class="key-note">Links are empty: this file has no key yet. Paste the file URL once ' +
436
+ "(Share → Copy link, or the browser address bar):</div>" +
437
+ '<div class="key-input-row">' +
438
+ '<input class="key-input" id="key-input" placeholder="https://www.figma.com/design/…" />' +
439
+ '<button class="key-save" id="key-save">Save</button>' +
440
+ "</div></div>";
441
+
442
+ const filterBar =
443
+ '<div class="filter-row">' +
444
+ FILTERS.map(
445
+ (f) =>
446
+ '<button class="filter-btn' + (selFilter === f.key ? " on" : "") + '" data-f="' + f.key + '">' +
447
+ f.label + "</button>",
448
+ ).join("") +
449
+ "</div>";
450
+
451
+ const head =
452
+ '<div class="res-head"><span class="res-title">Selected (' + shownItems.length + ")</span>" +
453
+ (shownItems.length > 1 ? '<button class="copy-btn copy-all" data-all="1">Copy all</button>' : "") +
454
+ "</div>";
455
+
456
+ const body = shownItems.length
457
+ ? '<div class="res-list">' +
458
+ shownItems
459
+ .map(
460
+ (it, i) =>
461
+ '<div class="res-row">' +
462
+ '<span class="res-name" title="' + esc(it.name) + '">' + esc(it.name) + "</span>" +
463
+ '<span class="res-type">' + esc(it.type) + "</span>" +
464
+ '<button class="copy-btn" data-i="' + i + '">' + copyLabelFor(it) + "</button>" +
465
+ "</div>",
466
+ )
467
+ .join("") +
468
+ "</div>"
469
+ : '<div class="res-empty">No matching nodes selected.</div>';
470
+
471
+ box.innerHTML = keyPrompt + filterBar + head + body;
472
+ box.className = "show";
473
+ }
474
+
475
+ // What each button copies: a row copies its own value (link block for a
476
+ // frame, SVG otherwise); "Copy all" joins every shown item with a blank
477
+ // line between — filter to one type first for a homogeneous result.
478
+ function copyValueFor(btn) {
479
+ if (btn.hasAttribute("data-all")) {
480
+ return shownItems.map(copyValueForItem).join("\n\n");
481
+ }
482
+ const it = shownItems[+btn.getAttribute("data-i")];
483
+ return it ? copyValueForItem(it) : "";
484
+ }
485
+
486
+ // navigator.clipboard is often unavailable in the plugin iframe, so use the
487
+ // textarea + execCommand fallback which works reliably here.
488
+ function copyText(text) {
489
+ const ta = document.createElement("textarea");
490
+ ta.value = text;
491
+ ta.style.position = "fixed";
492
+ ta.style.opacity = "0";
493
+ document.body.appendChild(ta);
494
+ ta.select();
495
+ try {
496
+ document.execCommand("copy");
497
+ } catch (e) { }
498
+ document.body.removeChild(ta);
499
+ }
500
+
501
+ document.getElementById("result").addEventListener("click", (e) => {
502
+ if (e.target.id === "key-save") {
503
+ const inp = document.getElementById("key-input");
504
+ const key = parseFileKey(inp ? inp.value : "");
505
+ if (key) {
506
+ parent.postMessage({ pluginMessage: { setFileKey: key } }, "*");
507
+ } else if (inp) {
508
+ inp.value = "";
509
+ inp.placeholder = "Couldn't read a file key from that — paste the full URL";
510
+ }
511
+ return;
512
+ }
513
+ const fb = e.target.closest(".filter-btn");
514
+ if (fb) {
515
+ selFilter = fb.getAttribute("data-f");
516
+ drawSelection();
517
+ return;
518
+ }
519
+ const btn = e.target.closest(".copy-btn");
520
+ if (!btn) return;
521
+ copyText(copyValueFor(btn));
522
+ const old = btn.textContent;
523
+ btn.textContent = "Copied";
524
+ btn.classList.add("done");
525
+ setTimeout(() => {
526
+ btn.textContent = old;
527
+ btn.classList.remove("done");
528
+ }, 1200);
529
+ });
530
+
531
+ // Relay plugin main-thread frames: the register frame is cached here and
532
+ // re-sent on every reconnect, the selection frame drives the list above, and
533
+ // everything else goes to the server.
534
+ window.onmessage = (e) => {
535
+ const msg = e.data.pluginMessage;
536
+ if (!msg) return;
537
+ if (msg.register) {
538
+ reg = msg.register;
539
+ sendRegister();
540
+ if (ws) setStatus(true, label());
541
+ return;
542
+ }
543
+ if (Array.isArray(msg.selection)) {
544
+ hasKey = !!msg.hasKey;
545
+ renderSelection(msg.selection);
546
+ return;
547
+ }
548
+ if (ws && ws.readyState === WebSocket.OPEN) {
549
+ ws.send(JSON.stringify(msg));
550
+ }
551
+ };
552
+
553
+ document.getElementById("status-row").onclick = () => connect();
554
+ document.addEventListener("visibilitychange", () => {
555
+ if (!document.hidden && !ws) connect();
556
+ });
557
+
558
+ connect();
559
+ </script>
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "figrelay",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.8.1",
4
+ "scripts": {
5
+ "prepublishOnly": "cp ../README.md ./README.md"
6
+ },
7
+ "description": "Figma MCP server (fork of figma-mcp-console) for AI coding assistants — real-time read/write via a Figma Desktop plugin. No API token, no rate limits.",
8
+ "bin": {
9
+ "figrelay": "run.js"
10
+ },
11
+ "files": [
12
+ "run.js",
13
+ "bin/"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/quannh287/figma-relay.git"
21
+ },
22
+ "homepage": "https://github.com/quannh287/figma-relay#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/quannh287/figma-relay/issues"
25
+ },
26
+ "keywords": [
27
+ "figma",
28
+ "mcp",
29
+ "model-context-protocol",
30
+ "claude",
31
+ "cursor",
32
+ "figma-mcp-console",
33
+ "design"
34
+ ],
35
+ "license": "MIT"
36
+ }
package/run.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // Run the Go binary bundled in the npm package — NO network/GitHub download.
3
+ // stdout is the MCP transport → all wrapper logs go to stderr.
4
+ "use strict";
5
+ const { spawnSync } = require("child_process");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const plat = process.platform === "win32" ? "windows" : process.platform; // darwin|linux|windows
11
+ const arch = { x64: "amd64", arm64: "arm64" }[process.arch];
12
+ function fail(m) {
13
+ process.stderr.write(`figrelay: ${m}\n`);
14
+ process.exit(1);
15
+ }
16
+ if (!arch || !["darwin", "linux", "windows"].includes(plat))
17
+ fail(`unsupported platform: ${process.platform}/${process.arch}`);
18
+
19
+ const exe = plat === "windows" ? ".exe" : "";
20
+ const binPath = path.join(
21
+ __dirname,
22
+ "bin",
23
+ `${plat}-${arch}`,
24
+ `figma-mcp${exe}`,
25
+ );
26
+
27
+ // Subcommand: install-plugin — extract the plugin into a VISIBLE folder (no leading dot)
28
+ // and open that folder so the user sees manifest.json right away.
29
+ if (process.argv[2] === "install-plugin") {
30
+ const srcDir = path.join(__dirname, "bin", "plugin");
31
+ const destDir = path.join(os.homedir(), "figrelay-plugin");
32
+ fs.mkdirSync(destDir, { recursive: true });
33
+ for (const f of ["code.js", "ui.html", "manifest.json"])
34
+ fs.copyFileSync(path.join(srcDir, f), path.join(destDir, f));
35
+
36
+ process.stdout.write(
37
+ "\n✅ Figma plugin ready.\n\n" +
38
+ "In Figma Desktop → Plugins → Development → Import plugin from manifest…\n" +
39
+ "and select this file:\n\n" +
40
+ " " +
41
+ path.join(destDir, "manifest.json") +
42
+ "\n\n",
43
+ );
44
+
45
+ // Open the folder in the file manager (best-effort; ignore errors).
46
+ const opener =
47
+ plat === "darwin" ? "open" : plat === "windows" ? "explorer" : "xdg-open";
48
+ try {
49
+ spawnSync(opener, [destDir], { stdio: "ignore", detached: true });
50
+ } catch (_) {}
51
+ process.exit(0);
52
+ }
53
+
54
+ if (!fs.existsSync(binPath)) fail(`binary not found for ${plat}-${arch}`);
55
+ try {
56
+ fs.chmodSync(binPath, 0o755);
57
+ } catch (_) {}
58
+ const r = spawnSync(binPath, process.argv.slice(2), { stdio: "inherit" });
59
+ if (r.error) fail(`failed to start server: ${r.error.message}`);
60
+ process.exit(r.status === null ? 1 : r.status);