@grampro/kit 2.4.2 → 2.4.4

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 (35) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +12 -0
  3. package/index.cjs +8 -1
  4. package/package.json +1 -1
  5. package/source/components/accordion/passport.json +2 -2
  6. package/source/components/alert/passport.json +2 -2
  7. package/source/components/avatar/passport.json +2 -2
  8. package/source/components/badge/passport.json +2 -2
  9. package/source/components/breadcrumb/passport.json +2 -2
  10. package/source/components/button/passport.json +2 -2
  11. package/source/components/card/passport.json +2 -2
  12. package/source/components/checkbox/passport.json +2 -2
  13. package/source/components/combobox/passport.json +2 -2
  14. package/source/components/data-grid/passport.json +2 -2
  15. package/source/components/date-picker/passport.json +2 -2
  16. package/source/components/dialog/passport.json +2 -2
  17. package/source/components/file-uploader/passport.json +2 -2
  18. package/source/components/input/passport.json +2 -2
  19. package/source/components/menu/passport.json +2 -2
  20. package/source/components/modal/passport.json +2 -2
  21. package/source/components/number-input/passport.json +2 -2
  22. package/source/components/popover/passport.json +2 -2
  23. package/source/components/progress/passport.json +2 -2
  24. package/source/components/radio-group/passport.json +2 -2
  25. package/source/components/skeleton/passport.json +2 -2
  26. package/source/components/spinner/passport.json +2 -2
  27. package/source/components/switch/passport.json +2 -2
  28. package/source/components/tabs/passport.json +2 -2
  29. package/source/components/textarea/passport.json +2 -2
  30. package/source/components/toaster/passport.json +2 -2
  31. package/source/components/tooltip/passport.json +2 -2
  32. package/tools/mcp/README.md +132 -0
  33. package/tools/mcp/figma.cjs +388 -0
  34. package/tools/mcp/server.cjs +458 -0
  35. package/tools/passport/extract.cjs +7 -1
@@ -0,0 +1,132 @@
1
+ # GramproKit MCP server — beta
2
+
3
+ Serves the component catalogue to a coding agent, so it writes against the
4
+ real contract instead of recalling one.
5
+
6
+ > **Beta.** The tools work and are tested, but their names and shapes may
7
+ > change before this settles. The Figma token handling in particular is not
8
+ > where it should be — see *Credentials* below.
9
+
10
+ ```bash
11
+ claude mcp add grampro -- npx -y @grampro/kit mcp
12
+ ```
13
+
14
+ Codex, Cursor and anything else that speaks MCP take the same command; it is
15
+ a stdio server with no configuration. Without the CLI, write `.mcp.json` in
16
+ your project:
17
+
18
+ ```json
19
+ {
20
+ "mcpServers": {
21
+ "grampro": {
22
+ "command": "node",
23
+ "args": ["node_modules/@grampro/kit/index.cjs", "mcp"]
24
+ }
25
+ }
26
+ }
27
+ ```
28
+
29
+ MCP servers are not invoked by name. There is no `/grampro`; the model calls
30
+ the tools itself when they are relevant. Use `/mcp` to check the server is
31
+ connected.
32
+
33
+ ## Why
34
+
35
+ An agent asked to build a page has two ways to learn a component's API: read
36
+ the source, which costs a great deal of context and still invites invention,
37
+ or be told. This tells it — one component at a time, from the passport that
38
+ was generated from the source and so cannot drift from it.
39
+
40
+ The catalogue is 280 KB of passports. A page needs two or three components,
41
+ which is a few KB. That difference is the whole argument for a server over a
42
+ document pasted into the prompt.
43
+
44
+ ## Tools
45
+
46
+ | tool | what it answers |
47
+ | --- | --- |
48
+ | `list_components` | What exists, and what each one is for |
49
+ | `get_component` | Props with types and allowed values, which are required, handler signatures, slots, states, composition rules |
50
+ | `get_example` | Working usage from the component's README |
51
+ | `get_guidance` | Authored notes on forms, overlays, the grid, pickers, styling, accessibility |
52
+ | `figma_frame` | A design's structure, text, repeated regions and theme variables |
53
+ | `install_component` | Copies components into the project being worked in |
54
+ | `validate_usage` | Whether the JSX an agent wrote matches the passports |
55
+
56
+ `validate_usage` is the one that earns its place. In the experiment that
57
+ justified building this, a page written from passports alone compiled on the
58
+ first attempt apart from a single prop the model had remembered from
59
+ elsewhere rather than read here. Every type, union value and handler
60
+ signature the passport carried came out right. So the remaining failure is
61
+ invention, and this catches it:
62
+
63
+ ```
64
+ <Alert variant="info" title="Hi" dismissible onDismiss={…} />
65
+
66
+ dismissible — not a prop of this component. Did you mean: onDismiss
67
+ ```
68
+
69
+ ## From a Figma design
70
+
71
+ Two routes, and the second needs no credentials.
72
+
73
+ **By URL.** Pass a `figma.com/design/...` link. Called with just the URL it
74
+ lists the frames in the file; called again with `frame` it returns that
75
+ screen. Needs a token.
76
+
77
+ **By plugin export.** In Figma, run **Grampro Kit → Export selection for an
78
+ agent**, then pass the JSON as `design`. The plugin reads the scene graph
79
+ directly, so this works on any file you can open and asks for no token at
80
+ all.
81
+
82
+ Either way the answer is the same shape: the structure, the text, the
83
+ regions whose names repeat — eight `Status cell` nodes are a column — and
84
+ the `--gbs-*` variables the design's own colours and radii map onto.
85
+
86
+ It does not decide which component each region is. A design drawn without
87
+ the component library has no component instances to look up, so that call is
88
+ inference, and an agent holding the catalogue and the designer's own names
89
+ infers better than string matching in a tool. What is not a kit component is
90
+ reported as such rather than approximated.
91
+
92
+ Colours are given roles rather than ranked. The most-used colour in a design
93
+ is the body text, and a near-black navy scores high on saturation, so both
94
+ "most common" and "most saturated" pick the wrong thing. Lightness and
95
+ saturation together find the accent.
96
+
97
+ ## Credentials
98
+
99
+ **This is the weakest part of the current design, and it is temporary.**
100
+
101
+ The URL route reads `FIGMA_TOKEN` from the environment, or from a `.env`
102
+ file beside the project. That means a long-lived credential sitting in a
103
+ file, which is not where it belongs.
104
+
105
+ A future release will keep it in the editor's own secret storage — VS Code's
106
+ `SecretStorage` for the extension, and the equivalent elsewhere — so the
107
+ token is held by the host rather than the repository. Until then:
108
+
109
+ - prefer the plugin export route, which needs no token at all;
110
+ - if you do use a token, give it only `File content: read`;
111
+ - keep `.env` out of version control.
112
+
113
+ ## Suggested use
114
+
115
+ Tell the agent to call `get_component` before writing JSX for a component,
116
+ and `validate_usage` on what it wrote before reporting back. Both are cheap,
117
+ and together they close the gap between code that looks right and code that
118
+ compiles.
119
+
120
+ ## Notes
121
+
122
+ No dependencies. MCP is newline-delimited JSON-RPC over stdio, which is a
123
+ few hundred lines, and a server shipped inside a library that every consumer
124
+ installs should not drag an SDK along with it.
125
+
126
+ The catalogue it serves is the one in this package, not the components
127
+ copied into your project — so an agent can see a component before you
128
+ install it, and `install_component` puts it in place.
129
+
130
+ A Figma file is fetched once and held for five minutes. The natural flow is
131
+ two calls, list then read, and refetching a large file in between is what
132
+ earns a rate limit.
@@ -0,0 +1,388 @@
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 or a .env beside the project.
28
+ *
29
+ * MCP clients pass environment through unevenly, so a .env is the fallback
30
+ * that works everywhere. It is read, never logged.
31
+ */
32
+ function figmaToken() {
33
+ if (process.env.FIGMA_TOKEN) return process.env.FIGMA_TOKEN.trim();
34
+ for (const dir of [process.cwd(), path.join(__dirname, "..", "..")]) {
35
+ const file = path.join(dir, ".env");
36
+ if (!fs.existsSync(file)) continue;
37
+ const match = fs.readFileSync(file, "utf8").match(/^\s*FIGMA_TOKEN\s*=\s*(.+)$/m);
38
+ if (match) return match[1].trim().replace(/^["']|["']$/g, "");
39
+ }
40
+ return null;
41
+ }
42
+
43
+ /** figma.com/design/<key>/<name>?node-id=1-23 → { fileKey, nodeId } */
44
+ function parseFigmaUrl(url) {
45
+ const key = String(url).match(/figma\.com\/(?:file|design|proto)\/([A-Za-z0-9]+)/);
46
+ if (!key) return { error: "That does not look like a Figma file URL." };
47
+ const node = String(url).match(/[?&]node-id=([0-9]+[-:][0-9]+)/);
48
+ return { fileKey: key[1], nodeId: node ? node[1].replace("-", ":") : null };
49
+ }
50
+
51
+ /*
52
+ * A file is fetched once and held briefly.
53
+ *
54
+ * The natural flow is two calls — list the frames, then read one — and a
55
+ * file of any size is hundreds of KB. Fetching it twice in a row is what
56
+ * earns a 429, which is how this cache came to exist.
57
+ */
58
+ const CACHE_MS = 5 * 60 * 1000;
59
+ const cache = new Map();
60
+
61
+ async function figmaGet(pathname, token) {
62
+ const hit = cache.get(pathname);
63
+ if (hit && Date.now() - hit.at < CACHE_MS) return hit.body;
64
+
65
+ const res = await fetch(`https://api.figma.com/v1${pathname}`, {
66
+ headers: { "X-Figma-Token": token },
67
+ });
68
+ if (!res.ok) {
69
+ const body = await res.text();
70
+ if (res.status === 403) {
71
+ throw new Error(
72
+ "Figma refused the token. Check FIGMA_TOKEN has 'File content: read' scope and can see this file.",
73
+ );
74
+ }
75
+ if (res.status === 429) {
76
+ throw new Error(
77
+ "Figma is rate limiting this token. Wait a minute and try again; the file is cached for five minutes once it arrives.",
78
+ );
79
+ }
80
+ throw new Error(`Figma returned ${res.status}: ${body.slice(0, 160)}`);
81
+ }
82
+
83
+ const body = await res.json();
84
+ cache.set(pathname, { at: Date.now(), body });
85
+ return body;
86
+ }
87
+
88
+ // ------------------------------------------------------------- condensing
89
+
90
+ const hex = (c) => Math.round(c * 255).toString(16).padStart(2, "0");
91
+
92
+ function solidFill(node) {
93
+ const fill = (node.fills || []).find((f) => f.type === "SOLID" && f.visible !== false);
94
+ return fill ? `#${hex(fill.color.r)}${hex(fill.color.g)}${hex(fill.color.b)}` : undefined;
95
+ }
96
+
97
+ function layoutOf(node) {
98
+ if (!node.layoutMode || node.layoutMode === "NONE") return undefined;
99
+ const pad = [node.paddingTop, node.paddingRight, node.paddingBottom, node.paddingLeft];
100
+ return {
101
+ direction: node.layoutMode === "HORIZONTAL" ? "row" : "column",
102
+ gap: node.itemSpacing || undefined,
103
+ padding: pad.some((v) => v) ? pad.map((v) => v || 0) : undefined,
104
+ align: node.counterAxisAlignItems,
105
+ justify: node.primaryAxisAlignItems,
106
+ };
107
+ }
108
+
109
+ /*
110
+ * Text is lifted to the parent when a node's only content is one label.
111
+ * Figma wraps nearly every string in its own frame, and passing that
112
+ * structure through verbatim buries the design in scaffolding.
113
+ */
114
+ function condense(node, depth) {
115
+ const out = { name: node.name, type: node.type };
116
+
117
+ const layout = layoutOf(node);
118
+ if (layout) out.layout = layout;
119
+
120
+ if (node.type === "TEXT" && node.characters && node.characters.trim()) {
121
+ out.text = node.characters.trim();
122
+ if (node.style) {
123
+ out.font = { size: node.style.fontSize, weight: node.style.fontWeight };
124
+ }
125
+ }
126
+
127
+ // An instance is a lookup, when the design was built from a library.
128
+ if (node.type === "INSTANCE" && node.componentId) out.instanceOf = node.componentId;
129
+
130
+ const style = {};
131
+ const fill = solidFill(node);
132
+ if (fill && node.type !== "TEXT") style.fill = fill;
133
+ if (typeof node.cornerRadius === "number" && node.cornerRadius) style.radius = node.cornerRadius;
134
+ if (Object.keys(style).length) out.style = style;
135
+
136
+ if (node.absoluteBoundingBox) {
137
+ out.size = [Math.round(node.absoluteBoundingBox.width), Math.round(node.absoluteBoundingBox.height)];
138
+ }
139
+
140
+ const kids = (node.children || []).filter((c) => c.visible !== false);
141
+ if (kids.length && depth < MAX_DEPTH) {
142
+ const children = kids.map((c) => condense(c, depth + 1));
143
+ // A frame that exists only to hold one text node is noise.
144
+ if (children.length === 1 && children[0].text && !children[0].children && !out.text) {
145
+ out.text = children[0].text;
146
+ out.font = children[0].font;
147
+ } else {
148
+ out.children = children;
149
+ }
150
+ }
151
+
152
+ return out;
153
+ }
154
+
155
+ // ---------------------------------------------------------------- tokens
156
+
157
+ const luminance = (h) => {
158
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
159
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
160
+ };
161
+
162
+ const saturation = (h) => {
163
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
164
+ const max = Math.max(r, g, b);
165
+ const min = Math.min(r, g, b);
166
+ return max === 0 ? 0 : (max - min) / max;
167
+ };
168
+
169
+ /*
170
+ * Colours get roles, not ranks.
171
+ *
172
+ * The most-used colour in a design is almost always the body text, and the
173
+ * accent is used a handful of times on the one button that matters. Sorting
174
+ * by frequency alone would theme the whole library the colour of its
175
+ * paragraphs.
176
+ */
177
+ function themeFrom(colours, radii, fontSizes) {
178
+ const used = [...colours.entries()].sort((a, b) => b[1] - a[1]);
179
+ const theme = [];
180
+ const take = (variable, entry, note) => {
181
+ if (entry) theme.push({ variable, value: entry[0], seenOn: entry[1], why: note });
182
+ };
183
+
184
+ /*
185
+ * A near-black navy reads as highly saturated by the HSV measure — #192435
186
+ * scores 0.53 — so saturation alone picks the body text as the brand
187
+ * colour. Requiring real lightness as well is what separates an accent
188
+ * from a dark neutral.
189
+ */
190
+ const saturated = used.filter(
191
+ ([h]) => saturation(h) > 0.45 && luminance(h) > 0.2 && luminance(h) < 0.8,
192
+ );
193
+ const darkest = used.filter(([h]) => luminance(h) < 0.25).sort((a, b) => luminance(a[0]) - luminance(b[0]));
194
+ const lightest = used.filter(([h]) => luminance(h) > 0.9).sort((a, b) => b[1] - a[1]);
195
+ const mid = used.filter(([h]) => luminance(h) >= 0.25 && luminance(h) <= 0.9 && saturation(h) <= 0.35);
196
+
197
+ take("--gbs-accent", saturated[0], "the most-used saturated colour");
198
+ take("--gbs-fg", darkest[0], "the darkest colour, used for text");
199
+ take("--gbs-bg", lightest[0], "the lightest colour, used for surfaces");
200
+ take("--gbs-muted", mid[0], "a desaturated mid tone");
201
+
202
+ const radius = [...radii.entries()].sort((a, b) => b[1] - a[1])[0];
203
+ if (radius) theme.push({ variable: "--gbs-radius", value: `${radius[0]}px`, seenOn: radius[1] });
204
+
205
+ // The body size is the most-used that a person could actually read.
206
+ const body = [...fontSizes.entries()]
207
+ .filter(([size]) => size >= 12)
208
+ .sort((a, b) => b[1] - a[1])[0];
209
+ if (body) theme.push({ variable: "--gbs-font-size", value: `${body[0]}px`, seenOn: body[1] });
210
+
211
+ return theme;
212
+ }
213
+
214
+ function collectTokens(node, colours, radii, fontSizes) {
215
+ const fill = solidFill(node);
216
+ if (fill) colours.set(fill, (colours.get(fill) || 0) + 1);
217
+ if (typeof node.cornerRadius === "number" && node.cornerRadius) {
218
+ radii.set(node.cornerRadius, (radii.get(node.cornerRadius) || 0) + 1);
219
+ }
220
+ if (node.style && node.style.fontSize) {
221
+ fontSizes.set(node.style.fontSize, (fontSizes.get(node.style.fontSize) || 0) + 1);
222
+ }
223
+ for (const child of node.children || []) collectTokens(child, colours, radii, fontSizes);
224
+ }
225
+
226
+ /** Names the designer repeated are the ones that mean something. */
227
+ function repeatedNames(node, counts) {
228
+ counts.set(node.name, (counts.get(node.name) || 0) + 1);
229
+ for (const child of node.children || []) repeatedNames(child, counts);
230
+ }
231
+
232
+ // ------------------------------------------------------------------- tool
233
+
234
+ /*
235
+ * A selection exported by the plugin, which has already done the condensing
236
+ * inside Figma. The work left is the part that needs no file access: giving
237
+ * the colours roles and counting the names that repeat.
238
+ *
239
+ * This is the path for anyone who would rather not hold an API token, and
240
+ * for files the token cannot reach.
241
+ */
242
+ function fromPluginExport(design) {
243
+ let parsed;
244
+ try {
245
+ parsed = typeof design === "string" ? JSON.parse(design) : design;
246
+ } catch (error) {
247
+ return { error: `That is not JSON: ${error.message}` };
248
+ }
249
+ if (!parsed || !Array.isArray(parsed.nodes)) {
250
+ return {
251
+ error:
252
+ "Expected the output of the Figma plugin's 'Export selection for an agent' command.",
253
+ };
254
+ }
255
+
256
+ const colours = new Map();
257
+ const radii = new Map();
258
+ const fontSizes = new Map();
259
+ const names = new Map();
260
+ const visit = (node) => {
261
+ names.set(node.name, (names.get(node.name) || 0) + 1);
262
+ if (node.style) {
263
+ if (node.style.fill) colours.set(node.style.fill, (colours.get(node.style.fill) || 0) + 1);
264
+ if (node.style.radius) radii.set(node.style.radius, (radii.get(node.style.radius) || 0) + 1);
265
+ if (node.style.fontSize) {
266
+ fontSizes.set(node.style.fontSize, (fontSizes.get(node.style.fontSize) || 0) + 1);
267
+ }
268
+ }
269
+ for (const child of node.children || []) visit(child);
270
+ };
271
+ for (const node of parsed.nodes) visit(node);
272
+
273
+ const instances = [];
274
+ const findInstances = (n) => {
275
+ if (n.component) instances.push(n.component);
276
+ for (const c of n.children || []) findInstances(c);
277
+ };
278
+ for (const node of parsed.nodes) findInstances(node);
279
+
280
+ return {
281
+ source: "figma plugin export",
282
+ frame: parsed.frame,
283
+ theme: themeFrom(colours, radii, fontSizes),
284
+ repeatedRegions: [...names.entries()]
285
+ .filter(([, n]) => n > 2)
286
+ .sort((a, b) => b[1] - a[1])
287
+ .slice(0, 20)
288
+ .map(([name, n]) => ({ name, times: n })),
289
+ builtFromLibrary: instances.length > 0,
290
+ componentsNamed: [...new Set(instances)],
291
+ tree: parsed.nodes,
292
+ };
293
+ }
294
+
295
+ async function figmaFrame({ url, design, frame, depth }) {
296
+ // The export needs no token, so it is offered before one is demanded.
297
+ if (design) return fromPluginExport(design);
298
+
299
+ const token = figmaToken();
300
+ if (!token) {
301
+ return {
302
+ error:
303
+ "No FIGMA_TOKEN. Either run the kit's Figma plugin → 'Export selection for an agent' and pass the result as `design`, or create a token at Figma → Settings → Security → Personal access tokens (scope: File content, read) and set FIGMA_TOKEN.",
304
+ };
305
+ }
306
+
307
+ const { fileKey, nodeId, error } = parseFigmaUrl(url);
308
+ if (error) return { error };
309
+
310
+ let file;
311
+ try {
312
+ file = nodeId
313
+ ? await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`, token)
314
+ : await figmaGet(`/files/${fileKey}`, token);
315
+ } catch (e) {
316
+ return { error: e.message };
317
+ }
318
+
319
+ const roots = nodeId
320
+ ? Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document)
321
+ : (file.document.children || []).flatMap((page) => page.children || []);
322
+
323
+ if (roots.length === 0) return { error: "Nothing to read at that URL." };
324
+
325
+ /*
326
+ * Without a frame named, the whole file would arrive at once — hundreds of
327
+ * KB that buries the screen the agent was asked about. So the first call
328
+ * lists what is there and the second asks for one.
329
+ */
330
+ const wanted = frame
331
+ ? roots.filter((r) => r.name.toLowerCase() === String(frame).toLowerCase())
332
+ : roots;
333
+
334
+ if (!frame && roots.length > 1) {
335
+ return {
336
+ file: file.name,
337
+ frames: roots.map((r) => ({
338
+ name: r.name,
339
+ size: r.absoluteBoundingBox
340
+ ? [Math.round(r.absoluteBoundingBox.width), Math.round(r.absoluteBoundingBox.height)]
341
+ : undefined,
342
+ })),
343
+ next: "Call again with `frame` set to the one you want to build.",
344
+ };
345
+ }
346
+
347
+ if (wanted.length === 0) {
348
+ return { error: `No frame called "${frame}".`, frames: roots.map((r) => r.name) };
349
+ }
350
+
351
+ const target = wanted[0];
352
+ const colours = new Map();
353
+ const radii = new Map();
354
+ const fontSizes = new Map();
355
+ const names = new Map();
356
+ collectTokens(target, colours, radii, fontSizes);
357
+ repeatedNames(target, names);
358
+
359
+ const limit = Math.min(Number(depth) || MAX_DEPTH, MAX_DEPTH);
360
+ const tree = condense(target, MAX_DEPTH - limit);
361
+
362
+ const instances = [];
363
+ const findInstances = (n) => {
364
+ if (n.instanceOf) instances.push(n.name);
365
+ for (const c of n.children || []) findInstances(c);
366
+ };
367
+ findInstances(tree);
368
+
369
+ return {
370
+ file: file.name,
371
+ frame: target.name,
372
+ size: tree.size,
373
+ theme: themeFrom(colours, radii, fontSizes),
374
+ /* Repetition is the signal: eight "Status cell" nodes are a column. */
375
+ repeatedRegions: [...names.entries()]
376
+ .filter(([, n]) => n > 2)
377
+ .sort((a, b) => b[1] - a[1])
378
+ .slice(0, 20)
379
+ .map(([name, n]) => ({ name, times: n })),
380
+ builtFromLibrary: instances.length > 0,
381
+ tree,
382
+ howToUse: instances.length
383
+ ? "This design uses component instances; `instanceOf` names the master for each."
384
+ : "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.",
385
+ };
386
+ }
387
+
388
+ module.exports = { figmaFrame, parseFigmaUrl, themeFrom };