figma-rust-mcp 7.0.0 → 8.0.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.
- package/README.md +24 -15
- package/bin/agent-config.js +18 -9
- package/bin/figma-rust-mcp.js +141 -50
- package/package.json +3 -2
- package/scripts/codegen-accuracy.mjs +182 -0
- package/scripts/test-plugin-tokens.mjs +33 -10
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
|
|
|
8
8
|
|
|
9
9
|
## ⚡ Highlights
|
|
10
10
|
|
|
11
|
-
- **Lossless Rust Tree Compression**:
|
|
11
|
+
- **Lossless Rust Tree Compression**: Node reads are compact by default: identical style bundles are shared and values every layer carries are stored once, only when the JSON gets smaller. Node IDs, hierarchy, text, geometry and state differences stay inline; `detail: "full"` returns the raw payload.
|
|
12
12
|
- **Pure Rust Native Performance**: Single native binary, no runtime or GC. Measure it on your machine with `npm run bench` or the plugin's **Bench** tab (see [Benchmarks](#-benchmarks)).
|
|
13
13
|
- **Scoped In-Memory Index (`figma_read` search)**: Indexes page roots first and loads frame descendants on demand. Search reports the indexed scope and completeness.
|
|
14
14
|
- **Binary IPC & Chunk Streaming**: Powered by **MessagePack** (`rmp-serde`) and progressive subtree chunking for instant transfers of massive design files.
|
|
@@ -65,7 +65,7 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
|
|
|
65
65
|
npx -y figma-rust-mcp@latest --init
|
|
66
66
|
```
|
|
67
67
|
The setup detects installed Codex, Claude Code, Antigravity, Cursor, Windsurf,
|
|
68
|
-
VS Code
|
|
68
|
+
VS Code, Copilot CLI, Gemini CLI, opencode, and Zed clients. Choose an action from the menu:
|
|
69
69
|
|
|
70
70
|
```text
|
|
71
71
|
1) Quick setup — plugin + background service + detected agents
|
|
@@ -227,18 +227,17 @@ Every read, search and export. **`operation`**:
|
|
|
227
227
|
- **Design system**: `rules` (markdown rule sheet of tokens, text styles and components), `get_styles`, `get_variables`, `get_tokens`, `get_local_components`, `get_component_map`, `get_unmapped_components`.
|
|
228
228
|
- **Export**: `screenshot`, `export_image` (png/jpg, `scale`), `export_svg`. Pass `outputPath` to save to disk instead of returning data.
|
|
229
229
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
end-to-end benchmark on real designs.
|
|
230
|
+
Node reads (`read_nodes`, `inspect_node`, `get_selection`, `subtree`, `typography`)
|
|
231
|
+
are compact by default. When it makes the JSON smaller, the response carries a
|
|
232
|
+
root `_compression` object (`version: 2`) with `styles` (exact style bundles shared
|
|
233
|
+
by `styleRef`) and `defaults` (a value every layer carried, stored once). To
|
|
234
|
+
reconstruct the plugin payload exactly: for each layer (object with string `id`,
|
|
235
|
+
`name` and `type`) merge `_compression.styles[styleRef]` and remove `styleRef`,
|
|
236
|
+
then add every `defaults` key the layer lacks; remove `_compression`. Identity,
|
|
237
|
+
geometry, text and hierarchy always stay inline. `detail: "full"` returns the raw
|
|
238
|
+
payload. On one 62-layer production frame read with every field group, the
|
|
239
|
+
response went from 81.5KB to 35.8KB (style sharing alone: 50.3KB); with default
|
|
240
|
+
fields, 19.0KB to 13.4KB. Bytes, not tokens; measure your own files.
|
|
242
241
|
|
|
243
242
|
### 3. `figma_write`
|
|
244
243
|
Executes JavaScript draw commands inside the sandboxed VM to build or modify designs on the Figma canvas.
|
|
@@ -263,7 +262,7 @@ Compiles a node (or the selection) into component code.
|
|
|
263
262
|
- **`framework`**: `"react-tailwind"` (default), `"react-shadcn"`, `"react-native"`, `"vue-tailwind"`, `"html"`, `"swiftui"`, `"clean-spec"`.
|
|
264
263
|
- **`outputPath`**, **`componentName`**.
|
|
265
264
|
- **`target: "tokens"`**: export variables and styles. **`format`**: `"css"` (default, `:root` + light/dark modes), `"tailwind"`, `"typescript"`, `"w3c"` (DTCG), `"json"`; **`collection`** / **`mode`** / **`prefix`**; **`outputPath`**.
|
|
266
|
-
- **`target: "project"`**: scaffold a design system — `globals.css` (Tailwind layers + CSS variables), `tailwind.config.ts` and one component per local component / component set (
|
|
265
|
+
- **`target: "project"`**: scaffold a design system — `globals.css` (Tailwind layers + CSS variables), `tailwind.config.ts` and one component per local component / component set in `componentDir`. For React, a component set becomes one component whose props (`size`, `state`, ...) pick the variant, each variant generated from its own node (up to 24); Vue/HTML use the default variant. Existing files and components already in the codebase are skipped unless **`overwrite`**. Options: **`projectDir`**, **`componentDir`** (`src/components/ui`), **`stylesPath`** (`src/styles/globals.css`), **`tailwindConfigPath`** (`tailwind.config.ts`), **`limit`** (30, max 100); frameworks `react-tailwind`, `react-shadcn`, `vue-tailwind`, `html`.
|
|
267
266
|
|
|
268
267
|
Token exports use schema v2 and preserve full paint/effect stacks, alpha, gradient
|
|
269
268
|
transforms, collection modes and raw alias values. CSS, Tailwind, TypeScript and
|
|
@@ -440,8 +439,18 @@ cargo build --release
|
|
|
440
439
|
|
|
441
440
|
# Rebuild Figma plugin bundle (if plugin-src/ is modified)
|
|
442
441
|
node scripts/build-plugin.js
|
|
442
|
+
|
|
443
|
+
# Codegen accuracy: render html output in headless Chrome and compare every
|
|
444
|
+
# layer (size, padding, gap, radius, typography, colors) with the spec
|
|
445
|
+
npm run accuracy # bundled fixture
|
|
446
|
+
npm run accuracy -- path/to/context.json --all # your own get_design_context dump
|
|
443
447
|
```
|
|
444
448
|
|
|
449
|
+
`npm run accuracy` needs Chrome (`CHROME_PATH` to override) and network access
|
|
450
|
+
for the Tailwind play CDN and Google Fonts. `--min <percent>` fails below a
|
|
451
|
+
threshold. The bundled checkout fixture currently matches 98.9% of 981 checks;
|
|
452
|
+
remaining misses are sub-pixel text metrics.
|
|
453
|
+
|
|
445
454
|
---
|
|
446
455
|
|
|
447
456
|
## 📄 License
|
package/bin/agent-config.js
CHANGED
|
@@ -28,7 +28,10 @@ export function detectAgents({ home = os.homedir(), env = process.env, platform
|
|
|
28
28
|
{ id: 'antigravity', label: 'Google Antigravity', configPath: path.join(home, '.gemini', 'config', 'mcp_config.json'), installed: existsAny([path.join(home, '.antigravity-ide'), path.join(home, '.gemini', 'config', 'mcp_config.json'), platform === 'darwin' ? path.join(support, 'Antigravity') : null, platform === 'win32' ? path.join(local, 'Programs', 'Antigravity') : null, macApp('Antigravity')]) || hasCommand('antigravity', env, platform) },
|
|
29
29
|
{ id: 'cursor', label: 'Cursor', configPath: path.join(home, '.cursor', 'mcp.json'), installed: existsAny([path.join(home, '.cursor'), platform === 'darwin' ? path.join(support, 'Cursor') : null, platform === 'win32' ? path.join(local, 'Programs', 'Cursor') : null, macApp('Cursor')]) || hasCommand('cursor', env, platform) },
|
|
30
30
|
{ id: 'windsurf', label: 'Windsurf', configPath: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), installed: existsAny([path.join(home, '.codeium', 'windsurf'), platform === 'darwin' ? path.join(support, 'Windsurf') : null, platform === 'win32' ? path.join(local, 'Programs', 'Windsurf') : null, macApp('Windsurf')]) || hasCommand('windsurf', env, platform) },
|
|
31
|
-
{ id: 'vscode', label: 'VS Code
|
|
31
|
+
{ id: 'vscode', label: 'VS Code', configPath: path.join(platform === 'darwin' ? support : platform === 'win32' ? roaming : xdg, 'Code', 'User', 'mcp.json'), installed: existsAny([path.join(home, '.vscode'), platform === 'darwin' ? path.join(support, 'Code') : null, platform === 'linux' ? path.join(xdg, 'Code') : null, platform === 'win32' ? path.join(local, 'Programs', 'Microsoft VS Code') : null, platform === 'win32' ? path.join(roaming, 'Code') : null, macApp('Visual Studio Code')]) || hasCommand('code', env, platform) },
|
|
32
|
+
{ id: 'copilot', label: 'Copilot CLI', configPath: path.join(env.COPILOT_HOME || path.join(home, '.copilot'), 'mcp-config.json'), installed: existsAny([env.COPILOT_HOME, path.join(home, '.copilot')]) || hasCommand('copilot', env, platform) },
|
|
33
|
+
{ id: 'gemini', label: 'Gemini CLI', configPath: path.join(home, '.gemini', 'settings.json'), installed: hasCommand('gemini', env, platform) },
|
|
34
|
+
{ id: 'opencode', label: 'opencode', configPath: path.join(xdg, 'opencode', 'opencode.json'), installed: existsAny([path.join(xdg, 'opencode'), path.join(home, '.opencode')]) || hasCommand('opencode', env, platform) },
|
|
32
35
|
{ id: 'zed', label: 'Zed', configPath: platform === 'darwin' ? path.join(support, 'Zed', 'settings.json') : platform === 'win32' ? path.join(roaming, 'Zed', 'settings.json') : path.join(xdg, 'zed', 'settings.json'), installed: existsAny([path.join(xdg, 'zed'), platform === 'darwin' ? path.join(support, 'Zed') : null, platform === 'win32' ? path.join(roaming, 'Zed') : null, macApp('Zed')]) || hasCommand('zed', env, platform) },
|
|
33
36
|
];
|
|
34
37
|
return agents.filter(agent => agent.installed).map(({ installed, ...agent }) => agent);
|
|
@@ -40,9 +43,9 @@ function upsertJsonc(filePath, rootKey, value) {
|
|
|
40
43
|
const config = parse(source, errors, { allowTrailingComma: true, disallowComments: false });
|
|
41
44
|
if (errors.length) throw new Error(`Invalid JSON/JSONC in ${filePath}; left it unchanged.`);
|
|
42
45
|
const current = config?.[rootKey]?.[SERVER_NAME];
|
|
43
|
-
if (current && Object.entries(value).every(([key, entryValue]) => current[key] === entryValue)) return false;
|
|
46
|
+
if (current && Object.entries(value).every(([key, entryValue]) => JSON.stringify(current[key]) === JSON.stringify(entryValue))) return false;
|
|
44
47
|
const merged = current && typeof current === 'object' && !Array.isArray(current) ? { ...current, ...value } : value;
|
|
45
|
-
for (const key of ['command', 'args', 'serverUrl', 'url', 'type']) {
|
|
48
|
+
for (const key of ['command', 'args', 'serverUrl', 'httpUrl', 'url', 'type']) {
|
|
46
49
|
if (!Object.hasOwn(value, key) && Object.hasOwn(merged, key)) delete merged[key];
|
|
47
50
|
}
|
|
48
51
|
const edits = modify(source, [rootKey, SERVER_NAME], merged, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: '\n' } });
|
|
@@ -76,14 +79,20 @@ function upsertCodex(filePath) {
|
|
|
76
79
|
return true;
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
const ENTRIES = {
|
|
83
|
+
antigravity: ['mcpServers', { serverUrl: 'http://127.0.0.1:41730/sse' }],
|
|
84
|
+
claude: ['mcpServers', { type: 'http', url: HTTP_URL }],
|
|
85
|
+
windsurf: ['mcpServers', { serverUrl: HTTP_URL }],
|
|
86
|
+
vscode: ['servers', { type: 'http', url: HTTP_URL }],
|
|
87
|
+
copilot: ['mcpServers', { type: 'http', url: HTTP_URL, tools: ['*'] }],
|
|
88
|
+
gemini: ['mcpServers', { httpUrl: HTTP_URL }],
|
|
89
|
+
opencode: ['mcp', { type: 'remote', url: HTTP_URL, enabled: true }],
|
|
90
|
+
zed: ['context_servers', { url: HTTP_URL }],
|
|
91
|
+
};
|
|
92
|
+
|
|
79
93
|
export function configureAgent(agent) {
|
|
80
|
-
const entry = agent.id === 'antigravity'
|
|
81
|
-
? { serverUrl: 'http://127.0.0.1:41730/sse' }
|
|
82
|
-
: agent.id === 'claude' || agent.id === 'vscode'
|
|
83
|
-
? { type: 'http', url: HTTP_URL }
|
|
84
|
-
: { url: HTTP_URL };
|
|
85
94
|
if (agent.id === 'codex') return upsertCodex(agent.configPath);
|
|
86
|
-
const rootKey = agent.id
|
|
95
|
+
const [rootKey, entry] = ENTRIES[agent.id] || ['mcpServers', { url: HTTP_URL }];
|
|
87
96
|
return upsertJsonc(agent.configPath, rootKey, entry);
|
|
88
97
|
}
|
|
89
98
|
|
package/bin/figma-rust-mcp.js
CHANGED
|
@@ -40,6 +40,17 @@ try {
|
|
|
40
40
|
} catch (_) {}
|
|
41
41
|
if (!pkgVersion) throw new Error(`Cannot read package version from ${pkgPath}`);
|
|
42
42
|
|
|
43
|
+
// ─── TERMINAL UI ──────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
46
|
+
const paint = code => text => useColor ? `\x1b[${code}m${text}\x1b[0m` : String(text);
|
|
47
|
+
const bold = paint('1'), dim = paint('2'), green = paint('32'), yellow = paint('33'), red = paint('31'), cyan = paint('36');
|
|
48
|
+
const tag = code => process.stderr.isTTY && !process.env.NO_COLOR ? `\x1b[${code}m[figma-rust-mcp]\x1b[0m` : '[figma-rust-mcp]';
|
|
49
|
+
const tilde = p => p.startsWith(os.homedir()) ? `~${p.slice(os.homedir().length)}` : p;
|
|
50
|
+
const ok = (msg, detail = '') => console.log(` ${green('✓')} ${msg}${detail ? ` ${dim(detail)}` : ''}`);
|
|
51
|
+
const note = msg => console.log(` ${dim(msg)}`);
|
|
52
|
+
const step = (n, total, title) => console.log(`\n${cyan(`[${n}/${total}]`)} ${bold(title)}`);
|
|
53
|
+
|
|
43
54
|
function getPlatformArch() {
|
|
44
55
|
const platform = process.platform;
|
|
45
56
|
const arch = process.arch;
|
|
@@ -83,6 +94,27 @@ function getCacheDir(version) {
|
|
|
83
94
|
return path.join(baseCache, 'figma-rust-mcp', `v${version}`);
|
|
84
95
|
}
|
|
85
96
|
|
|
97
|
+
// Progress bar on stderr (stdout carries MCP stdio traffic); silent when stderr is not a terminal.
|
|
98
|
+
function downloadProgress(total) {
|
|
99
|
+
const tty = process.stderr.isTTY;
|
|
100
|
+
const started = Date.now();
|
|
101
|
+
let received = 0, lastDraw = 0;
|
|
102
|
+
const mb = bytes => (bytes / 1048576).toFixed(1);
|
|
103
|
+
const draw = () => {
|
|
104
|
+
const speed = `${mb(received / Math.max((Date.now() - started) / 1000, 0.001))} MB/s`;
|
|
105
|
+
if (!total) return process.stderr.write(`\r\x1b[2K ↓ ${mb(received)} MB ${speed}`);
|
|
106
|
+
const ratio = Math.min(received / total, 1), width = 24, filled = Math.round(ratio * width);
|
|
107
|
+
process.stderr.write(`\r\x1b[2K ↓ \x1b[36m${'█'.repeat(filled)}\x1b[2m${'░'.repeat(width - filled)}\x1b[0m ${String(Math.round(ratio * 100)).padStart(3)}% ${mb(received)}/${mb(total)} MB ${speed}`);
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
add(bytes) {
|
|
111
|
+
received += bytes;
|
|
112
|
+
if (tty && Date.now() - lastDraw > 80) { lastDraw = Date.now(); draw(); }
|
|
113
|
+
},
|
|
114
|
+
done() { if (tty) { draw(); process.stderr.write('\n'); } },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
86
118
|
function downloadFile(url, destPath) {
|
|
87
119
|
return new Promise((resolve, reject) => {
|
|
88
120
|
const followRedirects = (currentUrl, redirectCount = 0) => {
|
|
@@ -104,9 +136,12 @@ function downloadFile(url, destPath) {
|
|
|
104
136
|
}
|
|
105
137
|
|
|
106
138
|
const fileStream = fs.createWriteStream(destPath);
|
|
139
|
+
const progress = downloadProgress(Number(res.headers['content-length']) || 0);
|
|
140
|
+
res.on('data', chunk => progress.add(chunk.length));
|
|
107
141
|
res.pipe(fileStream);
|
|
108
142
|
|
|
109
143
|
fileStream.on('finish', () => {
|
|
144
|
+
progress.done();
|
|
110
145
|
fileStream.close(() => resolve());
|
|
111
146
|
});
|
|
112
147
|
|
|
@@ -147,8 +182,12 @@ async function findOrDownloadBinary() {
|
|
|
147
182
|
const localRelease = path.resolve(__dirname, '../target/release/figma-rust-mcp' + (process.platform === 'win32' ? '.exe' : ''));
|
|
148
183
|
const localDebug = path.resolve(__dirname, '../target/debug/figma-rust-mcp' + (process.platform === 'win32' ? '.exe' : ''));
|
|
149
184
|
|
|
150
|
-
|
|
151
|
-
|
|
185
|
+
// A stale local build would silently downgrade the service; only use one built from this version.
|
|
186
|
+
const isCurrentBuild = bin => {
|
|
187
|
+
try { return fs.existsSync(bin) && execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000 }).trim().endsWith(` ${pkgVersion}`); }
|
|
188
|
+
catch (_) { return false; }
|
|
189
|
+
};
|
|
190
|
+
for (const bin of [localRelease, localDebug]) if (isCurrentBuild(bin)) return bin;
|
|
152
191
|
|
|
153
192
|
const info = getPlatformArch();
|
|
154
193
|
const cacheDir = getCacheDir(pkgVersion);
|
|
@@ -167,12 +206,12 @@ async function findOrDownloadBinary() {
|
|
|
167
206
|
const releaseUrl = `https://github.com/${repoOwner}/${repoName}/releases/download/v${pkgVersion}/${info.assetName}`;
|
|
168
207
|
const latestUrl = `https://github.com/${repoOwner}/${repoName}/releases/latest/download/${info.assetName}`;
|
|
169
208
|
|
|
170
|
-
process.stderr.write(
|
|
209
|
+
process.stderr.write(`${tag(36)} Downloading binary for ${info.osName}-${info.archName} (v${pkgVersion})...\n`);
|
|
171
210
|
|
|
172
211
|
try {
|
|
173
212
|
await downloadFile(releaseUrl, archivePath);
|
|
174
213
|
} catch (err) {
|
|
175
|
-
process.stderr.write(
|
|
214
|
+
process.stderr.write(`${tag(33)} Release v${pkgVersion} not found, trying latest release...\n`);
|
|
176
215
|
try {
|
|
177
216
|
await downloadFile(latestUrl, archivePath);
|
|
178
217
|
} catch (fallbackErr) {
|
|
@@ -201,7 +240,7 @@ async function findOrDownloadBinary() {
|
|
|
201
240
|
fs.chmodSync(cachedBin, 0o755);
|
|
202
241
|
}
|
|
203
242
|
|
|
204
|
-
process.stderr.write(
|
|
243
|
+
process.stderr.write(`${tag(32)} Downloaded to ${tilde(cacheDir)}\n`);
|
|
205
244
|
return cachedBin;
|
|
206
245
|
}
|
|
207
246
|
|
|
@@ -215,6 +254,7 @@ async function findOrDownloadBinary() {
|
|
|
215
254
|
*/
|
|
216
255
|
async function installService(binPath) {
|
|
217
256
|
removeLegacyService();
|
|
257
|
+
await stopRunningServer();
|
|
218
258
|
const platform = process.platform;
|
|
219
259
|
|
|
220
260
|
if (platform === 'darwin') {
|
|
@@ -253,15 +293,9 @@ async function installService(binPath) {
|
|
|
253
293
|
|
|
254
294
|
fs.writeFileSync(plistPath, plist, 'utf8');
|
|
255
295
|
|
|
256
|
-
|
|
257
|
-
try { execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'pipe' }); } catch (_) {}
|
|
258
|
-
execSync(`launchctl load -w "${plistPath}"`, { stdio: 'inherit' });
|
|
296
|
+
execSync(`launchctl load -w "${plistPath}"`, { stdio: 'pipe' });
|
|
259
297
|
|
|
260
|
-
|
|
261
|
-
console.log(` Plist: ${plistPath}`);
|
|
262
|
-
console.log(` Logs: ${logDir}/stdout.log`);
|
|
263
|
-
console.log(`\n\x1b[36mThe server will now start automatically on every login.\x1b[0m`);
|
|
264
|
-
console.log(`\x1b[36mTo uninstall: npx figma-rust-mcp --uninstall-service\x1b[0m`);
|
|
298
|
+
await serviceInstalled(`Logs ${tilde(path.join(logDir, 'stdout.log'))}`);
|
|
265
299
|
|
|
266
300
|
} else if (platform === 'linux') {
|
|
267
301
|
const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
@@ -284,13 +318,10 @@ WantedBy=default.target
|
|
|
284
318
|
`;
|
|
285
319
|
|
|
286
320
|
fs.writeFileSync(servicePath, unit, 'utf8');
|
|
287
|
-
execSync(`systemctl --user daemon-reload`, { stdio: '
|
|
288
|
-
execSync(`systemctl --user enable --now figma-rust-mcp`, { stdio: '
|
|
321
|
+
execSync(`systemctl --user daemon-reload`, { stdio: 'pipe' });
|
|
322
|
+
execSync(`systemctl --user enable --now figma-rust-mcp`, { stdio: 'pipe' });
|
|
289
323
|
|
|
290
|
-
|
|
291
|
-
console.log(` Service: ${servicePath}`);
|
|
292
|
-
console.log(`\n\x1b[36mThe server will now start automatically on every login.\x1b[0m`);
|
|
293
|
-
console.log(`\x1b[36mTo uninstall: npx figma-rust-mcp --uninstall-service\x1b[0m`);
|
|
324
|
+
await serviceInstalled('Logs: journalctl --user -u figma-rust-mcp');
|
|
294
325
|
|
|
295
326
|
} else if (platform === 'win32') {
|
|
296
327
|
const taskName = 'FigmaRustMCPServer';
|
|
@@ -298,15 +329,12 @@ WantedBy=default.target
|
|
|
298
329
|
// The native launcher detaches the server and redirects output to a log file.
|
|
299
330
|
const taskCommand = `"${binPath}" --background`;
|
|
300
331
|
execFileSync('schtasks', ['/Create', '/F', '/TN', taskName,
|
|
301
|
-
'/TR', taskCommand, '/SC', 'ONLOGON'], { stdio: '
|
|
332
|
+
'/TR', taskCommand, '/SC', 'ONLOGON'], { stdio: 'pipe', windowsHide: true });
|
|
302
333
|
|
|
303
334
|
// Start it immediately too
|
|
304
335
|
try { execSync(`schtasks /Run /TN "${taskName}"`, { stdio: 'pipe' }); } catch (_) {}
|
|
305
336
|
|
|
306
|
-
|
|
307
|
-
console.log(` Task: ${taskName}`);
|
|
308
|
-
console.log(`\n\x1b[36mThe server will now start automatically on every login.\x1b[0m`);
|
|
309
|
-
console.log(`\x1b[36mTo uninstall: npx figma-rust-mcp --uninstall-service\x1b[0m`);
|
|
337
|
+
await serviceInstalled(`Task Scheduler: ${taskName}`);
|
|
310
338
|
|
|
311
339
|
} else {
|
|
312
340
|
console.error(`\x1b[31mUnsupported platform: ${platform}\x1b[0m`);
|
|
@@ -314,6 +342,55 @@ WantedBy=default.target
|
|
|
314
342
|
}
|
|
315
343
|
}
|
|
316
344
|
|
|
345
|
+
async function serviceInstalled(where) {
|
|
346
|
+
const port = Number(process.env.FIGMA_RUST_MCP_PORT) || 41730;
|
|
347
|
+
const deadline = Date.now() + 10000;
|
|
348
|
+
while (!(await isOldServerUp(port)) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 300));
|
|
349
|
+
if (await isOldServerUp(port)) ok('Background service running', `http://127.0.0.1:${port}`);
|
|
350
|
+
else console.log(` ${yellow('!')} Service installed but not answering on port ${port} yet`);
|
|
351
|
+
note(`Starts on login · ${where}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isOldServerUp(port) {
|
|
355
|
+
return new Promise(resolve => {
|
|
356
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/plugin/version', timeout: 500 }, res => {
|
|
357
|
+
let body = '';
|
|
358
|
+
res.on('data', chunk => { body += chunk; });
|
|
359
|
+
res.on('end', () => { try { resolve(JSON.parse(body).name === 'figma-rust-mcp'); } catch (_) { resolve(false); } });
|
|
360
|
+
});
|
|
361
|
+
req.on('error', () => resolve(false));
|
|
362
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Stop the installed service and any other running server before installing the new one.
|
|
368
|
+
* A still-running old version keeps port 41730, so the new one would move to 41731 while
|
|
369
|
+
* agents stay pointed at the old server.
|
|
370
|
+
*/
|
|
371
|
+
async function stopRunningServer() {
|
|
372
|
+
const quiet = cmd => { try { execSync(cmd, { stdio: 'pipe', windowsHide: true }); } catch (_) {} };
|
|
373
|
+
if (process.platform === 'darwin') {
|
|
374
|
+
quiet(`launchctl unload "${path.join(os.homedir(), 'Library', 'LaunchAgents', 'io.github.figma-rust-mcp.server.plist')}"`);
|
|
375
|
+
quiet('pkill -x figma-rust-mcp');
|
|
376
|
+
} else if (process.platform === 'linux') {
|
|
377
|
+
quiet('systemctl --user stop figma-rust-mcp');
|
|
378
|
+
quiet('pkill -x figma-rust-mcp');
|
|
379
|
+
} else if (process.platform === 'win32') {
|
|
380
|
+
quiet('schtasks /End /TN "FigmaRustMCPServer"');
|
|
381
|
+
quiet('taskkill /F /T /IM figma-rust-mcp.exe');
|
|
382
|
+
}
|
|
383
|
+
const port = Number(process.env.FIGMA_RUST_MCP_PORT) || 41730;
|
|
384
|
+
const deadline = Date.now() + 10000;
|
|
385
|
+
while (await isOldServerUp(port)) {
|
|
386
|
+
if (Date.now() > deadline) {
|
|
387
|
+
console.log(` ${yellow('!')} An old figma-rust-mcp still answers on port ${port}; stop it, then rerun setup.`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
317
394
|
/**
|
|
318
395
|
* Uninstall the background service.
|
|
319
396
|
*/
|
|
@@ -331,7 +408,7 @@ async function uninstallService() {
|
|
|
331
408
|
}
|
|
332
409
|
removeLegacyService();
|
|
333
410
|
|
|
334
|
-
|
|
411
|
+
ok('Background service removed');
|
|
335
412
|
|
|
336
413
|
} else if (platform === 'linux') {
|
|
337
414
|
try { execSync(`systemctl --user disable --now figma-rust-mcp`, { stdio: 'inherit' }); } catch (_) {}
|
|
@@ -340,14 +417,14 @@ async function uninstallService() {
|
|
|
340
417
|
try { execSync(`systemctl --user daemon-reload`, { stdio: 'inherit' }); } catch (_) {}
|
|
341
418
|
removeLegacyService();
|
|
342
419
|
|
|
343
|
-
|
|
420
|
+
ok('Background service removed');
|
|
344
421
|
|
|
345
422
|
} else if (platform === 'win32') {
|
|
346
423
|
const taskName = 'FigmaRustMCPServer';
|
|
347
424
|
try { execSync(`schtasks /Delete /F /TN "${taskName}"`, { stdio: 'inherit' }); } catch (_) {}
|
|
348
425
|
removeLegacyService();
|
|
349
426
|
|
|
350
|
-
|
|
427
|
+
ok('Background service removed');
|
|
351
428
|
|
|
352
429
|
} else {
|
|
353
430
|
console.error(`\x1b[31mUnsupported platform: ${platform}\x1b[0m`);
|
|
@@ -415,13 +492,9 @@ function setupPlugin(customDir) {
|
|
|
415
492
|
}
|
|
416
493
|
|
|
417
494
|
const manifestPath = path.join(pluginDir, 'manifest.json');
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
console.log(` 1. Open Figma Desktop`);
|
|
422
|
-
console.log(` 2. Go to Plugins → Development → Import plugin from manifest...`);
|
|
423
|
-
console.log(` 3. Select: ${manifestPath}`);
|
|
424
|
-
console.log(` 4. Done! All future updates load dynamically from figma-rust-mcp without file re-imports.\n`);
|
|
495
|
+
ok('Figma plugin installed', tilde(manifestPath));
|
|
496
|
+
note('First time only: Figma Desktop → Plugins → Development → Import plugin from manifest…');
|
|
497
|
+
note('Later updates load automatically; no re-import needed.');
|
|
425
498
|
return manifestPath;
|
|
426
499
|
}
|
|
427
500
|
|
|
@@ -490,7 +563,7 @@ async function upgrade() {
|
|
|
490
563
|
try {
|
|
491
564
|
const major = pkgVersion ? pkgVersion.split('.')[0] : 'latest';
|
|
492
565
|
execSync(`npm install -g figma-rust-mcp@${major}`, { stdio: 'inherit' });
|
|
493
|
-
|
|
566
|
+
ok(`Upgraded figma-rust-mcp (${major === 'latest' ? 'latest' : `v${major}.x`})`);
|
|
494
567
|
setupPlugin();
|
|
495
568
|
if (isServiceInstalled()) {
|
|
496
569
|
console.log('\x1b[36mUpdating background service binary...\x1b[0m');
|
|
@@ -510,25 +583,42 @@ async function setupMenu() {
|
|
|
510
583
|
|
|
511
584
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
512
585
|
const agents = detectAgents();
|
|
586
|
+
const items = [
|
|
587
|
+
['1', 'Quick setup', 'plugin + background service + agents', true],
|
|
588
|
+
['2', 'Figma plugin', 'install or update only'],
|
|
589
|
+
['3', 'Run server', 'in this terminal'],
|
|
590
|
+
['4', 'Agents', `configure detected agents (${agents.length} found)`],
|
|
591
|
+
['5', 'Service status', 'is the background service installed?'],
|
|
592
|
+
['6', 'Upgrade', 'update package and refresh setup'],
|
|
593
|
+
['7', 'Remove service', 'stop and uninstall the background service'],
|
|
594
|
+
['0', 'Exit', ''],
|
|
595
|
+
];
|
|
513
596
|
try {
|
|
514
|
-
console.log(`\n
|
|
515
|
-
|
|
597
|
+
console.log(`\n ${bold('Figma Rust MCP')} ${dim(`v${pkgVersion}`)}\n`);
|
|
598
|
+
for (const [key, label, hint, recommended] of items) {
|
|
599
|
+
console.log(` ${cyan(key)} ${label.padEnd(16)}${dim(hint)}${recommended ? ` ${green('(recommended)')}` : ''}`);
|
|
600
|
+
}
|
|
601
|
+
const choice = (await rl.question(`\n${cyan('›')} Choose [1]: `)).trim() || '1';
|
|
516
602
|
switch (choice) {
|
|
517
603
|
case '1': {
|
|
604
|
+
step(1, 3, 'Figma plugin');
|
|
518
605
|
setupPlugin();
|
|
606
|
+
step(2, 3, 'Background service');
|
|
519
607
|
const binPath = await findOrDownloadBinary();
|
|
520
608
|
await installService(binPath);
|
|
609
|
+
step(3, 3, 'AI agents');
|
|
521
610
|
await configureDetectedAgents(rl, agents);
|
|
611
|
+
console.log(`\n${green('✓')} ${bold('Setup complete.')} Restart your agents, then run the plugin in Figma.\n`);
|
|
522
612
|
break;
|
|
523
613
|
}
|
|
524
614
|
case '2': setupPlugin(); break;
|
|
525
615
|
case '3': await runBinary(['--server']); break;
|
|
526
|
-
case '4': await configureDetectedAgents(rl, agents); break;
|
|
527
|
-
case '5':
|
|
616
|
+
case '4': await configureDetectedAgents(rl, agents); note('Restart the configured agents to connect.'); break;
|
|
617
|
+
case '5': isServiceInstalled() ? ok('Background service is installed') : console.log(` ${yellow('!')} Background service is not installed`); break;
|
|
528
618
|
case '6': await upgrade(); break;
|
|
529
619
|
case '7': await uninstallService(); break;
|
|
530
620
|
case '0': break;
|
|
531
|
-
default: console.log('Unknown option. Run --init to try again
|
|
621
|
+
default: console.log(` ${yellow('!')} Unknown option. Run --init to try again.`);
|
|
532
622
|
}
|
|
533
623
|
} finally {
|
|
534
624
|
rl.close();
|
|
@@ -537,33 +627,34 @@ async function setupMenu() {
|
|
|
537
627
|
|
|
538
628
|
async function configureDetectedAgents(rl, agents) {
|
|
539
629
|
if (!agents.length) {
|
|
540
|
-
console.log('No supported AI
|
|
630
|
+
console.log(` ${yellow('!')} No supported AI agents detected. Install one and rerun --init.`);
|
|
541
631
|
return;
|
|
542
632
|
}
|
|
543
633
|
|
|
544
|
-
|
|
545
|
-
agents.forEach((agent, index) => console.log(` ${index + 1}
|
|
546
|
-
const answer = (await rl.question('Configure which
|
|
547
|
-
if (answer === '')
|
|
634
|
+
const width = Math.max(...agents.map(agent => agent.label.length));
|
|
635
|
+
agents.forEach((agent, index) => console.log(` ${cyan(index + 1)} ${agent.label.padEnd(width + 2)}${dim(tilde(agent.configPath))}`));
|
|
636
|
+
const answer = (await rl.question(`\n${cyan('›')} Configure which? ${dim('numbers like 1,3 · a = all · n = skip')} [a]: `)).trim().toLowerCase() || 'a';
|
|
637
|
+
if (answer === 'n') {
|
|
638
|
+
note('Skipped; MCP configuration was not changed.');
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
548
641
|
const selected = answer === 'a'
|
|
549
642
|
? agents
|
|
550
643
|
: [...new Set(answer.split(',').map(value => Number(value.trim()) - 1))]
|
|
551
644
|
.filter(index => Number.isInteger(index) && index >= 0 && index < agents.length)
|
|
552
645
|
.map(index => agents[index]);
|
|
553
646
|
if (!selected.length) {
|
|
554
|
-
console.log('No valid
|
|
647
|
+
console.log(` ${yellow('!')} No valid selection; MCP configuration was not changed.`);
|
|
555
648
|
return;
|
|
556
649
|
}
|
|
557
650
|
|
|
558
651
|
for (const agent of selected) {
|
|
559
652
|
try {
|
|
560
|
-
|
|
561
|
-
console.log(` ${changed ? '✓ Configured' : '✓ Already configured'} ${agent.label}`);
|
|
653
|
+
ok(agent.label.padEnd(width + 2), configureAgent(agent) ? 'configured' : 'already configured');
|
|
562
654
|
} catch (err) {
|
|
563
|
-
console.
|
|
655
|
+
console.log(` ${red('✗')} ${agent.label.padEnd(width + 2)}${dim(err.message)}`);
|
|
564
656
|
}
|
|
565
657
|
}
|
|
566
|
-
console.log('Restart or reload the selected agent to connect it to Figma Rust MCP.');
|
|
567
658
|
}
|
|
568
659
|
|
|
569
660
|
function runBinary(args) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figma-rust-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.1",
|
|
4
4
|
"description": "Claude Code to Figma — Bidirectional MCP bridge. AI draws UI on Figma canvas, reads designs back. Works with Claude, Cursor, VS Code, Windsurf, Antigravity. No API key needed.",
|
|
5
5
|
"mcpName": "io.github.BuiHung1612/figma-rust-mcp",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"test:tokens": "node --test scripts/test-plugin-tokens.mjs && cargo test",
|
|
22
22
|
"dev": "cargo run",
|
|
23
23
|
"bench": "cargo build --release && node scripts/bench.mjs",
|
|
24
|
-
"test:sessions": "cargo build && node --test scripts/test-bridge-sessions.mjs"
|
|
24
|
+
"test:sessions": "cargo build && node --test scripts/test-bridge-sessions.mjs",
|
|
25
|
+
"accuracy": "cargo build && node scripts/codegen-accuracy.mjs"
|
|
25
26
|
},
|
|
26
27
|
"engines": {
|
|
27
28
|
"node": ">=18"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Measures figma_to_code accuracy: renders html-annotated output in headless Chrome
|
|
2
|
+
// and compares every layer's box and styles with the get_design_context spec.
|
|
3
|
+
// node scripts/codegen-accuracy.mjs [context.json ...] [--min <percent>] [--all]
|
|
4
|
+
// Default context: tests/fixtures/design-context-checkout.json. Needs Chrome and
|
|
5
|
+
// network access for the Tailwind play CDN and Google Fonts.
|
|
6
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
7
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const minIndex = args.indexOf('--min');
|
|
13
|
+
const min = minIndex >= 0 ? Number(args.splice(minIndex, 2)[1]) : null;
|
|
14
|
+
const showAll = args.includes('--all') && args.splice(args.indexOf('--all'), 1);
|
|
15
|
+
const files = args.length ? args : ['tests/fixtures/design-context-checkout.json'];
|
|
16
|
+
const binary = process.env.FIGMA_TEST_BINARY || 'target/debug/figma-rust-mcp';
|
|
17
|
+
const chromePath = process.env.CHROME_PATH || {
|
|
18
|
+
darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
19
|
+
win32: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
20
|
+
}[process.platform] || 'google-chrome';
|
|
21
|
+
|
|
22
|
+
const px = v => (typeof v === 'number' ? v : parseFloat(v));
|
|
23
|
+
const box = v => { const p = String(v ?? '').trim().split(/\s+/).map(px); return [p[0], p[1] ?? p[0], p[2] ?? p[0], p[3] ?? p[1] ?? p[0]]; };
|
|
24
|
+
const rgb = hex => { const h = hex.replace('#', ''); return `rgb(${parseInt(h.slice(0, 2), 16)}, ${parseInt(h.slice(2, 4), 16)}, ${parseInt(h.slice(4, 6), 16)})`; };
|
|
25
|
+
const WEIGHTS = { thin: 100, extralight: 200, light: 300, regular: 400, normal: 400, medium: 500, semibold: 600, bold: 700, extrabold: 800, black: 900 };
|
|
26
|
+
|
|
27
|
+
function walk(node, out = []) {
|
|
28
|
+
out.push(node);
|
|
29
|
+
for (const child of node.children || []) walk(child, out);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Tokens resolve through the layer's own solid paint, so colors compare as rendered.
|
|
34
|
+
function tokenCss(layers) {
|
|
35
|
+
const vars = new Map();
|
|
36
|
+
for (const n of layers) {
|
|
37
|
+
const color = n.paintData?.find(p => p.type === 'SOLID' && p.color)?.color;
|
|
38
|
+
for (const ref of [n.fill, n.text?.color]) {
|
|
39
|
+
const name = /^var\((--[^)]+)\)$/.exec(ref || '')?.[1];
|
|
40
|
+
if (name && color && /^#[0-9a-f]{6}$/i.test(color)) vars.set(name, color);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return `:root{${[...vars].map(([k, v]) => `${k}:${v}`).join(';')}}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveColor(value, layer) {
|
|
47
|
+
if (!value) return null;
|
|
48
|
+
if (value.startsWith('var(')) value = layer.paintData?.find(p => p.type === 'SOLID')?.color;
|
|
49
|
+
return value && /^#[0-9a-f]{6}$/i.test(value) ? rgb(value) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One entry per measurable property: [group, expected, actual, tolerance].
|
|
53
|
+
function checks(layer, got) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const num = (group, expected, actual, tol = 1) => {
|
|
56
|
+
if (Number.isFinite(expected)) out.push([group, expected, actual, tol]);
|
|
57
|
+
};
|
|
58
|
+
num('width', layer.size?.width, got.width);
|
|
59
|
+
num('height', layer.size?.height, got.height);
|
|
60
|
+
const layout = layer.layout;
|
|
61
|
+
if (layout?.padding) box(layout.padding).forEach((v, i) => num('padding', v, got.padding[i], 0.5));
|
|
62
|
+
if (layout?.display === 'flex' && layout.gap != null) {
|
|
63
|
+
num('gap', px(layout.gap), layout.flexDirection === 'column' ? got.rowGap : got.columnGap, 0.5);
|
|
64
|
+
}
|
|
65
|
+
// Browsers clamp radii to half the shorter side (rounded-full is 9999px); compare what renders.
|
|
66
|
+
const clamp = v => Math.min(v, Math.min(got.width, got.height) / 2);
|
|
67
|
+
if (layer.borderRadius) box(layer.borderRadius).forEach((v, i) => num('radius', clamp(v), clamp(got.radius[i]), 0.5));
|
|
68
|
+
const text = layer.text;
|
|
69
|
+
if (layer.type === 'TEXT' && text) {
|
|
70
|
+
num('fontSize', text.fontSize, got.fontSize, 0.1);
|
|
71
|
+
num('fontWeight', text.fontWeightNumeric ?? WEIGHTS[String(text.fontWeight).toLowerCase().replace(/\s/g, '')], got.fontWeight, 0);
|
|
72
|
+
if (String(text.lineHeight).endsWith('%')) num('lineHeight', text.fontSize * px(text.lineHeight) / 100, got.lineHeight, 0.5);
|
|
73
|
+
else if (text.lineHeight != null && text.lineHeight !== 'auto') num('lineHeight', px(text.lineHeight), got.lineHeight, 0.5);
|
|
74
|
+
const color = resolveColor(text.color, layer);
|
|
75
|
+
if (color) out.push(['color', color, got.color, 0]);
|
|
76
|
+
} else if (layer.backgroundCss?.property === 'background-color') {
|
|
77
|
+
const bg = resolveColor(layer.backgroundCss.value, layer);
|
|
78
|
+
if (bg) out.push(['background', bg, got.background, 0]);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function launchChrome() {
|
|
84
|
+
const profile = mkdtempSync(join(tmpdir(), 'codegen-accuracy-'));
|
|
85
|
+
const chrome = spawn(chromePath, ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--hide-scrollbars', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
86
|
+
const port = await new Promise((resolve, reject) => {
|
|
87
|
+
let log = '';
|
|
88
|
+
chrome.stderr.on('data', b => { log += b; const m = /DevTools listening on ws:\/\/[^:]+:(\d+)/.exec(log); if (m) resolve(m[1]); });
|
|
89
|
+
chrome.on('exit', code => reject(new Error(`Chrome exited (${code}): ${log.slice(-500)}`)));
|
|
90
|
+
});
|
|
91
|
+
return { chrome, port };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function cdpPage(port) {
|
|
95
|
+
const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json();
|
|
96
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
97
|
+
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
|
98
|
+
let id = 0;
|
|
99
|
+
const pending = new Map();
|
|
100
|
+
const events = [];
|
|
101
|
+
ws.onmessage = e => {
|
|
102
|
+
const msg = JSON.parse(e.data);
|
|
103
|
+
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); } else events.push(msg);
|
|
104
|
+
};
|
|
105
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
106
|
+
const msgId = ++id;
|
|
107
|
+
pending.set(msgId, msg => (msg.error ? reject(new Error(`${method}: ${msg.error.message}`)) : resolve(msg.result)));
|
|
108
|
+
ws.send(JSON.stringify({ id: msgId, method, params }));
|
|
109
|
+
});
|
|
110
|
+
return { send, events, close: () => ws.close() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const MEASURE = `(() => [...document.querySelectorAll('[data-node-id]')].map(el => {
|
|
114
|
+
const r = el.getBoundingClientRect(), s = getComputedStyle(el), n = v => parseFloat(v) || 0;
|
|
115
|
+
return { id: el.dataset.nodeId, width: r.width, height: r.height,
|
|
116
|
+
padding: [n(s.paddingTop), n(s.paddingRight), n(s.paddingBottom), n(s.paddingLeft)],
|
|
117
|
+
rowGap: n(s.rowGap), columnGap: n(s.columnGap),
|
|
118
|
+
radius: [n(s.borderTopLeftRadius), n(s.borderTopRightRadius), n(s.borderBottomRightRadius), n(s.borderBottomLeftRadius)],
|
|
119
|
+
fontSize: n(s.fontSize), fontWeight: n(s.fontWeight), lineHeight: s.lineHeight === 'normal' ? NaN : n(s.lineHeight),
|
|
120
|
+
color: s.color, background: s.backgroundColor };
|
|
121
|
+
}))()`;
|
|
122
|
+
|
|
123
|
+
async function measure(page, file) {
|
|
124
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
125
|
+
const root = raw.context || raw;
|
|
126
|
+
const layers = walk(root);
|
|
127
|
+
const html = execFileSync(binary, ['--codegen', 'html-annotated', file], { encoding: 'utf8' });
|
|
128
|
+
const families = [...new Set(layers.map(n => n.text?.fontFamily).filter(Boolean))];
|
|
129
|
+
const fonts = families.map(f => `family=${encodeURIComponent(f)}:ital,wght@0,100;0,300;0,400;0,700;0,900;1,400`).join('&');
|
|
130
|
+
const doc = `<!doctype html><html><head><meta charset="utf-8">
|
|
131
|
+
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
|
132
|
+
${fonts ? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${fonts}&display=block">` : ''}
|
|
133
|
+
<style>${tokenCss(layers)} body{margin:0}</style></head><body>${html}</body></html>`;
|
|
134
|
+
const path = join(mkdtempSync(join(tmpdir(), 'codegen-page-')), 'index.html');
|
|
135
|
+
writeFileSync(path, doc);
|
|
136
|
+
const width = Math.ceil(root.size?.width || 1440);
|
|
137
|
+
await page.send('Emulation.setDeviceMetricsOverride', { width, height: Math.ceil(root.size?.height || 900), deviceScaleFactor: 1, mobile: false });
|
|
138
|
+
await page.send('Page.navigate', { url: `file://${path}` });
|
|
139
|
+
await page.send('Runtime.evaluate', { expression: 'new Promise(r => (document.readyState === "complete" ? r() : addEventListener("load", r))).then(() => document.fonts.ready).then(() => new Promise(r => setTimeout(r, 300)))', awaitPromise: true });
|
|
140
|
+
const { result } = await page.send('Runtime.evaluate', { expression: MEASURE, returnByValue: true });
|
|
141
|
+
const rendered = new Map(result.value.map(m => [m.id, m]));
|
|
142
|
+
const byGroup = new Map();
|
|
143
|
+
const misses = [];
|
|
144
|
+
let missing = 0;
|
|
145
|
+
for (const layer of layers) {
|
|
146
|
+
const got = rendered.get(layer.id);
|
|
147
|
+
if (!got) { if (layer.id && !layer.isMask) missing++; continue; }
|
|
148
|
+
for (const [group, expected, actual, tol] of checks(layer, got)) {
|
|
149
|
+
const ok = typeof expected === 'number' ? Math.abs(expected - actual) <= tol : expected === actual;
|
|
150
|
+
const g = byGroup.get(group) || { pass: 0, total: 0 };
|
|
151
|
+
g.total++; if (ok) g.pass++;
|
|
152
|
+
byGroup.set(group, g);
|
|
153
|
+
if (!ok) misses.push({ group, layer: `${layer.name} (${layer.id})`, expected, actual: typeof actual === 'number' ? Math.round(actual * 100) / 100 : actual });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { byGroup, misses, missing, layers: layers.length };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { chrome, port } = await launchChrome();
|
|
160
|
+
let worst = 100;
|
|
161
|
+
try {
|
|
162
|
+
const page = await cdpPage(port);
|
|
163
|
+
await page.send('Page.enable');
|
|
164
|
+
for (const file of files) {
|
|
165
|
+
const { byGroup, misses, missing, layers } = await measure(page, file);
|
|
166
|
+
const pass = [...byGroup.values()].reduce((a, g) => a + g.pass, 0);
|
|
167
|
+
const total = [...byGroup.values()].reduce((a, g) => a + g.total, 0);
|
|
168
|
+
const pct = total ? (pass * 100) / total : 0;
|
|
169
|
+
worst = Math.min(worst, pct);
|
|
170
|
+
console.log(`\n${file}: ${pct.toFixed(1)}% of ${total} checks match (${layers} layers${missing ? `, ${missing} not rendered` : ''})`);
|
|
171
|
+
for (const [group, g] of [...byGroup].sort()) console.log(` ${group.padEnd(11)} ${String(g.pass).padStart(4)}/${String(g.total).padEnd(4)} ${((g.pass * 100) / g.total).toFixed(1)}%`);
|
|
172
|
+
for (const m of showAll ? misses : misses.slice(0, 12)) console.log(` ✗ ${m.group} ${m.layer}: expected ${m.expected}, got ${m.actual}`);
|
|
173
|
+
if (!showAll && misses.length > 12) console.log(` … ${misses.length - 12} more`);
|
|
174
|
+
}
|
|
175
|
+
page.close();
|
|
176
|
+
} finally {
|
|
177
|
+
chrome.kill();
|
|
178
|
+
}
|
|
179
|
+
if (min != null && worst < min) {
|
|
180
|
+
console.error(`\nAccuracy ${worst.toFixed(1)}% is below --min ${min}%`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
@@ -54,6 +54,17 @@ test('compact tree and scan preserve mixed typography instead of using the first
|
|
|
54
54
|
assert.equal(r.resolveTextStyle(node).fontWeight, undefined);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test('text list formatting is reported only when every paragraph shares it', () => {
|
|
58
|
+
const r = runtime({ root: { id: '0:0' }, currentPage: { id: '0:1' } });
|
|
59
|
+
vm.runInContext(readFileSync('plugin-src/read-helpers.js', 'utf8'), r);
|
|
60
|
+
const text = lists => ({ type: 'TEXT', characters: 'Account', fontSize: 16, fontName: { family: 'Lato', style: 'Regular' },
|
|
61
|
+
getStyledTextSegments: () => lists.map(type => ({ listOptions: { type } })) });
|
|
62
|
+
assert.equal(r.resolveTextStyle(text(['UNORDERED'])).listType, 'UNORDERED');
|
|
63
|
+
assert.equal(r.resolveTextStyle(text(['ORDERED', 'ORDERED'])).listType, 'ORDERED');
|
|
64
|
+
assert.equal(r.resolveTextStyle(text(['UNORDERED', 'NONE'])).listType, undefined);
|
|
65
|
+
assert.equal(r.resolveTextStyle(text(['NONE'])).listType, undefined);
|
|
66
|
+
});
|
|
67
|
+
|
|
57
68
|
test('scan_design expands instances by default and flags opaque ones as incomplete', async () => {
|
|
58
69
|
const text = { id: '1:3', name: 'Label', type: 'TEXT', characters: 'Hi', fontSize: 12, fontName: { family: 'Inter', style: 'Regular' }, visible: true };
|
|
59
70
|
const inst = { id: '1:2', name: 'Button', type: 'INSTANCE', visible: true, children: [text], getMainComponentAsync: async () => null };
|
|
@@ -278,21 +289,33 @@ test('setup command prints scriptable choices when no terminal is attached', ()
|
|
|
278
289
|
test('agent setup detects installed clients and preserves existing JSONC and TOML config', () => {
|
|
279
290
|
const home = mkdtempSync(path.join(os.tmpdir(), 'figma-rust-mcp-'));
|
|
280
291
|
try {
|
|
281
|
-
const
|
|
282
|
-
|
|
292
|
+
const bin = path.join(home, 'bin');
|
|
293
|
+
mkdirSync(bin);
|
|
294
|
+
writeFileSync(path.join(bin, 'gemini'), '');
|
|
295
|
+
const env = { PATH: bin };
|
|
296
|
+
for (const dir of ['.codex', '.claude', '.antigravity-ide', '.cursor', '.codeium/windsurf', '.config/Code', '.copilot', '.config/opencode', '.config/zed']) {
|
|
283
297
|
mkdirSync(path.join(home, dir), { recursive: true });
|
|
284
298
|
}
|
|
285
299
|
const agents = detectAgents({ home, env, platform: 'linux' });
|
|
286
|
-
assert.deepEqual(agents.map(agent => agent.id), ['codex', 'claude', 'antigravity', 'cursor', 'windsurf', 'vscode', 'zed']);
|
|
287
|
-
|
|
288
|
-
|
|
300
|
+
assert.deepEqual(agents.map(agent => agent.id), ['codex', 'claude', 'antigravity', 'cursor', 'windsurf', 'vscode', 'copilot', 'gemini', 'opencode', 'zed']);
|
|
301
|
+
|
|
302
|
+
const expected = {
|
|
303
|
+
claude: ['mcpServers', 'url', 'http://127.0.0.1:41730/mcp'],
|
|
304
|
+
antigravity: ['mcpServers', 'serverUrl', 'http://127.0.0.1:41730/sse'],
|
|
305
|
+
windsurf: ['mcpServers', 'serverUrl', 'http://127.0.0.1:41730/mcp'],
|
|
306
|
+
vscode: ['servers', 'url', 'http://127.0.0.1:41730/mcp'],
|
|
307
|
+
copilot: ['mcpServers', 'url', 'http://127.0.0.1:41730/mcp'],
|
|
308
|
+
gemini: ['mcpServers', 'httpUrl', 'http://127.0.0.1:41730/mcp'],
|
|
309
|
+
opencode: ['mcp', 'url', 'http://127.0.0.1:41730/mcp'],
|
|
310
|
+
};
|
|
311
|
+
for (const [id, [rootKey, key, value]] of Object.entries(expected)) {
|
|
289
312
|
const agent = agents.find(candidate => candidate.id === id);
|
|
290
|
-
configureAgent(agent);
|
|
291
|
-
const config = parseJsonc(readFileSync(agent.configPath, 'utf8'))
|
|
292
|
-
assert.
|
|
293
|
-
|
|
294
|
-
else assert.equal(config.url, 'http://127.0.0.1:41730/mcp');
|
|
313
|
+
assert.equal(configureAgent(agent), true);
|
|
314
|
+
const config = parseJsonc(readFileSync(agent.configPath, 'utf8'))[rootKey]['figma-rust-mcp'];
|
|
315
|
+
assert.equal(config[key], value);
|
|
316
|
+
assert.equal(configureAgent(agent), false);
|
|
295
317
|
}
|
|
318
|
+
assert.equal(agents.find(agent => agent.id === 'vscode').configPath, path.join(home, '.config', 'Code', 'User', 'mcp.json'));
|
|
296
319
|
|
|
297
320
|
const cursor = agents.find(agent => agent.id === 'cursor');
|
|
298
321
|
writeFileSync(cursor.configPath, '{\n // keep this note\n "mcpServers": { "other": { "url": "http://example.test" } }\n}\n');
|