figma-rust-mcp 7.0.0 → 8.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/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**: Compact/minimal tree responses share identical style bundles when the complete JSON becomes smaller. Node IDs, hierarchy, text, geometry and state differences remain intact. Full detail bypasses Rust compression; no heuristic merging of screens or repeated items.
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.
@@ -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
- Compact/minimal `get_selection` and `get_design` responses may contain a root
231
- `_compression` object with `version: 1` and a `styles` dictionary. For each node
232
- with `styleRef`, merge `_compression.styles[node.styleRef]` into that node and
233
- remove `styleRef`; remove root `_compression` to reconstruct the plugin payload
234
- exactly. Style references are dictionary lookups, not inherited CSS. Payloads
235
- with reserved-field collisions or no net byte savings stay inline. `detail: "full"` bypasses this Rust transformation and queries the plugin rather than a
236
- compact selection cache. Plugin detail, visibility, precision, depth and node
237
- budgets still determine the source payload; compression cannot restore fields
238
- omitted there. Code generation continues to consume inline styles.
239
-
240
- No token-reduction or AI-latency percentage is claimed without a tokenizer and
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 (first variant) in `componentDir`. 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`.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-rust-mcp",
3
- "version": "7.0.0",
3
+ "version": "8.0.0",
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 };