figma-rust-mcp 5.1.1 → 6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-rust-mcp",
3
- "version": "5.1.1",
3
+ "version": "6.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",
@@ -10,7 +10,7 @@ import { configureAgent, detectAgents } from '../bin/agent-config.js';
10
10
  import { parse as parseJsonc } from 'jsonc-parser';
11
11
 
12
12
  function runtime(figma = {}) {
13
- const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
13
+ const context = vm.createContext({ figma, handlers: {}, console, setTimeout, clearTimeout, Map, Set, Uint8Array });
14
14
  for (const file of ['utils', 'token-helpers', 'read-helpers', 'handlers-read-detail', 'handlers-read', 'handlers-tokens', 'handlers-write-ops', 'node-reader']) {
15
15
  vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
16
16
  }
@@ -161,12 +161,13 @@ test('export_node validates format and works through batch and aliases', async (
161
161
  assert.match(batch.results[1].error, /supports PNG/);
162
162
  });
163
163
 
164
- test('multiple paints preserve top-first ordering and node opacity stays separate', () => {
164
+ test('multiple paints: Figma bottom→top becomes CSS top-first; top-most solid wins', () => {
165
165
  const r = runtime();
166
- const paints = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .5 }, { type: 'SOLID', color: { r: 0, g: 0, b: 1 }, opacity: 1 }].map(r.serializePaint);
167
- const css = r.paintsToCss(paints, 200, 100);
168
- assert.match(css.value, /^linear-gradient\(rgba\(255, 0, 0, 0.5\)/);
169
- assert.match(css.value, /linear-gradient\(#0000ff, #0000ff\)$/);
166
+ const raw = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .5 }, { type: 'SOLID', color: { r: 0, g: 0, b: 1 }, opacity: 1 }];
167
+ const css = r.paintsToCss(raw.map(r.serializePaint), 200, 100);
168
+ assert.match(css.value, /^linear-gradient\(#0000ff, #0000ff\)/);
169
+ assert.match(css.value, /linear-gradient\(rgba\(255, 0, 0, 0.5\), rgba\(255, 0, 0, 0.5\)\)$/);
170
+ assert.equal(r.firstSolidHex(raw), '#0000ff');
170
171
  });
171
172
 
172
173
  test('UI badge uses release version initially and follows the server on reconnect', () => {
@@ -600,6 +601,32 @@ test('plugin tab IDs differ for the same document and request replay does not wr
600
601
  assert.ok(messages.filter(msg => msg.id).every(msg => msg.sessionId === first.currentSessionId));
601
602
  });
602
603
 
604
+ test('a runtime hash change hot-reloads once, not on every later server-hello', () => {
605
+ const html = readFileSync('plugin-runtime/ui.html', 'utf8');
606
+ const source = html.slice(html.indexOf(' function connectWs()'), html.indexOf(' async function startLongPoll()'));
607
+ const sockets = [];
608
+ let reloads = 0;
609
+ class Socket {
610
+ static OPEN = 1;
611
+ constructor(url) { this.url = url; this.readyState = 1; sockets.push(this); }
612
+ close() {} send() {}
613
+ }
614
+ const r = vm.createContext({
615
+ uiDisposed: false, ws: null, wsConnected: false, sessionId: 'tab-a', fileName: 'File', documentId: 'doc', BRIDGE: 'http://localhost:41730',
616
+ WebSocket: Socket, setInterval: () => 1, clearInterval: () => {}, setTimeout: () => { reloads++; return 1; }, clearTimeout: () => {},
617
+ sendRuntimeCapabilities: () => {}, consecutiveErrors: 0, everConnected: false, retryTimer: null, polling: true,
618
+ setStatus: () => {}, log: () => {}, currentPort: 41730, READ_OPS: [], updateRuntimeVersion: () => {},
619
+ activeIndexScan: null, startLongPoll: () => {}, dispatchToMain: () => {}, window: {},
620
+ });
621
+ vm.runInContext(source, r);
622
+ r.connectWs(); sockets[0].onopen();
623
+ const hello = hash => sockets[0].onmessage({ data: JSON.stringify({ type: 'server-hello', version: '5.1.0', runtimeHash: hash }) });
624
+ hello('a'); assert.equal(reloads, 0);
625
+ hello('b'); assert.equal(reloads, 1);
626
+ // window survives the reload's document.write; the same server must not reload it again.
627
+ hello('b'); hello('b'); assert.equal(reloads, 1);
628
+ });
629
+
603
630
  test('old UI socket callbacks cannot clear or dispatch through a replacement socket', () => {
604
631
  const html = readFileSync('plugin-runtime/ui.html', 'utf8');
605
632
  const source = html.slice(html.indexOf(' function connectWs()'), html.indexOf(' async function startLongPoll()'));
@@ -634,7 +661,7 @@ test('tree reads preserve Regular font weight, collect Regular font tokens, and
634
661
  getLocalTextStylesAsync: async () => [{ id: 'S:text-1', name: 'Lato/18px/regular' }],
635
662
  getLocalEffectStylesAsync: async () => [],
636
663
  };
637
- const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
664
+ const context = vm.createContext({ figma, handlers: {}, console, setTimeout, clearTimeout, Map, Set, Uint8Array });
638
665
  for (const file of ['utils', 'svg-path-helpers', 'paint-and-effects', 'token-helpers', 'read-helpers', 'handlers-read-detail', 'handlers-read']) {
639
666
  vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
640
667
  }
@@ -832,3 +859,86 @@ test('byte-bounded reads paginate without dropping unicode, oversized nodes or d
832
859
  assert.equal(all[5].content,'漢'.repeat(2000));
833
860
  await assert.rejects(r.handlers.read_nodes({id:'frame',maxBytes:0}),/maxBytes/);
834
861
  });
862
+
863
+ test('letter spacing PERCENT converts to px; line-height percent keeps decimals', () => {
864
+ const r = runtime();
865
+ const node = { id: '1:1', type: 'TEXT', characters: 'Hi', fontSize: 20, fontName: { family: 'Inter', style: 'Regular' },
866
+ letterSpacing: { unit: 'PERCENT', value: -2.5 }, lineHeight: { unit: 'PERCENT', value: 137.456 } };
867
+ const style = r.resolveTextStyle(node);
868
+ assert.equal(style.letterSpacing, -0.5);
869
+ assert.equal(style.lineHeight, '137.46%');
870
+ assert.equal(r.resolveTextStyle({ ...node, letterSpacing: { unit: 'PIXELS', value: 1.2 } }).letterSpacing, 1.2);
871
+ });
872
+
873
+ test('blur effects emit half the Figma radius', () => {
874
+ const r = runtime();
875
+ const css = r.effectsToCss([{ type: 'LAYER_BLUR', radius: 8 }, { type: 'BACKGROUND_BLUR', radius: 20 }]);
876
+ assert.equal(css.filter, 'blur(4px)');
877
+ assert.equal(css.backdropFilter, 'blur(10px)');
878
+ });
879
+
880
+ test('findNodeByIdAsync returns null for a missing instance sublayer, not its parent instance', async () => {
881
+ const instance = { id: '2715:40862', type: 'INSTANCE' };
882
+ const r = runtime({ root: { id: '0:0' }, currentPage: { id: '0:1', selection: [] },
883
+ getNodeByIdAsync: async id => (id === instance.id ? instance : null) });
884
+ assert.equal(await r.findNodeByIdAsync('I2715:40862;123:456'), null);
885
+ assert.equal(await r.findNodeByIdAsync('2715:40862'), instance);
886
+ });
887
+
888
+ test('bound variables resolve in the node mode and report library aliases', async () => {
889
+ const cols = [{ id: 'sem', defaultModeId: 'light' }, { id: 'lib', defaultModeId: 'l1' }];
890
+ const local = [{ id: 'bg', name: 'bg/surface', variableCollectionId: 'sem', resolvedType: 'COLOR',
891
+ valuesByMode: { light: { type: 'VARIABLE_ALIAS', id: 'white' }, dark: { type: 'VARIABLE_ALIAS', id: 'black' } } }];
892
+ const library = [
893
+ { id: 'white', name: 'gray/0', variableCollectionId: 'lib', resolvedType: 'COLOR', valuesByMode: { l1: { r: 1, g: 1, b: 1, a: 1 } } },
894
+ { id: 'black', name: 'gray/900', variableCollectionId: 'lib', resolvedType: 'COLOR', valuesByMode: { l1: { r: 0, g: 0, b: 0, a: 1 } } },
895
+ ];
896
+ const r = runtime({ variables: {
897
+ getLocalVariablesAsync: async () => local, getLocalVariableCollectionsAsync: async () => cols.slice(0, 1),
898
+ getVariableByIdAsync: async id => library.find(v => v.id === id) || local.find(v => v.id === id),
899
+ getVariableCollectionByIdAsync: async id => cols.find(c => c.id === id),
900
+ } });
901
+ const node = { id: '1:1', name: 'Card', type: 'RECTANGLE', width: 10, height: 10, x: 0, y: 0,
902
+ fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }], boundVariables: { fills: [{ id: 'bg' }] },
903
+ resolvedVariableModes: { sem: 'dark' } };
904
+ const map = await r.buildVariableResolverMapAsync(true);
905
+ const walkState = { variableMap: map, remaining: 10, budget: 10 };
906
+ const tree = r.extractDesignTree(node, 0, 5, 'full', true, null, null, walkState);
907
+ await r.resolvePendingVariablesAsync(walkState);
908
+ const entry = plain(tree.boundVariables.fills[0]);
909
+ assert.equal(entry.name, 'bg/surface');
910
+ assert.equal(entry.aliasTarget, 'gray/900');
911
+ assert.equal(entry.value, '#000000');
912
+ assert.equal(tree.fillToken, 'bg/surface');
913
+ // Second walk: library variables are cached, resolved synchronously.
914
+ const light = r.extractDesignTree({ ...node, resolvedVariableModes: { sem: 'light' } }, 0, 5, 'full', true, null, null, { variableMap: map, remaining: 10, budget: 10 });
915
+ assert.equal(light.boundVariables.fills[0].value, '#ffffff');
916
+ assert.equal(light.boundVariables.fills[0].aliasTarget, 'gray/0');
917
+ });
918
+
919
+ test('resolveVariableValueAsync reports alias names for node consumers', async () => {
920
+ const cols = [{ id: 'sem', defaultModeId: 'light' }, { id: 'prim', defaultModeId: 'p' }];
921
+ const vars = [
922
+ { id: 'a', name: 'text/primary', variableCollectionId: 'sem', resolvedType: 'COLOR', valuesByMode: { light: { type: 'VARIABLE_ALIAS', id: 'b' }, dark: { type: 'VARIABLE_ALIAS', id: 'c' } },
923
+ resolveForConsumer: () => ({ value: { r: 1, g: 1, b: 1, a: 1 } }) },
924
+ { id: 'b', name: 'gray/900', variableCollectionId: 'prim', resolvedType: 'COLOR', valuesByMode: { p: { r: 0, g: 0, b: 0, a: 1 } } },
925
+ { id: 'c', name: 'gray/0', variableCollectionId: 'prim', resolvedType: 'COLOR', valuesByMode: { p: { r: 1, g: 1, b: 1, a: 1 } } },
926
+ ];
927
+ const r = runtime({ variables: { getVariableByIdAsync: async id => vars.find(v => v.id === id), getVariableCollectionByIdAsync: async id => cols.find(c => c.id === id) } });
928
+ const res = await r.resolveVariableValueAsync(vars[0], { resolvedVariableModes: { sem: 'dark', prim: 'p' } });
929
+ assert.equal(res.type, 'ALIAS');
930
+ assert.equal(res.primitiveName, 'gray/0');
931
+ assert.equal(res.hex, '#ffffff');
932
+ });
933
+
934
+ test('get_variables finishes when Figma never answers a library alias lookup', async () => {
935
+ const sem = { id: 'V:2', name: 'bg/primary', resolvedType: 'COLOR', variableCollectionId: 'C:2',
936
+ valuesByMode: { l: { type: 'VARIABLE_ALIAS', id: 'VariableID:lib/1' } }, scopes: [] };
937
+ const col = { id: 'C:2', name: 'Sem', defaultModeId: 'l', modes: [{ modeId: 'l', name: 'Light' }], variableIds: ['V:2'] };
938
+ const r = runtime({ variables: {
939
+ getLocalVariableCollectionsAsync: async () => [col], getLocalVariablesAsync: async () => [sem],
940
+ getVariableByIdAsync: () => new Promise(() => {}), getVariableCollectionByIdAsync: async () => col } });
941
+ const out = plain(await r.handlers.get_variables());
942
+ assert.equal(out.collections[0].variables[0].values.l.resolvedValue, null);
943
+ assert.ok(out.diagnostics.some(d => /did not answer variable VariableID:lib\/1/.test(d.message)), JSON.stringify(out.diagnostics));
944
+ });