@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +138 -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 +180 -5
  35. 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
- 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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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.1",
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 };
@@ -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: parsed.nodes,
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
- async function figmaFrame({ url, design, frame, depth }) {
330
- // The export needs no token, so it is offered before one is demanded.
331
- if (design) return fromPluginExport(design);
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) {
@@ -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." },