@grampro/kit 2.4.3 → 2.5.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.
Files changed (73) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/README.md +12 -0
  3. package/index.cjs +44 -2
  4. package/package.json +1 -1
  5. package/source/components/accordion/passport.json +11 -7
  6. package/source/components/accordion/react/Accordion.tsx +5 -0
  7. package/source/components/alert/passport.json +3 -3
  8. package/source/components/avatar/passport.json +8 -6
  9. package/source/components/avatar/react/Avatar.tsx +4 -0
  10. package/source/components/badge/passport.json +8 -6
  11. package/source/components/badge/react/Badge.tsx +1 -0
  12. package/source/components/badge/react/Tag.tsx +1 -0
  13. package/source/components/breadcrumb/passport.json +8 -6
  14. package/source/components/breadcrumb/react/Breadcrumb.tsx +1 -0
  15. package/source/components/button/passport.json +8 -6
  16. package/source/components/button/react/Button.tsx +1 -0
  17. package/source/components/card/passport.json +17 -9
  18. package/source/components/card/react/Card.tsx +5 -0
  19. package/source/components/card/react/Stat.tsx +5 -0
  20. package/source/components/checkbox/passport.json +32 -14
  21. package/source/components/checkbox/react/Checkbox.tsx +17 -0
  22. package/source/components/checkbox/react/CheckboxGroup.tsx +6 -0
  23. package/source/components/combobox/passport.json +36 -16
  24. package/source/components/combobox/react/Select.tsx +6 -1
  25. package/source/components/combobox/react/props.ts +23 -0
  26. package/source/components/data-grid/core/types.ts +53 -0
  27. package/source/components/data-grid/passport.json +82 -30
  28. package/source/components/data-grid/react/AskGrid.tsx +10 -0
  29. package/source/components/data-grid/react/DataGrid.tsx +16 -0
  30. package/source/components/date-picker/passport.json +30 -14
  31. package/source/components/date-picker/react/DatePicker.tsx +5 -0
  32. package/source/components/date-picker/react/props.ts +13 -1
  33. package/source/components/dialog/core/types.ts +14 -0
  34. package/source/components/dialog/passport.json +26 -12
  35. package/source/components/dialog/react/Dialog.tsx +4 -0
  36. package/source/components/file-uploader/passport.json +44 -18
  37. package/source/components/file-uploader/react/FileUploader.tsx +39 -0
  38. package/source/components/input/passport.json +14 -8
  39. package/source/components/input/react/Input.tsx +3 -0
  40. package/source/components/menu/passport.json +29 -13
  41. package/source/components/menu/react/Menu.tsx +7 -0
  42. package/source/components/menu/react/items.tsx +4 -0
  43. package/source/components/modal/passport.json +14 -8
  44. package/source/components/modal/react/Modal.tsx +6 -0
  45. package/source/components/number-input/passport.json +23 -11
  46. package/source/components/number-input/react/NumberInput.tsx +13 -0
  47. package/source/components/popover/passport.json +17 -9
  48. package/source/components/popover/react/Popover.tsx +7 -0
  49. package/source/components/progress/passport.json +3 -3
  50. package/source/components/radio-group/passport.json +32 -14
  51. package/source/components/radio-group/react/Radio.tsx +17 -0
  52. package/source/components/radio-group/react/RadioGroup.tsx +13 -0
  53. package/source/components/skeleton/passport.json +8 -6
  54. package/source/components/skeleton/react/Skeleton.tsx +5 -0
  55. package/source/components/spinner/passport.json +3 -3
  56. package/source/components/switch/passport.json +23 -11
  57. package/source/components/switch/react/Switch.tsx +14 -0
  58. package/source/components/tabs/passport.json +11 -7
  59. package/source/components/tabs/react/Tabs.tsx +2 -0
  60. package/source/components/textarea/passport.json +14 -8
  61. package/source/components/textarea/react/Textarea.tsx +3 -0
  62. package/source/components/toaster/passport.json +8 -6
  63. package/source/components/toaster/react/Toaster.tsx +4 -0
  64. package/source/components/tooltip/passport.json +3 -3
  65. package/tools/mcp/README.md +224 -0
  66. package/tools/mcp/figma.cjs +445 -0
  67. package/tools/mcp/install.cjs +270 -0
  68. package/tools/mcp/secret-cli.cjs +174 -0
  69. package/tools/mcp/secret.cjs +289 -0
  70. package/tools/mcp/server.cjs +497 -0
  71. package/tools/passport/cli.cjs +8 -1
  72. package/tools/passport/extract.cjs +7 -3
  73. package/tools/passport/merge.cjs +24 -4
@@ -0,0 +1,445 @@
1
+ /*
2
+ * Read a Figma design through the REST API and condense it to what a coding
3
+ * agent can act on.
4
+ *
5
+ * Written against a real response rather than the documentation: the field
6
+ * names here were confirmed by fetching a live file, and they match the
7
+ * plugin API's scene graph exactly — `layoutMode`, `itemSpacing`, `padding*`,
8
+ * `cornerRadius`, `fills`, `characters`, `primaryAxisAlignItems`.
9
+ *
10
+ * What it deliberately does not do is decide which component each region is.
11
+ * A design drawn without the component library has no `componentId` to look
12
+ * up, so that call is inference — and the agent, holding the catalogue and
13
+ * the designer's own names for things, infers better than string matching in
14
+ * a tool. This supplies structure, names, text and tokens; the agent decides.
15
+ */
16
+ "use strict";
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+
21
+ /** Figma's own limit is generous; a frame beyond this is unreadable anyway. */
22
+ const MAX_DEPTH = 10;
23
+
24
+ // ------------------------------------------------------------------ token
25
+
26
+ /**
27
+ * The token, from the environment, the OS secret store, or a `.env`.
28
+ *
29
+ * The store is where it belongs and `.env` is kept only so that anyone who
30
+ * set it up that way is not broken by an upgrade. Reading from a `.env` says
31
+ * so once, on stderr — stdout carries JSON-RPC and nothing else may touch it.
32
+ */
33
+ let warnedAboutDotenv = false;
34
+
35
+ function figmaToken() {
36
+ if (process.env.FIGMA_TOKEN) return process.env.FIGMA_TOKEN.trim();
37
+
38
+ const stored = require("./secret.cjs").getSecret("figma");
39
+ if (stored) return stored;
40
+
41
+ for (const dir of [process.cwd(), path.join(__dirname, "..", "..")]) {
42
+ const file = path.join(dir, ".env");
43
+ if (!fs.existsSync(file)) continue;
44
+ const match = fs.readFileSync(file, "utf8").match(/^\s*FIGMA_TOKEN\s*=\s*(.+)$/m);
45
+ if (!match) continue;
46
+ if (!warnedAboutDotenv) {
47
+ warnedAboutDotenv = true;
48
+ process.stderr.write(
49
+ "grampro-kit: read FIGMA_TOKEN from .env. Move it out of the repository with " +
50
+ "`npx @grampro/kit secret set figma`, then delete the line.\n",
51
+ );
52
+ }
53
+ return match[1].trim().replace(/^["']|["']$/g, "");
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** figma.com/design/<key>/<name>?node-id=1-23 → { fileKey, nodeId } */
59
+ function parseFigmaUrl(url) {
60
+ const key = String(url).match(/figma\.com\/(?:file|design|proto)\/([A-Za-z0-9]+)/);
61
+ if (!key) return { error: "That does not look like a Figma file URL." };
62
+ const node = String(url).match(/[?&]node-id=([0-9]+[-:][0-9]+)/);
63
+ return { fileKey: key[1], nodeId: node ? node[1].replace("-", ":") : null };
64
+ }
65
+
66
+ /*
67
+ * A file is fetched once and held briefly.
68
+ *
69
+ * The natural flow is two calls — list the frames, then read one — and a
70
+ * file of any size is hundreds of KB. Fetching it twice in a row is what
71
+ * earns a 429, which is how this cache came to exist.
72
+ */
73
+ const CACHE_MS = 5 * 60 * 1000;
74
+ const cache = new Map();
75
+
76
+ async function figmaGet(pathname, token) {
77
+ const hit = cache.get(pathname);
78
+ if (hit && Date.now() - hit.at < CACHE_MS) return hit.body;
79
+
80
+ const res = await fetch(`https://api.figma.com/v1${pathname}`, {
81
+ headers: { "X-Figma-Token": token },
82
+ });
83
+ if (!res.ok) {
84
+ const body = await res.text();
85
+ if (res.status === 403) {
86
+ throw new Error(
87
+ "Figma refused the token. Check FIGMA_TOKEN has 'File content: read' scope and can see this file.",
88
+ );
89
+ }
90
+ if (res.status === 429) {
91
+ /*
92
+ * Figma's file-read limit is cost-based, and once a token is over it the
93
+ * window is long — tens of minutes, not the "try again shortly" that
94
+ * this message used to promise. The plugin route is the honest advice,
95
+ * because it does not touch the API at all.
96
+ */
97
+ /*
98
+ * Retry-After is meant to be seconds, and Figma has been seen to return
99
+ * 395274 on a file read — four and a half days, or six minutes if the
100
+ * value is milliseconds. Rather than state a figure that may be wrong by
101
+ * three orders of magnitude, the number is passed through as given.
102
+ */
103
+ const after = res.headers.get("retry-after");
104
+ const when = after
105
+ ? `Figma's Retry-After says ${after} (nominally seconds).`
106
+ : "That window is usually tens of minutes, not seconds.";
107
+ throw new Error(
108
+ `Figma is rate limiting this token. ${when} Nothing here can shorten it. ` +
109
+ "To carry on now, use the kit's Figma plugin → 'Export selection for an agent' " +
110
+ "and pass the result as `design` — that route reads the file from inside Figma " +
111
+ "and never calls the API.",
112
+ );
113
+ }
114
+ throw new Error(`Figma returned ${res.status}: ${body.slice(0, 160)}`);
115
+ }
116
+
117
+ const body = await res.json();
118
+ cache.set(pathname, { at: Date.now(), body });
119
+ return body;
120
+ }
121
+
122
+ // ------------------------------------------------------------- condensing
123
+
124
+ const hex = (c) => Math.round(c * 255).toString(16).padStart(2, "0");
125
+
126
+ function solidFill(node) {
127
+ const fill = (node.fills || []).find((f) => f.type === "SOLID" && f.visible !== false);
128
+ return fill ? `#${hex(fill.color.r)}${hex(fill.color.g)}${hex(fill.color.b)}` : undefined;
129
+ }
130
+
131
+ function layoutOf(node) {
132
+ if (!node.layoutMode || node.layoutMode === "NONE") return undefined;
133
+ const pad = [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft];
134
+ return {
135
+ direction: node.layoutMode === "HORIZONTAL" ? "row" : "column",
136
+ gap: node.itemSpacing || undefined,
137
+ padding: pad.some((v) => v) ? pad.map((v) => v || 0) : undefined,
138
+ align: node.counterAxisAlignItems,
139
+ justify: node.primaryAxisAlignItems,
140
+ };
141
+ }
142
+
143
+ /*
144
+ * Text is lifted to the parent when a node's only content is one label.
145
+ * Figma wraps nearly every string in its own frame, and passing that
146
+ * structure through verbatim buries the design in scaffolding.
147
+ */
148
+ function condense(node, depth) {
149
+ const out = { name: node.name, type: node.type };
150
+
151
+ const layout = layoutOf(node);
152
+ if (layout) out.layout = layout;
153
+
154
+ if (node.type === "TEXT" && node.characters && node.characters.trim()) {
155
+ out.text = node.characters.trim();
156
+ if (node.style) {
157
+ out.font = { size: node.style.fontSize, weight: node.style.fontWeight };
158
+ }
159
+ }
160
+
161
+ // An instance is a lookup, when the design was built from a library.
162
+ if (node.type === "INSTANCE" && node.componentId) out.instanceOf = node.componentId;
163
+
164
+ const style = {};
165
+ const fill = solidFill(node);
166
+ if (fill && node.type !== "TEXT") style.fill = fill;
167
+ if (typeof node.cornerRadius === "number" && node.cornerRadius) style.radius = node.cornerRadius;
168
+ if (Object.keys(style).length) out.style = style;
169
+
170
+ if (node.absoluteBoundingBox) {
171
+ out.size = [Math.round(node.absoluteBoundingBox.width), Math.round(node.absoluteBoundingBox.height)];
172
+ }
173
+
174
+ const kids = (node.children || []).filter((c) => c.visible !== false);
175
+ if (kids.length && depth < MAX_DEPTH) {
176
+ const children = kids.map((c) => condense(c, depth + 1));
177
+ // A frame that exists only to hold one text node is noise.
178
+ if (children.length === 1 && children[0].text && !children[0].children && !out.text) {
179
+ out.text = children[0].text;
180
+ out.font = children[0].font;
181
+ } else {
182
+ out.children = children;
183
+ }
184
+ }
185
+
186
+ return out;
187
+ }
188
+
189
+ // ---------------------------------------------------------------- tokens
190
+
191
+ const luminance = (h) => {
192
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
193
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
194
+ };
195
+
196
+ const saturation = (h) => {
197
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
198
+ const max = Math.max(r, g, b);
199
+ const min = Math.min(r, g, b);
200
+ return max === 0 ? 0 : (max - min) / max;
201
+ };
202
+
203
+ /*
204
+ * Colours get roles, not ranks.
205
+ *
206
+ * The most-used colour in a design is almost always the body text, and the
207
+ * accent is used a handful of times on the one button that matters. Sorting
208
+ * by frequency alone would theme the whole library the colour of its
209
+ * paragraphs.
210
+ */
211
+ function themeFrom(colours, radii, fontSizes) {
212
+ const used = [...colours.entries()].sort((a, b) => b[1] - a[1]);
213
+ const theme = [];
214
+ const take = (variable, entry, note) => {
215
+ if (entry) theme.push({ variable, value: entry[0], seenOn: entry[1], why: note });
216
+ };
217
+
218
+ /*
219
+ * A near-black navy reads as highly saturated by the HSV measure — #192435
220
+ * scores 0.53 — so saturation alone picks the body text as the brand
221
+ * colour. Requiring real lightness as well is what separates an accent
222
+ * from a dark neutral.
223
+ */
224
+ const saturated = used.filter(
225
+ ([h]) => saturation(h) > 0.45 && luminance(h) > 0.2 && luminance(h) < 0.8,
226
+ );
227
+ const darkest = used.filter(([h]) => luminance(h) < 0.25).sort((a, b) => luminance(a[0]) - luminance(b[0]));
228
+ const lightest = used.filter(([h]) => luminance(h) > 0.9).sort((a, b) => b[1] - a[1]);
229
+ const mid = used.filter(([h]) => luminance(h) >= 0.25 && luminance(h) <= 0.9 && saturation(h) <= 0.35);
230
+
231
+ take("--gbs-accent", saturated[0], "the most-used saturated colour");
232
+ take("--gbs-fg", darkest[0], "the darkest colour, used for text");
233
+ take("--gbs-bg", lightest[0], "the lightest colour, used for surfaces");
234
+ take("--gbs-muted", mid[0], "a desaturated mid tone");
235
+
236
+ const radius = [...radii.entries()].sort((a, b) => b[1] - a[1])[0];
237
+ if (radius) theme.push({ variable: "--gbs-radius", value: `${radius[0]}px`, seenOn: radius[1] });
238
+
239
+ // The body size is the most-used that a person could actually read.
240
+ const body = [...fontSizes.entries()]
241
+ .filter(([size]) => size >= 12)
242
+ .sort((a, b) => b[1] - a[1])[0];
243
+ if (body) theme.push({ variable: "--gbs-font-size", value: `${body[0]}px`, seenOn: body[1] });
244
+
245
+ return theme;
246
+ }
247
+
248
+ function collectTokens(node, colours, radii, fontSizes) {
249
+ const fill = solidFill(node);
250
+ if (fill) colours.set(fill, (colours.get(fill) || 0) + 1);
251
+ if (typeof node.cornerRadius === "number" && node.cornerRadius) {
252
+ radii.set(node.cornerRadius, (radii.get(node.cornerRadius) || 0) + 1);
253
+ }
254
+ if (node.style && node.style.fontSize) {
255
+ fontSizes.set(node.style.fontSize, (fontSizes.get(node.style.fontSize) || 0) + 1);
256
+ }
257
+ for (const child of node.children || []) collectTokens(child, colours, radii, fontSizes);
258
+ }
259
+
260
+ /** Names the designer repeated are the ones that mean something. */
261
+ function repeatedNames(node, counts) {
262
+ counts.set(node.name, (counts.get(node.name) || 0) + 1);
263
+ for (const child of node.children || []) repeatedNames(child, counts);
264
+ }
265
+
266
+ // ------------------------------------------------------------------- tool
267
+
268
+ /*
269
+ * A selection exported by the plugin, which has already done the condensing
270
+ * inside Figma. The work left is the part that needs no file access: giving
271
+ * the colours roles and counting the names that repeat.
272
+ *
273
+ * This is the path for anyone who would rather not hold an API token, and
274
+ * for files the token cannot reach.
275
+ */
276
+ function fromPluginExport(design) {
277
+ let parsed;
278
+ try {
279
+ parsed = typeof design === "string" ? JSON.parse(design) : design;
280
+ } catch (error) {
281
+ return { error: `That is not JSON: ${error.message}` };
282
+ }
283
+ if (!parsed || !Array.isArray(parsed.nodes)) {
284
+ return {
285
+ error:
286
+ "Expected the output of the Figma plugin's 'Export selection for an agent' command.",
287
+ };
288
+ }
289
+
290
+ const colours = new Map();
291
+ const radii = new Map();
292
+ const fontSizes = new Map();
293
+ const names = new Map();
294
+ const visit = (node) => {
295
+ names.set(node.name, (names.get(node.name) || 0) + 1);
296
+ if (node.style) {
297
+ if (node.style.fill) colours.set(node.style.fill, (colours.get(node.style.fill) || 0) + 1);
298
+ if (node.style.radius) radii.set(node.style.radius, (radii.get(node.style.radius) || 0) + 1);
299
+ if (node.style.fontSize) {
300
+ fontSizes.set(node.style.fontSize, (fontSizes.get(node.style.fontSize) || 0) + 1);
301
+ }
302
+ }
303
+ for (const child of node.children || []) visit(child);
304
+ };
305
+ for (const node of parsed.nodes) visit(node);
306
+
307
+ const instances = [];
308
+ const findInstances = (n) => {
309
+ if (n.component) instances.push(n.component);
310
+ for (const c of n.children || []) findInstances(c);
311
+ };
312
+ for (const node of parsed.nodes) findInstances(node);
313
+
314
+ return {
315
+ source: "figma plugin export",
316
+ frame: parsed.frame,
317
+ theme: themeFrom(colours, radii, fontSizes),
318
+ repeatedRegions: [...names.entries()]
319
+ .filter(([, n]) => n > 2)
320
+ .sort((a, b) => b[1] - a[1])
321
+ .slice(0, 20)
322
+ .map(([name, n]) => ({ name, times: n })),
323
+ builtFromLibrary: instances.length > 0,
324
+ componentsNamed: [...new Set(instances)],
325
+ tree: parsed.nodes,
326
+ };
327
+ }
328
+
329
+ async function figmaFrame({ url, design, frame, depth }) {
330
+ // The export needs no token, so it is offered before one is demanded.
331
+ if (design) return fromPluginExport(design);
332
+
333
+ const token = figmaToken();
334
+ if (!token) {
335
+ return {
336
+ error:
337
+ "No Figma token. Two ways forward, and the first needs no credentials: " +
338
+ "run the kit's Figma plugin → 'Export selection for an agent' and pass the result as `design`. " +
339
+ "Otherwise create a token at Figma → Settings → Security → Personal access tokens " +
340
+ "(scope: File content, read) and store it with `npx @grampro/kit secret set figma`.",
341
+ };
342
+ }
343
+
344
+ const { fileKey, nodeId, error } = parseFigmaUrl(url);
345
+ if (error) return { error };
346
+
347
+ /*
348
+ * Three shapes of request, cheapest first.
349
+ *
350
+ * A URL carrying a node-id — which is what Figma gives you when you copy a
351
+ * link to a frame — is one targeted call and nothing else. Otherwise the
352
+ * frames are listed with depth=1, which returns the outline of the document
353
+ * rather than the document, and the chosen frame is then fetched by id.
354
+ * Figma's limits are cost-based and the cost is the payload, so pulling the
355
+ * whole file to answer either question is what earns a 429.
356
+ */
357
+ let file;
358
+ let roots;
359
+ try {
360
+ if (nodeId) {
361
+ file = await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`, token);
362
+ roots = Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document);
363
+ } else {
364
+ const outline = await figmaGet(`/files/${fileKey}?depth=1`, token);
365
+ const tops = (outline.document.children || []).flatMap((page) => page.children || []);
366
+
367
+ if (!frame) {
368
+ return {
369
+ file: outline.name,
370
+ frames: tops.map((f) => ({
371
+ name: f.name,
372
+ id: f.id,
373
+ size: f.absoluteBoundingBox
374
+ ? [Math.round(f.absoluteBoundingBox.width), Math.round(f.absoluteBoundingBox.height)]
375
+ : undefined,
376
+ })),
377
+ next: "Call again with `frame` set to the one you want to build. Passing a URL that already carries a node-id skips this step.",
378
+ };
379
+ }
380
+
381
+ const match = tops.find((f) => f.name.toLowerCase() === String(frame).toLowerCase());
382
+ if (!match) {
383
+ return { error: `No frame called "${frame}".`, frames: tops.map((f) => f.name) };
384
+ }
385
+ file = await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(match.id)}`, token);
386
+ roots = Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document);
387
+ file.name = outline.name;
388
+ }
389
+ } catch (e) {
390
+ return { error: e.message };
391
+ }
392
+
393
+ if (!roots || roots.length === 0) return { error: "Nothing to read at that URL." };
394
+
395
+ /*
396
+ * Without a frame named, the whole file would arrive at once — hundreds of
397
+ * KB that buries the screen the agent was asked about. So the first call
398
+ * lists what is there and the second asks for one.
399
+ */
400
+ const wanted = frame
401
+ ? roots.filter((r) => r.name.toLowerCase() === String(frame).toLowerCase())
402
+ : roots;
403
+
404
+ if (wanted.length === 0) {
405
+ return { error: `No frame called "${frame}".`, frames: roots.map((r) => r.name) };
406
+ }
407
+
408
+ const target = wanted[0];
409
+ const colours = new Map();
410
+ const radii = new Map();
411
+ const fontSizes = new Map();
412
+ const names = new Map();
413
+ collectTokens(target, colours, radii, fontSizes);
414
+ repeatedNames(target, names);
415
+
416
+ const limit = Math.min(Number(depth) || MAX_DEPTH, MAX_DEPTH);
417
+ const tree = condense(target, MAX_DEPTH - limit);
418
+
419
+ const instances = [];
420
+ const findInstances = (n) => {
421
+ if (n.instanceOf) instances.push(n.name);
422
+ for (const c of n.children || []) findInstances(c);
423
+ };
424
+ findInstances(tree);
425
+
426
+ return {
427
+ file: file.name,
428
+ frame: target.name,
429
+ size: tree.size,
430
+ theme: themeFrom(colours, radii, fontSizes),
431
+ /* Repetition is the signal: eight "Status cell" nodes are a column. */
432
+ repeatedRegions: [...names.entries()]
433
+ .filter(([, n]) => n > 2)
434
+ .sort((a, b) => b[1] - a[1])
435
+ .slice(0, 20)
436
+ .map(([name, n]) => ({ name, times: n })),
437
+ builtFromLibrary: instances.length > 0,
438
+ tree,
439
+ howToUse: instances.length
440
+ ? "This design uses component instances; `instanceOf` names the master for each."
441
+ : "This design was drawn without the component library, so nothing names a component directly. Use the region names and structure to decide which catalogue component each part is, then call get_component before writing it.",
442
+ };
443
+ }
444
+
445
+ module.exports = { figmaFrame, parseFigmaUrl, themeFrom };