@grampro/kit 2.5.0 → 2.6.1
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 +138 -0
- package/README.md +25 -5
- package/index.cjs +32 -1
- package/package.json +1 -1
- package/source/components/accordion/passport.json +1 -1
- package/source/components/alert/passport.json +1 -1
- package/source/components/avatar/passport.json +1 -1
- package/source/components/badge/passport.json +1 -1
- package/source/components/breadcrumb/passport.json +1 -1
- package/source/components/button/passport.json +1 -1
- package/source/components/card/passport.json +1 -1
- package/source/components/checkbox/passport.json +1 -1
- package/source/components/combobox/passport.json +1 -1
- package/source/components/data-grid/passport.json +1 -1
- package/source/components/date-picker/passport.json +1 -1
- package/source/components/dialog/passport.json +1 -1
- package/source/components/file-uploader/passport.json +1 -1
- package/source/components/input/passport.json +1 -1
- package/source/components/menu/passport.json +1 -1
- package/source/components/modal/passport.json +1 -1
- package/source/components/number-input/passport.json +1 -1
- package/source/components/popover/passport.json +1 -1
- package/source/components/progress/passport.json +1 -1
- package/source/components/radio-group/passport.json +1 -1
- package/source/components/skeleton/passport.json +1 -1
- package/source/components/spinner/passport.json +1 -1
- package/source/components/switch/passport.json +1 -1
- package/source/components/tabs/passport.json +1 -1
- package/source/components/textarea/passport.json +1 -1
- package/source/components/toaster/passport.json +1 -1
- package/source/components/tooltip/passport.json +1 -1
- package/tools/mcp/README.md +37 -2
- package/tools/mcp/design.cjs +154 -0
- package/tools/mcp/figma.cjs +180 -5
- package/tools/mcp/server.cjs +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,143 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.6.1
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
**A screen costs about half what it did to read.** Measured on a real
|
|
8
|
+
purchase-orders design: 42.9 KB to 23.9 KB, roughly 13,300 tokens to
|
|
9
|
+
7,400.
|
|
10
|
+
|
|
11
|
+
Eight table rows exported as eight sibling subtrees of identical shape
|
|
12
|
+
whose text differed — 48% of that screen was the same row said seven
|
|
13
|
+
more times. The first is now given in full, because the agent builds from
|
|
14
|
+
its shape, and the rest become `repeatedText`: one array of strings per
|
|
15
|
+
row, which is closer to what a DataGrid's `data` prop wants than the
|
|
16
|
+
subtrees were.
|
|
17
|
+
|
|
18
|
+
Runs are matched on structure rather than appearance. Keyed on style, the
|
|
19
|
+
rows never matched at all: a striped table alternates its background, so
|
|
20
|
+
every consecutive pair looked like a different shape and nothing
|
|
21
|
+
collapsed. Where the fills do vary across a run they come back as
|
|
22
|
+
`repeatedFills`, which is how the striping and the per-status badge
|
|
23
|
+
colours stay legible.
|
|
24
|
+
|
|
25
|
+
Only runs of three or more collapse, and only where there is depth to
|
|
26
|
+
save: two of a thing is a pair, not a list, and a row of labels has
|
|
27
|
+
nothing inside it worth folding.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
**`depth` is honoured on the plugin and file routes.** The tool has always
|
|
32
|
+
offered it — "Lower it if the payload is large" — and only ever applied
|
|
33
|
+
it to a design fetched from a URL. A caller who read the schema and
|
|
34
|
+
lowered it to cut a large payload got the large payload back. The same
|
|
35
|
+
screen at `depth: 3` is now 3.5 KB, and what was dropped says so rather
|
|
36
|
+
than going quiet.
|
|
37
|
+
|
|
38
|
+
**Selecting several frames exports several screens.** It used to produce
|
|
39
|
+
one pseudo-frame called "3 selected nodes" holding all of them, which
|
|
40
|
+
`design` then wrote as a single file under that name — a folder whose
|
|
41
|
+
whole point is one file per screen, with one file for three. The screens
|
|
42
|
+
were in there, but nothing said where one ended and the next began.
|
|
43
|
+
|
|
44
|
+
A selection of several frames now produces the same shape the whole-file
|
|
45
|
+
export does, so the split, the index and `figma_frame` treat them
|
|
46
|
+
identically. One frame selected is still one screen.
|
|
47
|
+
|
|
48
|
+
This is also the scoping the all-screens command lacks: a file with forty
|
|
49
|
+
frames in it need not be exported entire to build two of them. Select the
|
|
50
|
+
screens you want and export those.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
## 2.6.0
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
**A whole Figma design, exported once and built screen by screen.**
|
|
58
|
+
|
|
59
|
+
In Figma, **Grampro Kit → Export all screens for an agent** walks every page
|
|
60
|
+
and takes each top-level frame. Then:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx @grampro/kit design ./design.json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
Wrote 3 screen(s) to design/
|
|
68
|
+
purchase-orders.json Purchase orders (Product) — 22 nodes
|
|
69
|
+
suppliers.json Suppliers (Product) — 16 nodes
|
|
70
|
+
settings.json Settings (Admin) — 12 nodes
|
|
71
|
+
index.json the plan: every screen, shared theme
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The accuracy is in the theme rather than the convenience. Derived one
|
|
75
|
+
frame at a time it disagrees with itself — a screen that is mostly white
|
|
76
|
+
names a different background from one that is mostly grey — and the agent
|
|
77
|
+
then themes each page differently. Read across every screen, once, it is
|
|
78
|
+
well founded.
|
|
79
|
+
|
|
80
|
+
`index.json` is the plan: every screen with its size, node count and the
|
|
81
|
+
regions whose names repeat, plus that shared theme. It is the cheap place
|
|
82
|
+
to be wrong. A design drawn without the component library names nothing
|
|
83
|
+
directly, so every component is inferred, and a guess that `Action` is a
|
|
84
|
+
Card rather than a Button costs one sentence there and five screens of
|
|
85
|
+
rework later.
|
|
86
|
+
|
|
87
|
+
**`figma_frame` reads a file.** Pass `designFile` with a path instead of
|
|
88
|
+
`design` with the JSON. A chat input caps at around fifty thousand
|
|
89
|
+
characters and a whole screen passes that; and a pasted export sits in the
|
|
90
|
+
conversation twice, once as the argument and again inside the tool call,
|
|
91
|
+
where a file appears only in the answer.
|
|
92
|
+
|
|
93
|
+
**Every prop the library declares now carries a description.** 117 did
|
|
94
|
+
not, and the passports are what an agent reads instead of the source, so
|
|
95
|
+
an undescribed prop is one it guesses at. `DataGrid`'s `columns` and
|
|
96
|
+
`data` had none at all. See 2.5.0's notes — this is that work, reaching
|
|
97
|
+
npm for the first time.
|
|
98
|
+
|
|
99
|
+
### Changed
|
|
100
|
+
|
|
101
|
+
**The Figma export costs a third of what it did.** The copy button takes
|
|
102
|
+
minified JSON: `JSON.parse` cannot tell the difference, and indentation
|
|
103
|
+
was most of the payload. Three fields that said nothing went with it —
|
|
104
|
+
`radius: 0` on every square frame, `padding: [0,0,0,0]` on every frame
|
|
105
|
+
without padding, and `align`/`justify` restating Figma's own defaults —
|
|
106
|
+
along with the child frame Figma wraps around every icon, whose entire
|
|
107
|
+
content was the word "Vector" when the frame's name (`search`, `plus`)
|
|
108
|
+
was the whole signal.
|
|
109
|
+
|
|
110
|
+
**It also says more about what it exports.** Node size and stroke colour
|
|
111
|
+
are now included, because most designs are not built from the kit's Figma
|
|
112
|
+
library and an agent has to infer every component from what it can see. A
|
|
113
|
+
36px pill with a label is a button; the same shape at 200px is a card. A
|
|
114
|
+
white box with a border is a secondary button; without one it is a
|
|
115
|
+
surface. Neither was visible before.
|
|
116
|
+
|
|
117
|
+
### Fixed
|
|
118
|
+
|
|
119
|
+
**The design system generator laid out over itself.** Variant grids were
|
|
120
|
+
placed by column count without measuring whether the row fitted, so 14 of
|
|
121
|
+
38 component sets overflowed the card holding them — Alert reached 2444px
|
|
122
|
+
inside a 1320px section.
|
|
123
|
+
|
|
124
|
+
**Text that was told a width kept its own.** Figma creates a text node
|
|
125
|
+
auto-width, and setting it to fill is quietly ignored, so 23 layers across
|
|
126
|
+
the file measured themselves from their own characters. NumberInput's
|
|
127
|
+
value layer, created empty and filled in later by a component property,
|
|
128
|
+
wrapped one glyph per line.
|
|
129
|
+
|
|
130
|
+
**Alert's coloured edge did not match its card**, being sized before the
|
|
131
|
+
card had any content and never clipped to its rounded corners. Toast drew
|
|
132
|
+
the same edge with the same fault.
|
|
133
|
+
|
|
134
|
+
**The uploader thumbnails and the dialog's intent badge rendered at full
|
|
135
|
+
saturation**, which also hid the icon drawn on them in the same colour.
|
|
136
|
+
|
|
137
|
+
**The plugin's name displayed as mojibake** in Figma's menu, and its
|
|
138
|
+
build was never committed despite its README saying so, so a clone could
|
|
139
|
+
not run it.
|
|
140
|
+
|
|
3
141
|
## 2.5.0
|
|
4
142
|
|
|
5
143
|
### Added
|
package/README.md
CHANGED
|
@@ -6,6 +6,10 @@ React components you own — styled, dependency-free, and machine-readable. Each
|
|
|
6
6
|
component arrives in your project as source, with its stylesheet, and with a
|
|
7
7
|
contract describing what it is and what can safely be done to it.
|
|
8
8
|
|
|
9
|
+
It ships an **MCP server**, so Claude Code, Codex and Cursor can read that
|
|
10
|
+
contract instead of guessing at it — and build a screen straight from a
|
|
11
|
+
Figma design.
|
|
12
|
+
|
|
9
13
|
```bash
|
|
10
14
|
npx @grampro/kit add DataGrid
|
|
11
15
|
```
|
|
@@ -92,15 +96,31 @@ In this repository:
|
|
|
92
96
|
|
|
93
97
|
## MCP server (beta)
|
|
94
98
|
|
|
95
|
-
|
|
99
|
+
The catalogue, served to a coding agent on demand over the Model Context
|
|
100
|
+
Protocol:
|
|
96
101
|
|
|
97
102
|
```bash
|
|
98
|
-
|
|
103
|
+
npx @grampro/kit mcp install
|
|
99
104
|
```
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
That configures **Claude Code** (`.mcp.json`), **Codex**
|
|
107
|
+
(`.codex/config.toml`) and **Cursor** (`.cursor/mcp.json`) in one command,
|
|
108
|
+
merging into whatever is already there.
|
|
109
|
+
|
|
110
|
+
Its seven tools answer what components exist (`list_components`), what a
|
|
111
|
+
component's props actually are (`get_component`), how it is used
|
|
112
|
+
(`get_example`, `get_guidance`), what a Figma frame contains
|
|
113
|
+
(`figma_frame`), and — the one that earns its place — whether the JSX an
|
|
114
|
+
agent just wrote matches the real API (`validate_usage`).
|
|
115
|
+
|
|
116
|
+
`install_component` copies what the design needs into your project, so an
|
|
117
|
+
agent can go from a Figma link to a working screen without you installing
|
|
118
|
+
anything first.
|
|
119
|
+
|
|
120
|
+
No dependencies, and it starts in about the time an empty Node process
|
|
121
|
+
takes. Full walkthrough, including a prompt to paste:
|
|
122
|
+
[kit.gramproindia.com/mcp-server](https://kit.gramproindia.com/mcp-server).
|
|
123
|
+
Details in [`tools/mcp/README.md`](tools/mcp/README.md).
|
|
104
124
|
|
|
105
125
|
## Agent skill
|
|
106
126
|
|
package/index.cjs
CHANGED
|
@@ -1190,7 +1190,7 @@ const validateComponents = (components, beta = false) => {
|
|
|
1190
1190
|
* projects a week install this; breaking their scripts to tidy an argument
|
|
1191
1191
|
* parser is not a trade worth making.
|
|
1192
1192
|
*/
|
|
1193
|
-
const COMMANDS = new Set(["add", "list", "skill", "passport", "mcp", "secret"]);
|
|
1193
|
+
const COMMANDS = new Set(["add", "list", "skill", "passport", "mcp", "secret", "design"]);
|
|
1194
1194
|
|
|
1195
1195
|
/**
|
|
1196
1196
|
* Flags that take a separate value, so the token after one is that value and
|
|
@@ -1297,6 +1297,7 @@ const main = async () => {
|
|
|
1297
1297
|
.example("$0 mcp install", "Register the MCP server with every agent found")
|
|
1298
1298
|
.example("$0 mcp install --for codex", "Only write the Codex config")
|
|
1299
1299
|
.example("$0 secret set figma", "Store a Figma token in the OS credential store")
|
|
1300
|
+
.example("$0 design ./design.json", "Split a whole-file Figma export into design/")
|
|
1300
1301
|
.example("$0 -i", "Interactive selection")
|
|
1301
1302
|
.help().argv;
|
|
1302
1303
|
|
|
@@ -1333,6 +1334,36 @@ const main = async () => {
|
|
|
1333
1334
|
return;
|
|
1334
1335
|
}
|
|
1335
1336
|
|
|
1337
|
+
if (parsed && parsed.command === "design") {
|
|
1338
|
+
const [file, dest] = parsed.operands;
|
|
1339
|
+
if (!file) {
|
|
1340
|
+
console.log(`Which export? Try: ${CLI_NAME} design ./design.json`);
|
|
1341
|
+
console.log("");
|
|
1342
|
+
console.log("Save one from Figma first: Grampro Kit -> Export all screens for an agent.");
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
const { splitDesign } = require("./tools/mcp/design.cjs");
|
|
1346
|
+
const result = splitDesign(file, dest || "design");
|
|
1347
|
+
if (result.error) {
|
|
1348
|
+
console.log(result.error);
|
|
1349
|
+
process.exitCode = 1;
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
const rel = path.relative(process.cwd(), result.dest) || result.dest;
|
|
1353
|
+
console.log(`Wrote ${result.written.length} screen(s) to ${rel}/`);
|
|
1354
|
+
for (const screen of result.written) {
|
|
1355
|
+
const where = screen.page ? ` (${screen.page})` : "";
|
|
1356
|
+
console.log(` ${screen.file.padEnd(28)} ${screen.name}${where} — ${screen.nodes} nodes`);
|
|
1357
|
+
}
|
|
1358
|
+
console.log(` index.json${" ".repeat(18)} the plan: every screen, shared theme`);
|
|
1359
|
+
console.log("");
|
|
1360
|
+
console.log(
|
|
1361
|
+
`Point an agent at it: "read ${rel}/index.json and build these screens`
|
|
1362
|
+
+ ` with the grampro MCP server".`,
|
|
1363
|
+
);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1336
1367
|
if (parsed) {
|
|
1337
1368
|
if (parsed.command === "list") argv.list = true;
|
|
1338
1369
|
if (parsed.command === "skill") argv.skill = true;
|
package/package.json
CHANGED
package/tools/mcp/README.md
CHANGED
|
@@ -108,7 +108,7 @@ document pasted into the prompt.
|
|
|
108
108
|
| `get_component` | Props with types and allowed values, which are required, handler signatures, slots, states, composition rules |
|
|
109
109
|
| `get_example` | Working usage from the component's README |
|
|
110
110
|
| `get_guidance` | Authored notes on forms, overlays, the grid, pickers, styling, accessibility |
|
|
111
|
-
| `figma_frame` | A design's structure, text, repeated regions and theme variables |
|
|
111
|
+
| `figma_frame` | A design's structure, text, repeated regions and theme variables, from a `designFile` path, a pasted `design`, or a Figma `url` |
|
|
112
112
|
| `install_component` | Copies components into the project being worked in |
|
|
113
113
|
| `validate_usage` | Whether the JSX an agent wrote matches the passports |
|
|
114
114
|
|
|
@@ -125,9 +125,38 @@ invention, and this catches it:
|
|
|
125
125
|
dismissible — not a prop of this component. Did you mean: onDismiss
|
|
126
126
|
```
|
|
127
127
|
|
|
128
|
+
## A whole design at once
|
|
129
|
+
|
|
130
|
+
In Figma, **Grampro Kit → Export all screens for an agent** walks every
|
|
131
|
+
page and exports each top-level frame, with one set of theme values read
|
|
132
|
+
across the whole design rather than guessed per frame. Save it, then:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npx @grampro/kit design ./design.json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
Wrote 3 screen(s) to design/
|
|
140
|
+
purchase-orders.json Purchase orders (Product) — 22 nodes
|
|
141
|
+
suppliers.json Suppliers (Product) — 16 nodes
|
|
142
|
+
settings.json Settings (Admin) — 12 nodes
|
|
143
|
+
index.json the plan: every screen, shared theme
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A browser cannot write a directory and a plugin is an iframe, so the
|
|
147
|
+
plugin hands over one file and the CLI does the filesystem work. Each
|
|
148
|
+
screen is written in the shape a single-frame export has, so `figma_frame`
|
|
149
|
+
reads one with `designFile` and no special case.
|
|
150
|
+
|
|
151
|
+
`index.json` is the plan: every screen with its size, node count and
|
|
152
|
+
repeated regions, plus the shared theme. Have an agent map regions to
|
|
153
|
+
components from that and correct it before any code exists — a guess that
|
|
154
|
+
`Action` is a Card rather than a Button costs one sentence there and five
|
|
155
|
+
screens of rework later. Then build one screen at a time, validating each.
|
|
156
|
+
|
|
128
157
|
## From a Figma design
|
|
129
158
|
|
|
130
|
-
|
|
159
|
+
Three routes, and only the first needs credentials.
|
|
131
160
|
|
|
132
161
|
**By URL.** Pass a `figma.com/design/...` link. Called with just the URL it
|
|
133
162
|
lists the frames in the file; called again with `frame` it returns that
|
|
@@ -140,6 +169,12 @@ agent**, then pass the JSON as `design`. The plugin reads the scene graph
|
|
|
140
169
|
directly, so this works on any file you can open, asks for no token, and
|
|
141
170
|
never touches the REST API — which also means it cannot be rate limited.
|
|
142
171
|
|
|
172
|
+
**By file.** Press **Save .json** in that panel and pass the path as
|
|
173
|
+
`designFile`. Prefer this for anything but a small frame: a chat input
|
|
174
|
+
caps at around fifty thousand characters, and a pasted export sits in the
|
|
175
|
+
conversation twice — once as the argument, again inside the tool call —
|
|
176
|
+
where a file appears only in the answer.
|
|
177
|
+
|
|
143
178
|
Either way the answer is the same shape: the structure, the text, the
|
|
144
179
|
regions whose names repeat — eight `Status cell` nodes are a column — and
|
|
145
180
|
the `--gbs-*` variables the design's own colours and radii map onto.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* `grampro-kit design <file>` — a whole-file Figma export, split into a folder.
|
|
3
|
+
*
|
|
4
|
+
* The plugin downloads one file because a browser cannot write a directory.
|
|
5
|
+
* Splitting it is filesystem work, which belongs in the CLI rather than in a
|
|
6
|
+
* plugin iframe, and it is what makes the result usable: an agent reads the
|
|
7
|
+
* index to plan, then opens one screen at a time, instead of loading every
|
|
8
|
+
* screen to build any of them.
|
|
9
|
+
*
|
|
10
|
+
* Each screen is written in the same shape a single-frame export has, so
|
|
11
|
+
* `figma_frame` reads one with no special case.
|
|
12
|
+
*/
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
const { themeFrom } = require("./figma.cjs");
|
|
18
|
+
|
|
19
|
+
/** A file name from a screen name: lowercase, dashes, nothing exotic. */
|
|
20
|
+
const slug = (name) =>
|
|
21
|
+
String(name)
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
24
|
+
.replace(/^-|-$/g, "") || "screen";
|
|
25
|
+
|
|
26
|
+
/** Names a designer repeated are the ones that mean something. */
|
|
27
|
+
function countNames(node, counts) {
|
|
28
|
+
counts.set(node.name, (counts.get(node.name) || 0) + 1);
|
|
29
|
+
for (const child of node.children || []) countNames(child, counts);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const repeatedIn = (nodes) => {
|
|
33
|
+
const counts = new Map();
|
|
34
|
+
for (const node of nodes) countNames(node, counts);
|
|
35
|
+
return [...counts.entries()]
|
|
36
|
+
.filter(([, n]) => n > 2)
|
|
37
|
+
.sort((a, b) => b[1] - a[1])
|
|
38
|
+
.slice(0, 12)
|
|
39
|
+
.map(([name, times]) => ({ name, times }));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Component instances found anywhere in a screen. */
|
|
43
|
+
function componentsIn(nodes) {
|
|
44
|
+
const found = new Set();
|
|
45
|
+
const visit = (node) => {
|
|
46
|
+
if (node.component) found.add(node.component);
|
|
47
|
+
for (const child of node.children || []) visit(child);
|
|
48
|
+
};
|
|
49
|
+
for (const node of nodes) visit(node);
|
|
50
|
+
return [...found];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const countNodes = (nodes) =>
|
|
54
|
+
nodes.reduce((n, node) => n + 1 + countNodes(node.children || []), 0);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Split an export into `dest`.
|
|
58
|
+
*
|
|
59
|
+
* Returns what it wrote rather than printing, so the CLI owns the words and
|
|
60
|
+
* this stays testable.
|
|
61
|
+
*/
|
|
62
|
+
function splitDesign(file, dest) {
|
|
63
|
+
const source = path.resolve(file);
|
|
64
|
+
if (!fs.existsSync(source)) return { error: `No file at ${source}.` };
|
|
65
|
+
|
|
66
|
+
let design;
|
|
67
|
+
try {
|
|
68
|
+
design = JSON.parse(fs.readFileSync(source, "utf8").replace(/^/, ""));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return { error: `${source} is not JSON: ${error.message}` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/*
|
|
74
|
+
* A single-frame export is accepted too, as one screen. Someone who ran the
|
|
75
|
+
* wrong menu item should get a folder, not a lecture.
|
|
76
|
+
*/
|
|
77
|
+
const screens = Array.isArray(design.screens)
|
|
78
|
+
? design.screens
|
|
79
|
+
: Array.isArray(design.nodes)
|
|
80
|
+
? [{ name: design.frame || "screen", page: "", nodes: design.nodes }]
|
|
81
|
+
: null;
|
|
82
|
+
|
|
83
|
+
if (!screens) {
|
|
84
|
+
return {
|
|
85
|
+
error:
|
|
86
|
+
"Expected the output of the Figma plugin's 'Export all screens for an agent' " +
|
|
87
|
+
"(or 'Export selection'). This file has neither `screens` nor `nodes`.",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
if (screens.length === 0) return { error: "That export contains no screens." };
|
|
91
|
+
|
|
92
|
+
const out = path.resolve(dest);
|
|
93
|
+
fs.mkdirSync(out, { recursive: true });
|
|
94
|
+
|
|
95
|
+
const tokens = design.tokens || {};
|
|
96
|
+
const colours = new Map((tokens.colours || []).map((c) => [c.value, c.uses]));
|
|
97
|
+
const radii = new Map((tokens.radii || []).map((r) => [r.value, r.uses]));
|
|
98
|
+
const fontSizes = new Map((tokens.fontSizes || []).map((f) => [f.value, f.uses]));
|
|
99
|
+
|
|
100
|
+
const taken = new Set();
|
|
101
|
+
const written = [];
|
|
102
|
+
|
|
103
|
+
for (const screen of screens) {
|
|
104
|
+
// Two screens may share a name across pages; the second gets a suffix
|
|
105
|
+
// rather than quietly overwriting the first.
|
|
106
|
+
let name = slug(screen.name);
|
|
107
|
+
if (taken.has(name)) {
|
|
108
|
+
let n = 2;
|
|
109
|
+
while (taken.has(`${name}-${n}`)) n += 1;
|
|
110
|
+
name = `${name}-${n}`;
|
|
111
|
+
}
|
|
112
|
+
taken.add(name);
|
|
113
|
+
|
|
114
|
+
const fileName = `${name}.json`;
|
|
115
|
+
fs.writeFileSync(
|
|
116
|
+
path.join(out, fileName),
|
|
117
|
+
`${JSON.stringify({ frame: screen.name, nodes: screen.nodes, tokens }, null, 2)}\n`,
|
|
118
|
+
"utf8",
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
written.push({
|
|
122
|
+
name: screen.name,
|
|
123
|
+
page: screen.page || undefined,
|
|
124
|
+
file: fileName,
|
|
125
|
+
size: screen.size,
|
|
126
|
+
nodes: countNodes(screen.nodes),
|
|
127
|
+
repeatedRegions: repeatedIn(screen.nodes),
|
|
128
|
+
componentsNamed: componentsIn(screen.nodes),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* The index is what an agent reads first: every screen, how big it is, the
|
|
134
|
+
* regions whose names repeat, and one theme for all of them. Enough to
|
|
135
|
+
* propose a component for each region before any code is written, which is
|
|
136
|
+
* where a wrong guess is cheap to correct.
|
|
137
|
+
*/
|
|
138
|
+
const index = {
|
|
139
|
+
screens: written,
|
|
140
|
+
theme: themeFrom(colours, radii, fontSizes),
|
|
141
|
+
builtFromLibrary: written.some((s) => s.componentsNamed.length > 0),
|
|
142
|
+
howToUse:
|
|
143
|
+
"Read this index first and say which kit component each repeated region maps to, " +
|
|
144
|
+
"so the mapping can be corrected before any screen is built. Then take one screen " +
|
|
145
|
+
"at a time: figma_frame with designFile set to its file, get_component for each " +
|
|
146
|
+
"component before writing it, install_component for what is missing, and " +
|
|
147
|
+
"validate_usage on the JSX before moving to the next screen.",
|
|
148
|
+
};
|
|
149
|
+
fs.writeFileSync(path.join(out, "index.json"), `${JSON.stringify(index, null, 2)}\n`, "utf8");
|
|
150
|
+
|
|
151
|
+
return { dest: out, index, written };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
module.exports = { splitDesign, slug };
|
package/tools/mcp/figma.cjs
CHANGED
|
@@ -265,6 +265,123 @@ function repeatedNames(node, counts) {
|
|
|
265
265
|
|
|
266
266
|
// ------------------------------------------------------------------- tool
|
|
267
267
|
|
|
268
|
+
/*
|
|
269
|
+
* Repetition is the point, and sending it in full is the waste.
|
|
270
|
+
*
|
|
271
|
+
* A table of eight rows exports as eight siblings of identical shape whose
|
|
272
|
+
* text differs. The agent needs the shape once and the values eight times;
|
|
273
|
+
* on a real screen the other seven copies were 48% of the payload.
|
|
274
|
+
*/
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A node's structure: names, types, nesting and layout.
|
|
278
|
+
*
|
|
279
|
+
* Style is deliberately not part of it. Table rows are zebra-striped, so
|
|
280
|
+
* consecutive rows differ by a background colour and nothing else — keying
|
|
281
|
+
* on style meant every pair looked distinct and no run was ever found. The
|
|
282
|
+
* variation is reported instead of being treated as a different shape.
|
|
283
|
+
*/
|
|
284
|
+
function shapeKey(node) {
|
|
285
|
+
return JSON.stringify([
|
|
286
|
+
node.name,
|
|
287
|
+
node.type,
|
|
288
|
+
node.layout,
|
|
289
|
+
(node.children || []).map(shapeKey),
|
|
290
|
+
]);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The fills down a subtree, so a collapsed run can say how they alternate. */
|
|
294
|
+
function fillsOf(node, into = []) {
|
|
295
|
+
if (node.style && node.style.fill) into.push(node.style.fill);
|
|
296
|
+
for (const child of node.children || []) fillsOf(child, into);
|
|
297
|
+
return into;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Every string in a subtree, in order: what one row of a table actually says. */
|
|
301
|
+
function textsOf(node, into = []) {
|
|
302
|
+
if (node.text) into.push(node.text);
|
|
303
|
+
for (const child of node.children || []) textsOf(child, into);
|
|
304
|
+
return into;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** How many levels deep a subtree goes. */
|
|
308
|
+
const depthOf = (node) =>
|
|
309
|
+
1 + Math.max(0, ...(node.children || []).map(depthOf));
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Collapse runs of same-shaped siblings, and trim to `limit` levels.
|
|
313
|
+
*
|
|
314
|
+
* Only runs of three or more are collapsed: two of a thing is a pair, not a
|
|
315
|
+
* list, and the saving would not pay for the indirection. A run that is only
|
|
316
|
+
* one level deep is left alone too — there is nothing to save in a row of
|
|
317
|
+
* labels, and collapsing it would cost the agent the order they appear in.
|
|
318
|
+
*/
|
|
319
|
+
function condenseTree(nodes, limit, level = 1) {
|
|
320
|
+
const out = [];
|
|
321
|
+
let i = 0;
|
|
322
|
+
|
|
323
|
+
while (i < nodes.length) {
|
|
324
|
+
const node = nodes[i];
|
|
325
|
+
const key = shapeKey(node);
|
|
326
|
+
let run = 1;
|
|
327
|
+
while (i + run < nodes.length && shapeKey(nodes[i + run]) === key) run += 1;
|
|
328
|
+
|
|
329
|
+
const worthIt = run >= 3 && depthOf(node) > 1;
|
|
330
|
+
const trimmed = trim(node, limit, level);
|
|
331
|
+
|
|
332
|
+
if (!worthIt) {
|
|
333
|
+
for (let n = 0; n < run; n += 1) out.push(trim(nodes[i + n], limit, level));
|
|
334
|
+
i += run;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/*
|
|
339
|
+
* The first in full, then the rest as what they say. `repeats` is the
|
|
340
|
+
* count including this one, so an agent reading only this node knows how
|
|
341
|
+
* many rows to render.
|
|
342
|
+
*/
|
|
343
|
+
const rest = [];
|
|
344
|
+
for (let n = 1; n < run; n += 1) rest.push(textsOf(nodes[i + n]));
|
|
345
|
+
|
|
346
|
+
/*
|
|
347
|
+
* Fills are reported only where they actually differ across the run.
|
|
348
|
+
* Where they do it is nearly always zebra striping, and two colours
|
|
349
|
+
* said once is the whole of it.
|
|
350
|
+
*/
|
|
351
|
+
const fills = [];
|
|
352
|
+
for (let n = 0; n < run; n += 1) fills.push(fillsOf(nodes[i + n]).join(","));
|
|
353
|
+
const varies = new Set(fills).size > 1;
|
|
354
|
+
|
|
355
|
+
out.push({
|
|
356
|
+
...trimmed,
|
|
357
|
+
repeats: run,
|
|
358
|
+
repeatsNote:
|
|
359
|
+
`${run} siblings share this structure. The first is given in full; ` +
|
|
360
|
+
"`repeatedText` holds what each of the others says, in the same order " +
|
|
361
|
+
"as the text in this one." +
|
|
362
|
+
(varies ? " `repeatedFills` gives each one's colours, which alternate." : ""),
|
|
363
|
+
repeatedText: rest,
|
|
364
|
+
...(varies ? { repeatedFills: fills.map((f) => f.split(",")) } : {}),
|
|
365
|
+
});
|
|
366
|
+
i += run;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Drop everything below `limit` levels, saying what was dropped. */
|
|
373
|
+
function trim(node, limit, level) {
|
|
374
|
+
if (!node.children || node.children.length === 0) return node;
|
|
375
|
+
if (level >= limit) {
|
|
376
|
+
const inside = node.children.reduce(
|
|
377
|
+
(n, child) => n + 1 + (child.children ? child.children.length : 0),
|
|
378
|
+
0,
|
|
379
|
+
);
|
|
380
|
+
const { children, ...rest } = node;
|
|
381
|
+
return { ...rest, truncated: `${inside}+ nodes below this, not shown` };
|
|
382
|
+
}
|
|
383
|
+
return { ...node, children: condenseTree(node.children, limit, level + 1) };
|
|
384
|
+
}
|
|
268
385
|
/*
|
|
269
386
|
* A selection exported by the plugin, which has already done the condensing
|
|
270
387
|
* inside Figma. The work left is the part that needs no file access: giving
|
|
@@ -273,7 +390,7 @@ function repeatedNames(node, counts) {
|
|
|
273
390
|
* This is the path for anyone who would rather not hold an API token, and
|
|
274
391
|
* for files the token cannot reach.
|
|
275
392
|
*/
|
|
276
|
-
function fromPluginExport(design) {
|
|
393
|
+
function fromPluginExport(design, depth) {
|
|
277
394
|
let parsed;
|
|
278
395
|
try {
|
|
279
396
|
parsed = typeof design === "string" ? JSON.parse(design) : design;
|
|
@@ -311,6 +428,14 @@ function fromPluginExport(design) {
|
|
|
311
428
|
};
|
|
312
429
|
for (const node of parsed.nodes) findInstances(node);
|
|
313
430
|
|
|
431
|
+
/*
|
|
432
|
+
* `depth` was offered on this tool and only ever honoured for a URL. A
|
|
433
|
+
* caller who read the schema and lowered it to cut a large payload got
|
|
434
|
+
* the large payload back.
|
|
435
|
+
*/
|
|
436
|
+
const limit = Math.min(Number(depth) || MAX_DEPTH, MAX_DEPTH);
|
|
437
|
+
const tree = condenseTree(parsed.nodes, limit);
|
|
438
|
+
|
|
314
439
|
return {
|
|
315
440
|
source: "figma plugin export",
|
|
316
441
|
frame: parsed.frame,
|
|
@@ -322,13 +447,63 @@ function fromPluginExport(design) {
|
|
|
322
447
|
.map(([name, n]) => ({ name, times: n })),
|
|
323
448
|
builtFromLibrary: instances.length > 0,
|
|
324
449
|
componentsNamed: [...new Set(instances)],
|
|
325
|
-
tree
|
|
450
|
+
tree,
|
|
451
|
+
/*
|
|
452
|
+
* The same closing instruction the URL route gives. Without it an agent
|
|
453
|
+
* arriving by the plugin — now the route the documentation leads with —
|
|
454
|
+
* got the tree and no word on what to do when nothing names a component,
|
|
455
|
+
* which is exactly when it starts approximating.
|
|
456
|
+
*/
|
|
457
|
+
howToUse: instances.length
|
|
458
|
+
? "This design uses component instances; `component` names the master for each."
|
|
459
|
+
: "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.",
|
|
326
460
|
};
|
|
327
461
|
}
|
|
328
462
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
463
|
+
/**
|
|
464
|
+
* The plugin's export, read from a file.
|
|
465
|
+
*
|
|
466
|
+
* Worth preferring over `design` for anything but a small frame, because a
|
|
467
|
+
* chat input caps at around fifty thousand characters and a whole screen
|
|
468
|
+
* passes that.
|
|
469
|
+
*
|
|
470
|
+
* It halves the context too, though not more: pasted, the export is in the
|
|
471
|
+
* conversation twice — once as the argument and again inside the tool call
|
|
472
|
+
* — where from a file it appears only in the answer. The tree still comes
|
|
473
|
+
* back in full, because the tree is what the agent builds from. The plugin
|
|
474
|
+
* has already condensed it; there is nothing further to drop that the
|
|
475
|
+
* screen does not need.
|
|
476
|
+
*/
|
|
477
|
+
function fromFile(file, depth) {
|
|
478
|
+
let resolved;
|
|
479
|
+
try {
|
|
480
|
+
resolved = path.resolve(file);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
return { error: `Not a usable path: ${file}` };
|
|
483
|
+
}
|
|
484
|
+
if (!fs.existsSync(resolved)) {
|
|
485
|
+
return {
|
|
486
|
+
error:
|
|
487
|
+
`No file at ${resolved}. Save the plugin's export first — in Figma, ` +
|
|
488
|
+
"Grampro Kit -> Export selection for an agent, then Save .json.",
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
let body;
|
|
492
|
+
try {
|
|
493
|
+
body = fs.readFileSync(resolved, "utf8");
|
|
494
|
+
} catch (error) {
|
|
495
|
+
return { error: `Could not read ${resolved}: ${error.message}` };
|
|
496
|
+
}
|
|
497
|
+
// A byte-order mark survives a round trip through some editors and is not
|
|
498
|
+
// valid JSON, so it goes before parsing rather than after a confusing throw.
|
|
499
|
+
return fromPluginExport(body.replace(/^\uFEFF/, ""), depth);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async function figmaFrame({ url, design, designFile, frame, depth }) {
|
|
503
|
+
// Both export routes need no token, so they are offered before one is
|
|
504
|
+
// demanded. The file comes first: it is the one that scales.
|
|
505
|
+
if (designFile) return fromFile(designFile, depth);
|
|
506
|
+
if (design) return fromPluginExport(design, depth);
|
|
332
507
|
|
|
333
508
|
const token = figmaToken();
|
|
334
509
|
if (!token) {
|
package/tools/mcp/server.cjs
CHANGED
|
@@ -371,7 +371,7 @@ const TOOLS = [
|
|
|
371
371
|
{
|
|
372
372
|
name: "figma_frame",
|
|
373
373
|
description:
|
|
374
|
-
"BETA. Read a Figma design
|
|
374
|
+
"BETA. Read a Figma design: from a URL (needs FIGMA_TOKEN), from a file the kit's Figma plugin saved (`designFile`, no token, and the one to use for a whole screen), or from that export pasted in (`design`). Called with just a URL it lists the frames in the file; called with `frame` it returns that screen's structure, text, the repeated regions that indicate lists and tables, and the theme variables its colours and radii map onto.",
|
|
375
375
|
inputSchema: {
|
|
376
376
|
type: "object",
|
|
377
377
|
properties: {
|
|
@@ -379,7 +379,12 @@ const TOOLS = [
|
|
|
379
379
|
design: {
|
|
380
380
|
type: "string",
|
|
381
381
|
description:
|
|
382
|
-
"Alternative to url, needing no token: JSON from the kit's Figma plugin, 'Export selection for an agent'.",
|
|
382
|
+
"Alternative to url, needing no token: JSON from the kit's Figma plugin, 'Export selection for an agent'. For anything but a small frame, prefer designFile.",
|
|
383
|
+
},
|
|
384
|
+
designFile: {
|
|
385
|
+
type: "string",
|
|
386
|
+
description:
|
|
387
|
+
"Path to a .json saved by the plugin's 'Save .json' button. Preferred over `design`: the file is read here, so the raw export never passes through the conversation.",
|
|
383
388
|
},
|
|
384
389
|
frame: { type: "string", description: "Which frame to read, from the listing." },
|
|
385
390
|
depth: { type: "number", description: "How deep to descend. Lower it if the payload is large." },
|