@grampro/kit 2.4.4 → 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 (74) hide show
  1. package/CHANGELOG.md +257 -0
  2. package/README.md +25 -5
  3. package/index.cjs +70 -4
  4. package/package.json +1 -1
  5. package/source/components/accordion/passport.json +11 -7
  6. package/source/components/accordion/react/Accordion.tsx +5 -0
  7. package/source/components/alert/passport.json +3 -3
  8. package/source/components/avatar/passport.json +8 -6
  9. package/source/components/avatar/react/Avatar.tsx +4 -0
  10. package/source/components/badge/passport.json +8 -6
  11. package/source/components/badge/react/Badge.tsx +1 -0
  12. package/source/components/badge/react/Tag.tsx +1 -0
  13. package/source/components/breadcrumb/passport.json +8 -6
  14. package/source/components/breadcrumb/react/Breadcrumb.tsx +1 -0
  15. package/source/components/button/passport.json +8 -6
  16. package/source/components/button/react/Button.tsx +1 -0
  17. package/source/components/card/passport.json +17 -9
  18. package/source/components/card/react/Card.tsx +5 -0
  19. package/source/components/card/react/Stat.tsx +5 -0
  20. package/source/components/checkbox/passport.json +32 -14
  21. package/source/components/checkbox/react/Checkbox.tsx +17 -0
  22. package/source/components/checkbox/react/CheckboxGroup.tsx +6 -0
  23. package/source/components/combobox/passport.json +36 -16
  24. package/source/components/combobox/react/Select.tsx +6 -1
  25. package/source/components/combobox/react/props.ts +23 -0
  26. package/source/components/data-grid/core/types.ts +53 -0
  27. package/source/components/data-grid/passport.json +82 -30
  28. package/source/components/data-grid/react/AskGrid.tsx +10 -0
  29. package/source/components/data-grid/react/DataGrid.tsx +16 -0
  30. package/source/components/date-picker/passport.json +30 -14
  31. package/source/components/date-picker/react/DatePicker.tsx +5 -0
  32. package/source/components/date-picker/react/props.ts +13 -1
  33. package/source/components/dialog/core/types.ts +14 -0
  34. package/source/components/dialog/passport.json +26 -12
  35. package/source/components/dialog/react/Dialog.tsx +4 -0
  36. package/source/components/file-uploader/passport.json +44 -18
  37. package/source/components/file-uploader/react/FileUploader.tsx +39 -0
  38. package/source/components/input/passport.json +14 -8
  39. package/source/components/input/react/Input.tsx +3 -0
  40. package/source/components/menu/passport.json +29 -13
  41. package/source/components/menu/react/Menu.tsx +7 -0
  42. package/source/components/menu/react/items.tsx +4 -0
  43. package/source/components/modal/passport.json +14 -8
  44. package/source/components/modal/react/Modal.tsx +6 -0
  45. package/source/components/number-input/passport.json +23 -11
  46. package/source/components/number-input/react/NumberInput.tsx +13 -0
  47. package/source/components/popover/passport.json +17 -9
  48. package/source/components/popover/react/Popover.tsx +7 -0
  49. package/source/components/progress/passport.json +3 -3
  50. package/source/components/radio-group/passport.json +32 -14
  51. package/source/components/radio-group/react/Radio.tsx +17 -0
  52. package/source/components/radio-group/react/RadioGroup.tsx +13 -0
  53. package/source/components/skeleton/passport.json +8 -6
  54. package/source/components/skeleton/react/Skeleton.tsx +5 -0
  55. package/source/components/spinner/passport.json +3 -3
  56. package/source/components/switch/passport.json +23 -11
  57. package/source/components/switch/react/Switch.tsx +14 -0
  58. package/source/components/tabs/passport.json +11 -7
  59. package/source/components/tabs/react/Tabs.tsx +2 -0
  60. package/source/components/textarea/passport.json +14 -8
  61. package/source/components/textarea/react/Textarea.tsx +3 -0
  62. package/source/components/toaster/passport.json +8 -6
  63. package/source/components/toaster/react/Toaster.tsx +4 -0
  64. package/source/components/tooltip/passport.json +3 -3
  65. package/tools/mcp/README.md +163 -36
  66. package/tools/mcp/design.cjs +154 -0
  67. package/tools/mcp/figma.cjs +136 -29
  68. package/tools/mcp/install.cjs +270 -0
  69. package/tools/mcp/secret-cli.cjs +174 -0
  70. package/tools/mcp/secret.cjs +289 -0
  71. package/tools/mcp/server.cjs +60 -16
  72. package/tools/passport/cli.cjs +8 -1
  73. package/tools/passport/extract.cjs +7 -3
  74. package/tools/passport/merge.cjs +24 -4
@@ -12,7 +12,7 @@
12
12
  ]
13
13
  },
14
14
  "source": {
15
- "libraryVersion": "2.4.4",
15
+ "libraryVersion": "2.6.0",
16
16
  "files": [
17
17
  "core/index.ts",
18
18
  "core/navigation.ts",
@@ -21,7 +21,7 @@
21
21
  "react/Tabs.tsx",
22
22
  "react/context.ts"
23
23
  ],
24
- "sourceHash": "sha256:af648a096e1a1c4e3f61c57b4e61a6c115c3d2351d5f2c8c6b4519348ae80dfc"
24
+ "sourceHash": "sha256:3d8e29857c59101e547f234c45735db68cc87ee143cc87d841372ec612e2c663"
25
25
  },
26
26
  "props": [
27
27
  {
@@ -124,7 +124,9 @@
124
124
  "type": "other",
125
125
  "typeText": "ReactNode",
126
126
  "required": false,
127
- "origin": "derived"
127
+ "origin": "derived",
128
+ "description": "An icon before the tab's text.",
129
+ "descriptionOrigin": "jsdoc"
128
130
  },
129
131
  {
130
132
  "name": "id",
@@ -155,7 +157,9 @@
155
157
  "type": "function",
156
158
  "signature": "((value: string) => void) | undefined",
157
159
  "required": false,
158
- "origin": "derived"
160
+ "origin": "derived",
161
+ "description": "The newly selected tab. Not fired when the same tab is clicked again.",
162
+ "descriptionOrigin": "jsdoc"
159
163
  },
160
164
  {
161
165
  "name": "orientation",
@@ -305,9 +309,9 @@
305
309
  "safeMutations": {},
306
310
  "examples": [],
307
311
  "coverage": {
308
- "propsTotal": 25,
309
- "propsDescribed": 7,
310
- "describedPct": 28,
312
+ "propsTotal": 11,
313
+ "propsDescribed": 9,
314
+ "describedPct": 82,
311
315
  "authoredSections": [
312
316
  "accessibility"
313
317
  ]
@@ -33,6 +33,7 @@ export interface TabsProps extends Omit<
33
33
  value?: string;
34
34
  /** Selected tab at first. Without either, the first enabled tab is selected. */
35
35
  defaultValue?: string;
36
+ /** The newly selected tab. Not fired when the same tab is clicked again. */
36
37
  onValueChange?(value: string): void;
37
38
  /** Default `horizontal`. */
38
39
  orientation?: TabsOrientation;
@@ -221,6 +222,7 @@ export interface TabProps extends Omit<
221
222
  > {
222
223
  /** Identifies the tab and its panel. */
223
224
  value: string;
225
+ /** An icon before the tab's text. */
224
226
  icon?: ReactNode;
225
227
  /** A small count or label after the text, e.g. unread items. */
226
228
  badge?: ReactNode;
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.4.4",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/count.ts",
14
14
  "core/index.ts",
@@ -19,7 +19,7 @@
19
19
  "react/locale.ts",
20
20
  "react/props.ts"
21
21
  ],
22
- "sourceHash": "sha256:35a538fdd2522c310be181bbb598fdc6eebb99fc5dd1a0ffab3cccf8d84ac43a"
22
+ "sourceHash": "sha256:a586004558fc7c4059756daa252c36028f3f9b1f4cf55fcccb9b5af2ce1e21e9"
23
23
  },
24
24
  "props": [
25
25
  {
@@ -93,7 +93,9 @@
93
93
  "type": "other",
94
94
  "typeText": "ReactNode",
95
95
  "required": false,
96
- "origin": "derived"
96
+ "origin": "derived",
97
+ "description": "A hint under the label, linked as the field's description.",
98
+ "descriptionOrigin": "jsdoc"
97
99
  },
98
100
  {
99
101
  "name": "disabled",
@@ -137,7 +139,9 @@
137
139
  "type": "other",
138
140
  "typeText": "ReactNode",
139
141
  "required": false,
140
- "origin": "derived"
142
+ "origin": "derived",
143
+ "description": "The field's name, shown above it and linked to it.",
144
+ "descriptionOrigin": "jsdoc"
141
145
  },
142
146
  {
143
147
  "name": "localeText",
@@ -267,7 +271,9 @@
267
271
  "typeText": "FieldSize | undefined",
268
272
  "required": false,
269
273
  "default": "md",
270
- "origin": "derived"
274
+ "origin": "derived",
275
+ "description": "Default `md`.",
276
+ "descriptionOrigin": "jsdoc"
271
277
  },
272
278
  {
273
279
  "name": "style",
@@ -372,9 +378,9 @@
372
378
  "safeMutations": {},
373
379
  "examples": [],
374
380
  "coverage": {
375
- "propsTotal": 35,
376
- "propsDescribed": 8,
377
- "describedPct": 23,
381
+ "propsTotal": 12,
382
+ "propsDescribed": 10,
383
+ "describedPct": 83,
378
384
  "authoredSections": [
379
385
  "accessibility"
380
386
  ]
@@ -28,10 +28,13 @@ export interface TextareaProps
28
28
  defaultValue?: string;
29
29
  /** The new text on every change. The native `onChange` fires as well. */
30
30
  onValueChange?(value: string): void;
31
+ /** The field's name, shown above it and linked to it. */
31
32
  label?: ReactNode;
33
+ /** A hint under the label, linked as the field's description. */
32
34
  description?: ReactNode;
33
35
  /** Message below the field; also marks it invalid. */
34
36
  error?: ReactNode;
37
+ /** Default `md`. */
35
38
  size?: FieldSize;
36
39
  /** Grow and shrink with the text, between `minRows` and `maxRows`. Default false. */
37
40
  autoResize?: boolean;
@@ -11,7 +11,7 @@
11
11
  ]
12
12
  },
13
13
  "source": {
14
- "libraryVersion": "2.4.4",
14
+ "libraryVersion": "2.6.0",
15
15
  "files": [
16
16
  "core/api.ts",
17
17
  "core/index.ts",
@@ -26,7 +26,7 @@
26
26
  "react/props.ts",
27
27
  "react/useToasts.ts"
28
28
  ],
29
- "sourceHash": "sha256:ac828195f4d7ced89c99eb2bd78bb2581ba7a81d62dcfb892b9acef75bddda77"
29
+ "sourceHash": "sha256:976553d5d606c12b441caba76badad73812eea6dde400b51eb3ee17d05db7871"
30
30
  },
31
31
  "props": [
32
32
  {
@@ -63,7 +63,9 @@
63
63
  ],
64
64
  "typeText": "'ltr' | 'rtl' | 'auto' | undefined",
65
65
  "required": false,
66
- "origin": "derived"
66
+ "origin": "derived",
67
+ "description": "Writing direction, which decides which corner `position` means.\nDefault `auto`, taken from the document.",
68
+ "descriptionOrigin": "jsdoc"
67
69
  },
68
70
  {
69
71
  "name": "duration",
@@ -211,9 +213,9 @@
211
213
  "safeMutations": {},
212
214
  "examples": [],
213
215
  "coverage": {
214
- "propsTotal": 12,
215
- "propsDescribed": 7,
216
- "describedPct": 58,
216
+ "propsTotal": 8,
217
+ "propsDescribed": 8,
218
+ "describedPct": 100,
217
219
  "authoredSections": [
218
220
  "accessibility"
219
221
  ]
@@ -36,6 +36,10 @@ export interface ToasterProps {
36
36
  icons?: Partial<Record<ToastType, ReactNode>>;
37
37
  /** A store from `createToastStore()`. Defaults to the one behind `toast()`. */
38
38
  store?: ToastStore;
39
+ /**
40
+ * Writing direction, which decides which corner `position` means.
41
+ * Default `auto`, taken from the document.
42
+ */
39
43
  dir?: "ltr" | "rtl" | "auto";
40
44
  className?: string;
41
45
  classNames?: Partial<Record<ToasterSlot, string>>;
@@ -8,7 +8,7 @@
8
8
  ]
9
9
  },
10
10
  "source": {
11
- "libraryVersion": "2.4.4",
11
+ "libraryVersion": "2.6.0",
12
12
  "files": [
13
13
  "core/index.ts",
14
14
  "core/types.ts",
@@ -160,9 +160,9 @@
160
160
  "safeMutations": {},
161
161
  "examples": [],
162
162
  "coverage": {
163
- "propsTotal": 12,
163
+ "propsTotal": 9,
164
164
  "propsDescribed": 7,
165
- "describedPct": 58,
165
+ "describedPct": 78,
166
166
  "authoredSections": [
167
167
  "accessibility"
168
168
  ]
@@ -4,31 +4,90 @@ Serves the component catalogue to a coding agent, so it writes against the
4
4
  real contract instead of recalling one.
5
5
 
6
6
  > **Beta.** The tools work and are tested, but their names and shapes may
7
- > change before this settles. The Figma token handling in particular is not
8
- > where it should be — see *Credentials* below.
7
+ > change before this settles.
8
+
9
+ ## Setup
10
+
11
+ ```bash
12
+ npx @grampro/kit mcp install
13
+ ```
14
+
15
+ That writes the configuration for every agent it knows about — Claude Code,
16
+ Codex and Cursor — and tells you which files it touched. Restrict it with
17
+ `--for codex` (or `claude`, `cursor`, or a comma-separated list).
18
+
19
+ It is worth using rather than editing the files by hand: it writes UTF-8
20
+ without a byte-order mark and with LF endings, which is the one thing that
21
+ reliably goes wrong. PowerShell's `Out-File -Encoding utf8` adds a BOM, and
22
+ every JSON parser then rejects the file with an error that names the first
23
+ character and explains nothing.
24
+
25
+ Running it again is safe. It merges into whatever is already there, leaves
26
+ other servers alone, and says `unchanged` when there is nothing to do.
27
+
28
+ It writes to the project root — the nearest folder up from you with a `.git`
29
+ or a `package.json` — and prints which one it chose, because an agent reads
30
+ these files from the folder it has open and writing to a subdirectory is the
31
+ one way this can quietly do nothing.
32
+
33
+ ### If the server does not appear
34
+
35
+ The file being written is not the same as the server being loaded, and in
36
+ Claude Code two things stand between them.
37
+
38
+ **It needs approving.** A `.mcp.json` server is project-scoped, and Claude
39
+ Code asks before trusting one — until you say yes it sits at `Pending
40
+ approval`, and nothing asks twice. `claude mcp reset-project-choices` brings
41
+ the prompt back if it was dismissed.
42
+
43
+ **It needs a fresh session.** The configuration is read when a session
44
+ starts, so an editor window that was already open sees nothing. In the VS
45
+ Code extension, run *Developer: Reload Window*.
46
+
47
+ `/mcp` lists the servers and their state, which is the quickest way to tell
48
+ "not configured" from "configured, not approved".
49
+
50
+ To skip both, install it for yourself rather than for the project:
51
+
52
+ ```bash
53
+ claude mcp add -s user grampro -- npx -y @grampro/kit mcp
54
+ ```
55
+
56
+ User scope lives in `~/.claude.json`, loads in every project, and needs no
57
+ approval because it is yours rather than the repository's. Project scope is
58
+ the better choice when you want the rest of the team to get it from a commit.
59
+
60
+ ### What it writes
61
+
62
+ | agent | file | format |
63
+ | --- | --- | --- |
64
+ | Claude Code | `.mcp.json` | `mcpServers.grampro` |
65
+ | Codex | `.codex/config.toml` | `[mcp_servers.grampro]` |
66
+ | Cursor | `.cursor/mcp.json` | `mcpServers.grampro` |
67
+
68
+ The Codex config is project-scoped. For every project instead, put the same
69
+ `[mcp_servers.grampro]` block in `~/.codex/config.toml`.
70
+
71
+ A project that already has the package installed gets a direct `node`
72
+ invocation; anywhere else the config uses `npx -y @grampro/kit mcp`, which
73
+ works without an install but re-resolves the package on every start.
74
+
75
+ ### By hand, if you prefer
9
76
 
10
77
  ```bash
11
78
  claude mcp add grampro -- npx -y @grampro/kit mcp
12
79
  ```
13
80
 
14
- Codex, Cursor and anything else that speaks MCP take the same command; it is
15
- a stdio server with no configuration. Without the CLI, write `.mcp.json` in
16
- your project:
17
-
18
- ```json
19
- {
20
- "mcpServers": {
21
- "grampro": {
22
- "command": "node",
23
- "args": ["node_modules/@grampro/kit/index.cjs", "mcp"]
24
- }
25
- }
26
- }
81
+ ```toml
82
+ # ~/.codex/config.toml
83
+ [mcp_servers.grampro]
84
+ command = "npx"
85
+ args = ["-y", "@grampro/kit", "mcp"]
27
86
  ```
28
87
 
29
88
  MCP servers are not invoked by name. There is no `/grampro`; the model calls
30
- the tools itself when they are relevant. Use `/mcp` to check the server is
31
- connected.
89
+ the tools itself when they are relevant. In Claude Code, `/mcp` shows whether
90
+ the server connected.
32
91
 
33
92
  ## Why
34
93
 
@@ -49,7 +108,7 @@ document pasted into the prompt.
49
108
  | `get_component` | Props with types and allowed values, which are required, handler signatures, slots, states, composition rules |
50
109
  | `get_example` | Working usage from the component's README |
51
110
  | `get_guidance` | Authored notes on forms, overlays, the grid, pickers, styling, accessibility |
52
- | `figma_frame` | A design's structure, text, repeated regions and theme variables |
111
+ | `figma_frame` | A design's structure, text, repeated regions and theme variables, from a `designFile` path, a pasted `design`, or a Figma `url` |
53
112
  | `install_component` | Copies components into the project being worked in |
54
113
  | `validate_usage` | Whether the JSX an agent wrote matches the passports |
55
114
 
@@ -66,18 +125,55 @@ invention, and this catches it:
66
125
  dismissible — not a prop of this component. Did you mean: onDismiss
67
126
  ```
68
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
+
69
157
  ## From a Figma design
70
158
 
71
- Two routes, and the second needs no credentials.
159
+ Three routes, and only the first needs credentials.
72
160
 
73
161
  **By URL.** Pass a `figma.com/design/...` link. Called with just the URL it
74
162
  lists the frames in the file; called again with `frame` it returns that
75
- screen. Needs a token.
163
+ screen. A link that already carries a `node-id` — which is what Figma copies
164
+ when you right-click a frame — skips straight to that frame in one call.
165
+ Needs a token.
76
166
 
77
167
  **By plugin export.** In Figma, run **Grampro Kit → Export selection for an
78
168
  agent**, then pass the JSON as `design`. The plugin reads the scene graph
79
- directly, so this works on any file you can open and asks for no token at
80
- all.
169
+ directly, so this works on any file you can open, asks for no token, and
170
+ never touches the REST API — which also means it cannot be rate limited.
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.
81
177
 
82
178
  Either way the answer is the same shape: the structure, the text, the
83
179
  regions whose names repeat — eight `Status cell` nodes are a column — and
@@ -94,21 +190,51 @@ is the body text, and a near-black navy scores high on saturation, so both
94
190
  "most common" and "most saturated" pick the wrong thing. Lightness and
95
191
  saturation together find the accent.
96
192
 
193
+ ### Rate limits
194
+
195
+ Figma's limits on file reads are cost-based, and the cost is the size of the
196
+ response. Asking for a whole file to answer a question about one frame is
197
+ what earns a 429, and the lockout is long — a `Retry-After` of 395274 has
198
+ been observed on a single token.
199
+
200
+ So nothing here fetches a whole file. Listing the frames uses `depth=1`,
201
+ which returns the document's outline rather than its contents, and the frame
202
+ itself is then fetched by id. A URL carrying a `node-id` skips the listing
203
+ altogether. Responses are held for five minutes, so the natural two-call
204
+ flow — list, then read — costs one listing and one frame.
205
+
206
+ If a token is already locked out, the plugin export route still works.
207
+
97
208
  ## Credentials
98
209
 
99
- **This is the weakest part of the current design, and it is temporary.**
210
+ The URL route needs a Figma personal access token. Store it where the
211
+ operating system keeps credentials:
100
212
 
101
- The URL route reads `FIGMA_TOKEN` from the environment, or from a `.env`
102
- file beside the project. That means a long-lived credential sitting in a
103
- file, which is not where it belongs.
213
+ ```bash
214
+ npx @grampro/kit secret set figma
215
+ ```
104
216
 
105
- A future release will keep it in the editor's own secret storage — VS Code's
106
- `SecretStorage` for the extension, and the equivalent elsewhere — so the
107
- token is held by the host rather than the repository. Until then:
217
+ It prompts, with the echo off, and never takes the value as an argument —
218
+ arguments land in shell history and in every process listing on the machine.
219
+ Piping works too, for a script: `echo "$TOKEN" | npx @grampro/kit secret set figma`.
108
220
 
109
- - prefer the plugin export route, which needs no token at all;
110
- - if you do use a token, give it only `File content: read`;
111
- - keep `.env` out of version control.
221
+ | platform | where it goes |
222
+ | --- | --- |
223
+ | Windows | Data Protection API, tied to your Windows account |
224
+ | macOS | the keychain |
225
+ | Linux | Secret Service (GNOME Keyring, KWallet) |
226
+ | anything else | a 0600 file in your config directory, and it says so |
227
+
228
+ `secret list` shows what is stored, masked, and names the backend in use.
229
+ `secret remove figma` deletes it. Create the token at Figma → Settings →
230
+ Security → Personal access tokens, with `File content: read` and nothing
231
+ else.
232
+
233
+ The token is read from `FIGMA_TOKEN` in the environment first, then the
234
+ store, then a `.env` — the last only so that an upgrade does not break a
235
+ project set up the old way. Reading from a `.env` prints a note saying to
236
+ move it. A credential in a file inside a working tree is one `git add -A`
237
+ away from being published, which is why this moved.
112
238
 
113
239
  ## Suggested use
114
240
 
@@ -123,10 +249,11 @@ No dependencies. MCP is newline-delimited JSON-RPC over stdio, which is a
123
249
  few hundred lines, and a server shipped inside a library that every consumer
124
250
  installs should not drag an SDK along with it.
125
251
 
252
+ Start-up is about the cost of an empty Node process. An agent spawns the
253
+ server once per session, and `index.cjs` routes `mcp` before loading the
254
+ CLI's own dependencies, which are no use to a JSON-RPC loop and cost around
255
+ 70ms. The passports are indexed on the first question and not re-read after.
256
+
126
257
  The catalogue it serves is the one in this package, not the components
127
258
  copied into your project — so an agent can see a component before you
128
259
  install it, and `install_component` puts it in place.
129
-
130
- A Figma file is fetched once and held for five minutes. The natural flow is
131
- two calls, list then read, and refetching a large file in between is what
132
- earns a rate limit.
@@ -0,0 +1,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 };