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 +23 -14
- package/package.json +3 -2
- package/scripts/codegen-accuracy.mjs +182 -0
- package/scripts/test-plugin-tokens.mjs +11 -0
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.
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figma-rust-mcp",
|
|
3
|
-
"version": "
|
|
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 };
|