@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.
- package/CHANGELOG.md +25 -0
- package/README.md +12 -0
- package/index.cjs +8 -1
- package/package.json +1 -1
- package/source/components/accordion/passport.json +2 -2
- package/source/components/alert/passport.json +2 -2
- package/source/components/avatar/passport.json +2 -2
- package/source/components/badge/passport.json +2 -2
- package/source/components/breadcrumb/passport.json +2 -2
- package/source/components/button/passport.json +2 -2
- package/source/components/card/passport.json +2 -2
- package/source/components/checkbox/passport.json +2 -2
- package/source/components/combobox/passport.json +2 -2
- package/source/components/data-grid/passport.json +2 -2
- package/source/components/date-picker/passport.json +2 -2
- package/source/components/dialog/passport.json +2 -2
- package/source/components/file-uploader/passport.json +2 -2
- package/source/components/input/passport.json +2 -2
- package/source/components/menu/passport.json +2 -2
- package/source/components/modal/passport.json +2 -2
- package/source/components/number-input/passport.json +2 -2
- package/source/components/popover/passport.json +2 -2
- package/source/components/progress/passport.json +2 -2
- package/source/components/radio-group/passport.json +2 -2
- package/source/components/skeleton/passport.json +2 -2
- package/source/components/spinner/passport.json +2 -2
- package/source/components/switch/passport.json +2 -2
- package/source/components/tabs/passport.json +2 -2
- package/source/components/textarea/passport.json +2 -2
- package/source/components/toaster/passport.json +2 -2
- package/source/components/tooltip/passport.json +2 -2
- package/tools/mcp/README.md +132 -0
- package/tools/mcp/figma.cjs +388 -0
- package/tools/mcp/server.cjs +458 -0
- 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 };
|