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 +1 -1
- package/scripts/test-plugin-tokens.mjs +117 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figma-rust-mcp",
|
|
3
|
-
"version": "
|
|
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
|
|
164
|
+
test('multiple paints: Figma bottom→top becomes CSS top-first; top-most solid wins', () => {
|
|
165
165
|
const r = runtime();
|
|
166
|
-
const
|
|
167
|
-
const css = r.paintsToCss(
|
|
168
|
-
assert.match(css.value, /^linear-gradient\(
|
|
169
|
-
assert.match(css.value, /linear-gradient\(
|
|
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
|
+
});
|