@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.
- package/CHANGELOG.md +169 -0
- package/README.md +12 -0
- package/index.cjs +44 -2
- package/package.json +1 -1
- package/source/components/accordion/passport.json +11 -7
- package/source/components/accordion/react/Accordion.tsx +5 -0
- package/source/components/alert/passport.json +3 -3
- package/source/components/avatar/passport.json +8 -6
- package/source/components/avatar/react/Avatar.tsx +4 -0
- package/source/components/badge/passport.json +8 -6
- package/source/components/badge/react/Badge.tsx +1 -0
- package/source/components/badge/react/Tag.tsx +1 -0
- package/source/components/breadcrumb/passport.json +8 -6
- package/source/components/breadcrumb/react/Breadcrumb.tsx +1 -0
- package/source/components/button/passport.json +8 -6
- package/source/components/button/react/Button.tsx +1 -0
- package/source/components/card/passport.json +17 -9
- package/source/components/card/react/Card.tsx +5 -0
- package/source/components/card/react/Stat.tsx +5 -0
- package/source/components/checkbox/passport.json +32 -14
- package/source/components/checkbox/react/Checkbox.tsx +17 -0
- package/source/components/checkbox/react/CheckboxGroup.tsx +6 -0
- package/source/components/combobox/passport.json +36 -16
- package/source/components/combobox/react/Select.tsx +6 -1
- package/source/components/combobox/react/props.ts +23 -0
- package/source/components/data-grid/core/types.ts +53 -0
- package/source/components/data-grid/passport.json +82 -30
- package/source/components/data-grid/react/AskGrid.tsx +10 -0
- package/source/components/data-grid/react/DataGrid.tsx +16 -0
- package/source/components/date-picker/passport.json +30 -14
- package/source/components/date-picker/react/DatePicker.tsx +5 -0
- package/source/components/date-picker/react/props.ts +13 -1
- package/source/components/dialog/core/types.ts +14 -0
- package/source/components/dialog/passport.json +26 -12
- package/source/components/dialog/react/Dialog.tsx +4 -0
- package/source/components/file-uploader/passport.json +44 -18
- package/source/components/file-uploader/react/FileUploader.tsx +39 -0
- package/source/components/input/passport.json +14 -8
- package/source/components/input/react/Input.tsx +3 -0
- package/source/components/menu/passport.json +29 -13
- package/source/components/menu/react/Menu.tsx +7 -0
- package/source/components/menu/react/items.tsx +4 -0
- package/source/components/modal/passport.json +14 -8
- package/source/components/modal/react/Modal.tsx +6 -0
- package/source/components/number-input/passport.json +23 -11
- package/source/components/number-input/react/NumberInput.tsx +13 -0
- package/source/components/popover/passport.json +17 -9
- package/source/components/popover/react/Popover.tsx +7 -0
- package/source/components/progress/passport.json +3 -3
- package/source/components/radio-group/passport.json +32 -14
- package/source/components/radio-group/react/Radio.tsx +17 -0
- package/source/components/radio-group/react/RadioGroup.tsx +13 -0
- package/source/components/skeleton/passport.json +8 -6
- package/source/components/skeleton/react/Skeleton.tsx +5 -0
- package/source/components/spinner/passport.json +3 -3
- package/source/components/switch/passport.json +23 -11
- package/source/components/switch/react/Switch.tsx +14 -0
- package/source/components/tabs/passport.json +11 -7
- package/source/components/tabs/react/Tabs.tsx +2 -0
- package/source/components/textarea/passport.json +14 -8
- package/source/components/textarea/react/Textarea.tsx +3 -0
- package/source/components/toaster/passport.json +8 -6
- package/source/components/toaster/react/Toaster.tsx +4 -0
- package/source/components/tooltip/passport.json +3 -3
- package/tools/mcp/README.md +224 -0
- package/tools/mcp/figma.cjs +445 -0
- package/tools/mcp/install.cjs +270 -0
- package/tools/mcp/secret-cli.cjs +174 -0
- package/tools/mcp/secret.cjs +289 -0
- package/tools/mcp/server.cjs +497 -0
- package/tools/passport/cli.cjs +8 -1
- package/tools/passport/extract.cjs +7 -3
- 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 };
|