@grampro/kit 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +25 -5
  3. package/index.cjs +32 -1
  4. package/package.json +1 -1
  5. package/source/components/accordion/passport.json +1 -1
  6. package/source/components/alert/passport.json +1 -1
  7. package/source/components/avatar/passport.json +1 -1
  8. package/source/components/badge/passport.json +1 -1
  9. package/source/components/breadcrumb/passport.json +1 -1
  10. package/source/components/button/passport.json +1 -1
  11. package/source/components/card/passport.json +1 -1
  12. package/source/components/checkbox/passport.json +1 -1
  13. package/source/components/combobox/passport.json +1 -1
  14. package/source/components/data-grid/passport.json +1 -1
  15. package/source/components/date-picker/passport.json +1 -1
  16. package/source/components/dialog/passport.json +1 -1
  17. package/source/components/file-uploader/passport.json +1 -1
  18. package/source/components/input/passport.json +1 -1
  19. package/source/components/menu/passport.json +1 -1
  20. package/source/components/modal/passport.json +1 -1
  21. package/source/components/number-input/passport.json +1 -1
  22. package/source/components/popover/passport.json +1 -1
  23. package/source/components/progress/passport.json +1 -1
  24. package/source/components/radio-group/passport.json +1 -1
  25. package/source/components/skeleton/passport.json +1 -1
  26. package/source/components/spinner/passport.json +1 -1
  27. package/source/components/switch/passport.json +1 -1
  28. package/source/components/tabs/passport.json +1 -1
  29. package/source/components/textarea/passport.json +1 -1
  30. package/source/components/toaster/passport.json +1 -1
  31. package/source/components/tooltip/passport.json +1 -1
  32. package/tools/mcp/README.md +37 -2
  33. package/tools/mcp/design.cjs +154 -0
  34. package/tools/mcp/figma.cjs +52 -2
  35. package/tools/mcp/server.cjs +7 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.6.0
4
+
5
+ ### Added
6
+
7
+ **A whole Figma design, exported once and built screen by screen.**
8
+
9
+ In Figma, **Grampro Kit → Export all screens for an agent** walks every page
10
+ and takes each top-level frame. Then:
11
+
12
+ ```bash
13
+ npx @grampro/kit design ./design.json
14
+ ```
15
+
16
+ ```text
17
+ Wrote 3 screen(s) to design/
18
+ purchase-orders.json Purchase orders (Product) — 22 nodes
19
+ suppliers.json Suppliers (Product) — 16 nodes
20
+ settings.json Settings (Admin) — 12 nodes
21
+ index.json the plan: every screen, shared theme
22
+ ```
23
+
24
+ The accuracy is in the theme rather than the convenience. Derived one
25
+ frame at a time it disagrees with itself — a screen that is mostly white
26
+ names a different background from one that is mostly grey — and the agent
27
+ then themes each page differently. Read across every screen, once, it is
28
+ well founded.
29
+
30
+ `index.json` is the plan: every screen with its size, node count and the
31
+ regions whose names repeat, plus that shared theme. It is the cheap place
32
+ to be wrong. A design drawn without the component library names nothing
33
+ directly, so every component is inferred, and a guess that `Action` is a
34
+ Card rather than a Button costs one sentence there and five screens of
35
+ rework later.
36
+
37
+ **`figma_frame` reads a file.** Pass `designFile` with a path instead of
38
+ `design` with the JSON. A chat input caps at around fifty thousand
39
+ characters and a whole screen passes that; and a pasted export sits in the
40
+ conversation twice, once as the argument and again inside the tool call,
41
+ where a file appears only in the answer.
42
+
43
+ **Every prop the library declares now carries a description.** 117 did
44
+ not, and the passports are what an agent reads instead of the source, so
45
+ an undescribed prop is one it guesses at. `DataGrid`'s `columns` and
46
+ `data` had none at all. See 2.5.0's notes — this is that work, reaching
47
+ npm for the first time.
48
+
49
+ ### Changed
50
+
51
+ **The Figma export costs a third of what it did.** The copy button takes
52
+ minified JSON: `JSON.parse` cannot tell the difference, and indentation
53
+ was most of the payload. Three fields that said nothing went with it —
54
+ `radius: 0` on every square frame, `padding: [0,0,0,0]` on every frame
55
+ without padding, and `align`/`justify` restating Figma's own defaults —
56
+ along with the child frame Figma wraps around every icon, whose entire
57
+ content was the word "Vector" when the frame's name (`search`, `plus`)
58
+ was the whole signal.
59
+
60
+ **It also says more about what it exports.** Node size and stroke colour
61
+ are now included, because most designs are not built from the kit's Figma
62
+ library and an agent has to infer every component from what it can see. A
63
+ 36px pill with a label is a button; the same shape at 200px is a card. A
64
+ white box with a border is a secondary button; without one it is a
65
+ surface. Neither was visible before.
66
+
67
+ ### Fixed
68
+
69
+ **The design system generator laid out over itself.** Variant grids were
70
+ placed by column count without measuring whether the row fitted, so 14 of
71
+ 38 component sets overflowed the card holding them — Alert reached 2444px
72
+ inside a 1320px section.
73
+
74
+ **Text that was told a width kept its own.** Figma creates a text node
75
+ auto-width, and setting it to fill is quietly ignored, so 23 layers across
76
+ the file measured themselves from their own characters. NumberInput's
77
+ value layer, created empty and filled in later by a component property,
78
+ wrapped one glyph per line.
79
+
80
+ **Alert's coloured edge did not match its card**, being sized before the
81
+ card had any content and never clipped to its rounded corners. Toast drew
82
+ the same edge with the same fault.
83
+
84
+ **The uploader thumbnails and the dialog's intent badge rendered at full
85
+ saturation**, which also hid the icon drawn on them in the same colour.
86
+
87
+ **The plugin's name displayed as mojibake** in Figma's menu, and its
88
+ build was never committed despite its README saying so, so a clone could
89
+ not run it.
90
+
3
91
  ## 2.5.0
4
92
 
5
93
  ### 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
- For agents that speak MCP, the catalogue is also served on demand:
99
+ The catalogue, served to a coding agent on demand over the Model Context
100
+ Protocol:
96
101
 
97
102
  ```bash
98
- claude mcp add grampro -- npx -y @grampro/kit mcp
103
+ npx @grampro/kit mcp install
99
104
  ```
100
105
 
101
- It reads a Figma design, installs what it needs, answers what exists, each component's full contract, working examples, and
102
- whether the JSX an agent just wrote actually matches the component it used.
103
- No dependencies, no configuration. See `tools/mcp/README.md`.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grampro/kit",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "GramproKit \u2014 an agent-native UI runtime for React. Copy-in components you own, each publishing a contract that software can read.",
5
5
  "type": "module",
6
6
  "files": [
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "source": {
13
- "libraryVersion": "2.5.0",
13
+ "libraryVersion": "2.6.0",
14
14
  "files": [
15
15
  "core/index.ts",
16
16
  "core/open.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.5.0",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/live.ts",
@@ -9,7 +9,7 @@
9
9
  ]
10
10
  },
11
11
  "source": {
12
- "libraryVersion": "2.5.0",
12
+ "libraryVersion": "2.6.0",
13
13
  "files": [
14
14
  "core/identity.ts",
15
15
  "core/index.ts",
@@ -9,7 +9,7 @@
9
9
  ]
10
10
  },
11
11
  "source": {
12
- "libraryVersion": "2.5.0",
12
+ "libraryVersion": "2.6.0",
13
13
  "files": [
14
14
  "core/count.ts",
15
15
  "core/index.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.5.0",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/trail.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.5.0",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/state.ts",
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  "source": {
15
- "libraryVersion": "2.5.0",
15
+ "libraryVersion": "2.6.0",
16
16
  "files": [
17
17
  "core/index.ts",
18
18
  "core/trend.ts",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "source": {
13
- "libraryVersion": "2.5.0",
13
+ "libraryVersion": "2.6.0",
14
14
  "files": [
15
15
  "core/group.ts",
16
16
  "core/index.ts",
@@ -9,7 +9,7 @@
9
9
  ]
10
10
  },
11
11
  "source": {
12
- "libraryVersion": "2.5.0",
12
+ "libraryVersion": "2.6.0",
13
13
  "files": [
14
14
  "core/filter.ts",
15
15
  "core/index.ts",
@@ -14,7 +14,7 @@
14
14
  ]
15
15
  },
16
16
  "source": {
17
- "libraryVersion": "2.5.0",
17
+ "libraryVersion": "2.6.0",
18
18
  "files": [
19
19
  "agent/coerce.ts",
20
20
  "agent/contract.ts",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "source": {
13
- "libraryVersion": "2.5.0",
13
+ "libraryVersion": "2.6.0",
14
14
  "files": [
15
15
  "core/calendar.ts",
16
16
  "core/format.ts",
@@ -9,7 +9,7 @@
9
9
  ]
10
10
  },
11
11
  "source": {
12
- "libraryVersion": "2.5.0",
12
+ "libraryVersion": "2.6.0",
13
13
  "files": [
14
14
  "core/api.ts",
15
15
  "core/dialog.ts",
@@ -13,7 +13,7 @@
13
13
  ]
14
14
  },
15
15
  "source": {
16
- "libraryVersion": "2.5.0",
16
+ "libraryVersion": "2.6.0",
17
17
  "files": [
18
18
  "core/format.ts",
19
19
  "core/index.ts",
@@ -9,7 +9,7 @@
9
9
  ]
10
10
  },
11
11
  "source": {
12
- "libraryVersion": "2.5.0",
12
+ "libraryVersion": "2.6.0",
13
13
  "files": [
14
14
  "core/count.ts",
15
15
  "core/index.ts",
@@ -16,7 +16,7 @@
16
16
  ]
17
17
  },
18
18
  "source": {
19
- "libraryVersion": "2.5.0",
19
+ "libraryVersion": "2.6.0",
20
20
  "files": [
21
21
  "core/index.ts",
22
22
  "core/navigation.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.5.0",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/dismiss.ts",
14
14
  "core/index.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.5.0",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/format.ts",
14
14
  "core/index.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.5.0",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/types.ts",
@@ -9,7 +9,7 @@
9
9
  ]
10
10
  },
11
11
  "source": {
12
- "libraryVersion": "2.5.0",
12
+ "libraryVersion": "2.6.0",
13
13
  "files": [
14
14
  "core/index.ts",
15
15
  "core/progress.ts",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "source": {
13
- "libraryVersion": "2.5.0",
13
+ "libraryVersion": "2.6.0",
14
14
  "files": [
15
15
  "core/group.ts",
16
16
  "core/index.ts",
@@ -9,7 +9,7 @@
9
9
  ]
10
10
  },
11
11
  "source": {
12
- "libraryVersion": "2.5.0",
12
+ "libraryVersion": "2.6.0",
13
13
  "files": [
14
14
  "core/index.ts",
15
15
  "core/lines.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.5.0",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/types.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.5.0",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/toggle.ts",
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  "source": {
15
- "libraryVersion": "2.5.0",
15
+ "libraryVersion": "2.6.0",
16
16
  "files": [
17
17
  "core/index.ts",
18
18
  "core/navigation.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.5.0",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/count.ts",
14
14
  "core/index.ts",
@@ -11,7 +11,7 @@
11
11
  ]
12
12
  },
13
13
  "source": {
14
- "libraryVersion": "2.5.0",
14
+ "libraryVersion": "2.6.0",
15
15
  "files": [
16
16
  "core/api.ts",
17
17
  "core/index.ts",
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.5.0",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/types.ts",
@@ -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
- Two routes, and the second needs no credentials.
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 };
@@ -323,11 +323,61 @@ function fromPluginExport(design) {
323
323
  builtFromLibrary: instances.length > 0,
324
324
  componentsNamed: [...new Set(instances)],
325
325
  tree: parsed.nodes,
326
+ /*
327
+ * The same closing instruction the URL route gives. Without it an agent
328
+ * arriving by the plugin — now the route the documentation leads with —
329
+ * got the tree and no word on what to do when nothing names a component,
330
+ * which is exactly when it starts approximating.
331
+ */
332
+ howToUse: instances.length
333
+ ? "This design uses component instances; `component` names the master for each."
334
+ : "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
335
  };
327
336
  }
328
337
 
329
- async function figmaFrame({ url, design, frame, depth }) {
330
- // The export needs no token, so it is offered before one is demanded.
338
+ /**
339
+ * The plugin's export, read from a file.
340
+ *
341
+ * Worth preferring over `design` for anything but a small frame, because a
342
+ * chat input caps at around fifty thousand characters and a whole screen
343
+ * passes that.
344
+ *
345
+ * It halves the context too, though not more: pasted, the export is in the
346
+ * conversation twice — once as the argument and again inside the tool call
347
+ * — where from a file it appears only in the answer. The tree still comes
348
+ * back in full, because the tree is what the agent builds from. The plugin
349
+ * has already condensed it; there is nothing further to drop that the
350
+ * screen does not need.
351
+ */
352
+ function fromFile(file) {
353
+ let resolved;
354
+ try {
355
+ resolved = path.resolve(file);
356
+ } catch (error) {
357
+ return { error: `Not a usable path: ${file}` };
358
+ }
359
+ if (!fs.existsSync(resolved)) {
360
+ return {
361
+ error:
362
+ `No file at ${resolved}. Save the plugin's export first — in Figma, ` +
363
+ "Grampro Kit -> Export selection for an agent, then Save .json.",
364
+ };
365
+ }
366
+ let body;
367
+ try {
368
+ body = fs.readFileSync(resolved, "utf8");
369
+ } catch (error) {
370
+ return { error: `Could not read ${resolved}: ${error.message}` };
371
+ }
372
+ // A byte-order mark survives a round trip through some editors and is not
373
+ // valid JSON, so it goes before parsing rather than after a confusing throw.
374
+ return fromPluginExport(body.replace(/^\uFEFF/, ""));
375
+ }
376
+
377
+ async function figmaFrame({ url, design, designFile, frame, depth }) {
378
+ // Both export routes need no token, so they are offered before one is
379
+ // demanded. The file comes first: it is the one that scales.
380
+ if (designFile) return fromFile(designFile);
331
381
  if (design) return fromPluginExport(design);
332
382
 
333
383
  const token = figmaToken();
@@ -371,7 +371,7 @@ const TOOLS = [
371
371
  {
372
372
  name: "figma_frame",
373
373
  description:
374
- "BETA. Read a Figma design, from a URL (needs FIGMA_TOKEN) or from the plugin export (no token). 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. Needs FIGMA_TOKEN.",
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." },