@ttsalpha/mirra 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +132 -2
- package/dist/index.js +1186 -0
- package/dist/plugin/main.js +1291 -0
- package/dist/plugin/manifest.json +58 -0
- package/dist/plugin/ui.html +282 -0
- package/package.json +55 -4
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Mirra",
|
|
3
|
+
"id": "mirra-figma-bridge",
|
|
4
|
+
"api": "1.0.0",
|
|
5
|
+
"main": "main.js",
|
|
6
|
+
"ui": "ui.html",
|
|
7
|
+
"editorType": [
|
|
8
|
+
"figma"
|
|
9
|
+
],
|
|
10
|
+
"documentAccess": "dynamic-page",
|
|
11
|
+
"networkAccess": {
|
|
12
|
+
"allowedDomains": [
|
|
13
|
+
"ws://localhost:51730",
|
|
14
|
+
"ws://localhost:51731",
|
|
15
|
+
"ws://localhost:51732",
|
|
16
|
+
"ws://localhost:51733",
|
|
17
|
+
"ws://localhost:51734",
|
|
18
|
+
"ws://localhost:51735",
|
|
19
|
+
"ws://localhost:51736",
|
|
20
|
+
"ws://localhost:51737",
|
|
21
|
+
"ws://localhost:51738",
|
|
22
|
+
"ws://localhost:51739",
|
|
23
|
+
"http://localhost:51730",
|
|
24
|
+
"http://localhost:51731",
|
|
25
|
+
"http://localhost:51732",
|
|
26
|
+
"http://localhost:51733",
|
|
27
|
+
"http://localhost:51734",
|
|
28
|
+
"http://localhost:51735",
|
|
29
|
+
"http://localhost:51736",
|
|
30
|
+
"http://localhost:51737",
|
|
31
|
+
"http://localhost:51738",
|
|
32
|
+
"http://localhost:51739"
|
|
33
|
+
],
|
|
34
|
+
"devAllowedDomains": [
|
|
35
|
+
"ws://localhost:51730",
|
|
36
|
+
"ws://localhost:51731",
|
|
37
|
+
"ws://localhost:51732",
|
|
38
|
+
"ws://localhost:51733",
|
|
39
|
+
"ws://localhost:51734",
|
|
40
|
+
"ws://localhost:51735",
|
|
41
|
+
"ws://localhost:51736",
|
|
42
|
+
"ws://localhost:51737",
|
|
43
|
+
"ws://localhost:51738",
|
|
44
|
+
"ws://localhost:51739",
|
|
45
|
+
"http://localhost:51730",
|
|
46
|
+
"http://localhost:51731",
|
|
47
|
+
"http://localhost:51732",
|
|
48
|
+
"http://localhost:51733",
|
|
49
|
+
"http://localhost:51734",
|
|
50
|
+
"http://localhost:51735",
|
|
51
|
+
"http://localhost:51736",
|
|
52
|
+
"http://localhost:51737",
|
|
53
|
+
"http://localhost:51738",
|
|
54
|
+
"http://localhost:51739"
|
|
55
|
+
],
|
|
56
|
+
"reasoning": "Talks to the Mirra MCP server on the user's own machine over loopback: a WebSocket for the agent's commands, and a health check that tells a missing server apart from a blocked one. No remote server is contacted."
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<style>
|
|
6
|
+
:root {
|
|
7
|
+
color-scheme: light dark;
|
|
8
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
9
|
+
font-size: 11px;
|
|
10
|
+
--mark-ink: #101418;
|
|
11
|
+
--mark-mint: #0fa87c;
|
|
12
|
+
}
|
|
13
|
+
@media (prefers-color-scheme: dark) {
|
|
14
|
+
:root {
|
|
15
|
+
--mark-ink: #fff;
|
|
16
|
+
--mark-mint: #49e3b0;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
html,
|
|
20
|
+
body {
|
|
21
|
+
height: 100%;
|
|
22
|
+
}
|
|
23
|
+
body {
|
|
24
|
+
margin: 0;
|
|
25
|
+
min-height: 0;
|
|
26
|
+
padding: 12px;
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
gap: 10px;
|
|
30
|
+
color: var(--figma-color-text, #1e1e1e);
|
|
31
|
+
background: var(--figma-color-bg, #fff);
|
|
32
|
+
}
|
|
33
|
+
header {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
gap: 8px;
|
|
37
|
+
}
|
|
38
|
+
.mark {
|
|
39
|
+
width: 16px;
|
|
40
|
+
height: 16px;
|
|
41
|
+
flex: none;
|
|
42
|
+
}
|
|
43
|
+
.dot {
|
|
44
|
+
width: 8px;
|
|
45
|
+
height: 8px;
|
|
46
|
+
border-radius: 50%;
|
|
47
|
+
background: #b3b3b3;
|
|
48
|
+
flex: none;
|
|
49
|
+
}
|
|
50
|
+
.dot.online {
|
|
51
|
+
background: #14ae5c;
|
|
52
|
+
}
|
|
53
|
+
.dot.connecting {
|
|
54
|
+
background: #e5a000;
|
|
55
|
+
}
|
|
56
|
+
.dot.offline {
|
|
57
|
+
background: #e03e1a;
|
|
58
|
+
}
|
|
59
|
+
#state {
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
}
|
|
62
|
+
#port {
|
|
63
|
+
margin-left: auto;
|
|
64
|
+
color: var(--figma-color-text-secondary, #777);
|
|
65
|
+
font-variant-numeric: tabular-nums;
|
|
66
|
+
}
|
|
67
|
+
button {
|
|
68
|
+
border: 1px solid var(--figma-color-border, #e6e6e6);
|
|
69
|
+
background: var(--figma-color-bg-secondary, #f5f5f5);
|
|
70
|
+
color: inherit;
|
|
71
|
+
border-radius: 6px;
|
|
72
|
+
padding: 6px 10px;
|
|
73
|
+
font: inherit;
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
}
|
|
76
|
+
button:hover {
|
|
77
|
+
background: var(--figma-color-bg-hover, #ebebeb);
|
|
78
|
+
}
|
|
79
|
+
#log {
|
|
80
|
+
flex: 1 1 auto;
|
|
81
|
+
min-height: 0;
|
|
82
|
+
margin: 0;
|
|
83
|
+
padding: 8px;
|
|
84
|
+
overflow: auto;
|
|
85
|
+
white-space: pre-wrap;
|
|
86
|
+
border-radius: 6px;
|
|
87
|
+
background: var(--figma-color-bg-secondary, #f5f5f5);
|
|
88
|
+
color: var(--figma-color-text-secondary, #777);
|
|
89
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
90
|
+
font-size: 10px;
|
|
91
|
+
line-height: 1.5;
|
|
92
|
+
}
|
|
93
|
+
#exec-row {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
gap: 6px;
|
|
97
|
+
color: var(--figma-color-text-secondary, #777);
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
}
|
|
100
|
+
#allow-exec {
|
|
101
|
+
margin: 0;
|
|
102
|
+
}
|
|
103
|
+
footer {
|
|
104
|
+
color: var(--figma-color-text-tertiary, #999);
|
|
105
|
+
}
|
|
106
|
+
</style>
|
|
107
|
+
</head>
|
|
108
|
+
<body>
|
|
109
|
+
<header>
|
|
110
|
+
<svg class="mark" viewBox="0 0 256 256" aria-hidden="true">
|
|
111
|
+
<g fill="none" stroke-width="32" stroke-linejoin="round" stroke-linecap="butt">
|
|
112
|
+
<path d="M48 116V44l80 56 80-56v72" stroke="var(--mark-ink)" />
|
|
113
|
+
<path d="M48 142v72l80-56 80 56v-72" stroke="var(--mark-mint)" />
|
|
114
|
+
</g>
|
|
115
|
+
</svg>
|
|
116
|
+
<span id="dot" class="dot offline"></span>
|
|
117
|
+
<span id="state">Disconnected</span>
|
|
118
|
+
<span id="port">—</span>
|
|
119
|
+
</header>
|
|
120
|
+
<button id="reconnect" type="button">Reconnect</button>
|
|
121
|
+
<label id="exec-row">
|
|
122
|
+
<input id="allow-exec" type="checkbox" />
|
|
123
|
+
Allow code execution
|
|
124
|
+
</label>
|
|
125
|
+
<pre id="log"></pre>
|
|
126
|
+
<footer>Keep this panel open while the agent works.</footer>
|
|
127
|
+
<script>
|
|
128
|
+
"use strict";
|
|
129
|
+
(() => {
|
|
130
|
+
// ../schema/dist/constants.js
|
|
131
|
+
var PROTOCOL_VERSION = 1;
|
|
132
|
+
var DEFAULT_PORT = 51730;
|
|
133
|
+
var PORT_RANGE_END = 51739;
|
|
134
|
+
|
|
135
|
+
// src/ui.ts
|
|
136
|
+
var PORTS = Array.from({ length: PORT_RANGE_END - DEFAULT_PORT + 1 }, (_, i) => DEFAULT_PORT + i);
|
|
137
|
+
var CONNECT_TIMEOUT_MS = 5e3;
|
|
138
|
+
var RESCAN_MS = 5e3;
|
|
139
|
+
var MAX_LOGS = 10;
|
|
140
|
+
var context = null;
|
|
141
|
+
var scanning = false;
|
|
142
|
+
var logs = [];
|
|
143
|
+
var sockets = /* @__PURE__ */ new Map();
|
|
144
|
+
var askedBy = /* @__PURE__ */ new Map();
|
|
145
|
+
var dot = element("dot");
|
|
146
|
+
var stateLabel = element("state");
|
|
147
|
+
var portLabel = element("port");
|
|
148
|
+
var logList = element("log");
|
|
149
|
+
var allowExec = element("allow-exec");
|
|
150
|
+
allowExec.addEventListener("change", () => {
|
|
151
|
+
toMain({ kind: "allow-exec", value: allowExec.checked });
|
|
152
|
+
log(`code execution ${allowExec.checked ? "allowed" : "blocked"}`);
|
|
153
|
+
});
|
|
154
|
+
element("reconnect").addEventListener("click", () => {
|
|
155
|
+
for (const socket of sockets.values()) socket.close();
|
|
156
|
+
sockets.clear();
|
|
157
|
+
void scan();
|
|
158
|
+
});
|
|
159
|
+
window.onmessage = (event) => {
|
|
160
|
+
const message = event.data?.pluginMessage;
|
|
161
|
+
if (!message) return;
|
|
162
|
+
if (message.kind === "context") {
|
|
163
|
+
const first = context === null;
|
|
164
|
+
context = message;
|
|
165
|
+
if (first) {
|
|
166
|
+
void scan();
|
|
167
|
+
setInterval(() => void scan(), RESCAN_MS);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const { kind: _kind, ...envelope } = message;
|
|
172
|
+
const socket = askedBy.get(envelope.id);
|
|
173
|
+
askedBy.delete(envelope.id);
|
|
174
|
+
socket?.send(JSON.stringify(envelope));
|
|
175
|
+
};
|
|
176
|
+
document.addEventListener("securitypolicyviolation", (event) => {
|
|
177
|
+
log(`blocked by CSP: ${event.blockedURI}`);
|
|
178
|
+
});
|
|
179
|
+
toMain({ kind: "ready" });
|
|
180
|
+
async function scan() {
|
|
181
|
+
if (scanning) return;
|
|
182
|
+
scanning = true;
|
|
183
|
+
if (!sockets.size) setStatus("connecting");
|
|
184
|
+
try {
|
|
185
|
+
await Promise.all(PORTS.filter((port) => !sockets.has(port)).map(open));
|
|
186
|
+
} finally {
|
|
187
|
+
scanning = false;
|
|
188
|
+
}
|
|
189
|
+
if (sockets.size) return setStatus("online");
|
|
190
|
+
await probe(PORTS[0]);
|
|
191
|
+
setStatus("offline");
|
|
192
|
+
}
|
|
193
|
+
function open(port) {
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
let settled = false;
|
|
196
|
+
let candidate;
|
|
197
|
+
try {
|
|
198
|
+
candidate = new WebSocket(`ws://localhost:${port}`);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
log(`cannot open ${port}: ${error instanceof Error ? error.message : String(error)}`);
|
|
201
|
+
return resolve();
|
|
202
|
+
}
|
|
203
|
+
const done = () => {
|
|
204
|
+
if (settled) return;
|
|
205
|
+
settled = true;
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
resolve();
|
|
208
|
+
};
|
|
209
|
+
const timer = setTimeout(() => {
|
|
210
|
+
candidate.close();
|
|
211
|
+
done();
|
|
212
|
+
}, CONNECT_TIMEOUT_MS);
|
|
213
|
+
candidate.onopen = () => {
|
|
214
|
+
attach(candidate, port);
|
|
215
|
+
done();
|
|
216
|
+
};
|
|
217
|
+
candidate.onerror = () => done();
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
function attach(socket, port) {
|
|
221
|
+
sockets.set(port, socket);
|
|
222
|
+
setStatus("online");
|
|
223
|
+
log(`connected on ${port}`);
|
|
224
|
+
socket.onmessage = (event) => {
|
|
225
|
+
let envelope;
|
|
226
|
+
try {
|
|
227
|
+
envelope = JSON.parse(String(event.data));
|
|
228
|
+
} catch {
|
|
229
|
+
return log("dropped malformed message");
|
|
230
|
+
}
|
|
231
|
+
askedBy.set(envelope.id, socket);
|
|
232
|
+
toMain({ kind: "request", id: envelope.id, op: envelope.op, params: envelope.params });
|
|
233
|
+
};
|
|
234
|
+
socket.onclose = () => {
|
|
235
|
+
if (sockets.get(port) !== socket) return;
|
|
236
|
+
sockets.delete(port);
|
|
237
|
+
log(`lost ${port}`);
|
|
238
|
+
setStatus(sockets.size ? "online" : "offline");
|
|
239
|
+
};
|
|
240
|
+
socket.onerror = () => log(`socket error on ${port}`);
|
|
241
|
+
socket.send(
|
|
242
|
+
JSON.stringify({
|
|
243
|
+
type: "hello",
|
|
244
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
245
|
+
pluginVersion: context?.pluginVersion ?? "unknown",
|
|
246
|
+
fileKey: context?.fileKey ?? null,
|
|
247
|
+
fileName: context?.fileName ?? "unknown"
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
async function probe(port) {
|
|
252
|
+
try {
|
|
253
|
+
const response = await fetch(`http://localhost:${port}/health`);
|
|
254
|
+
log(`probe ${port}: http ${response.status}`);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
log(`probe ${port}: ${error instanceof Error ? error.message : String(error)}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function setStatus(next) {
|
|
260
|
+
dot.className = `dot ${next}`;
|
|
261
|
+
const ports = [...sockets.keys()].sort((a, b) => a - b);
|
|
262
|
+
stateLabel.textContent = next === "online" ? `Connected${ports.length > 1 ? ` (${ports.length})` : ""}` : next === "connecting" ? "Connecting\u2026" : "Disconnected";
|
|
263
|
+
portLabel.textContent = ports.length ? ports.map((port) => `:${port}`).join(" ") : "\u2014";
|
|
264
|
+
}
|
|
265
|
+
function log(message) {
|
|
266
|
+
logs.unshift(`${(/* @__PURE__ */ new Date()).toLocaleTimeString()} ${message}`);
|
|
267
|
+
logs.length = Math.min(logs.length, MAX_LOGS);
|
|
268
|
+
logList.textContent = logs.join("\n");
|
|
269
|
+
}
|
|
270
|
+
function toMain(message) {
|
|
271
|
+
parent.postMessage({ pluginMessage: message }, "*");
|
|
272
|
+
}
|
|
273
|
+
function element(id) {
|
|
274
|
+
const node = document.getElementById(id);
|
|
275
|
+
if (!node) throw new Error(`Missing #${id} in the panel markup`);
|
|
276
|
+
return node;
|
|
277
|
+
}
|
|
278
|
+
})();
|
|
279
|
+
|
|
280
|
+
</script>
|
|
281
|
+
</body>
|
|
282
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ttsalpha/mirra",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Two-way MCP server for Figma: read a design and render one back through the Figma Plugin API",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"figma",
|
|
7
|
+
"mcp",
|
|
8
|
+
"figma-mcp",
|
|
9
|
+
"figma-plugin",
|
|
10
|
+
"design-to-code",
|
|
11
|
+
"claude"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Son Tran @ttsalpha",
|
|
15
|
+
"homepage": "https://github.com/ttsalpha/mirra#readme",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/ttsalpha/mirra.git",
|
|
19
|
+
"directory": "packages/server"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/ttsalpha/mirra/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"bin": {
|
|
26
|
+
"mirra": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=24"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "node build.mjs && node scripts/stage-package.mjs",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"prepublishOnly": "pnpm -w run build",
|
|
40
|
+
"build:mcpb": "node scripts/build-mcpb.mjs"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
44
|
+
"ws": "8.22.0",
|
|
45
|
+
"zod": "4.6.7"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@mirra/plugin": "workspace:*",
|
|
49
|
+
"@mirra/schema": "workspace:*",
|
|
50
|
+
"@types/node": "24.19.2",
|
|
51
|
+
"@types/ws": "8.18.2",
|
|
52
|
+
"esbuild": "0.25.12"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|