@grampro/kit 2.4.4 → 2.5.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 (72) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/index.cjs +39 -4
  3. package/package.json +1 -1
  4. package/source/components/accordion/passport.json +11 -7
  5. package/source/components/accordion/react/Accordion.tsx +5 -0
  6. package/source/components/alert/passport.json +3 -3
  7. package/source/components/avatar/passport.json +8 -6
  8. package/source/components/avatar/react/Avatar.tsx +4 -0
  9. package/source/components/badge/passport.json +8 -6
  10. package/source/components/badge/react/Badge.tsx +1 -0
  11. package/source/components/badge/react/Tag.tsx +1 -0
  12. package/source/components/breadcrumb/passport.json +8 -6
  13. package/source/components/breadcrumb/react/Breadcrumb.tsx +1 -0
  14. package/source/components/button/passport.json +8 -6
  15. package/source/components/button/react/Button.tsx +1 -0
  16. package/source/components/card/passport.json +17 -9
  17. package/source/components/card/react/Card.tsx +5 -0
  18. package/source/components/card/react/Stat.tsx +5 -0
  19. package/source/components/checkbox/passport.json +32 -14
  20. package/source/components/checkbox/react/Checkbox.tsx +17 -0
  21. package/source/components/checkbox/react/CheckboxGroup.tsx +6 -0
  22. package/source/components/combobox/passport.json +36 -16
  23. package/source/components/combobox/react/Select.tsx +6 -1
  24. package/source/components/combobox/react/props.ts +23 -0
  25. package/source/components/data-grid/core/types.ts +53 -0
  26. package/source/components/data-grid/passport.json +82 -30
  27. package/source/components/data-grid/react/AskGrid.tsx +10 -0
  28. package/source/components/data-grid/react/DataGrid.tsx +16 -0
  29. package/source/components/date-picker/passport.json +30 -14
  30. package/source/components/date-picker/react/DatePicker.tsx +5 -0
  31. package/source/components/date-picker/react/props.ts +13 -1
  32. package/source/components/dialog/core/types.ts +14 -0
  33. package/source/components/dialog/passport.json +26 -12
  34. package/source/components/dialog/react/Dialog.tsx +4 -0
  35. package/source/components/file-uploader/passport.json +44 -18
  36. package/source/components/file-uploader/react/FileUploader.tsx +39 -0
  37. package/source/components/input/passport.json +14 -8
  38. package/source/components/input/react/Input.tsx +3 -0
  39. package/source/components/menu/passport.json +29 -13
  40. package/source/components/menu/react/Menu.tsx +7 -0
  41. package/source/components/menu/react/items.tsx +4 -0
  42. package/source/components/modal/passport.json +14 -8
  43. package/source/components/modal/react/Modal.tsx +6 -0
  44. package/source/components/number-input/passport.json +23 -11
  45. package/source/components/number-input/react/NumberInput.tsx +13 -0
  46. package/source/components/popover/passport.json +17 -9
  47. package/source/components/popover/react/Popover.tsx +7 -0
  48. package/source/components/progress/passport.json +3 -3
  49. package/source/components/radio-group/passport.json +32 -14
  50. package/source/components/radio-group/react/Radio.tsx +17 -0
  51. package/source/components/radio-group/react/RadioGroup.tsx +13 -0
  52. package/source/components/skeleton/passport.json +8 -6
  53. package/source/components/skeleton/react/Skeleton.tsx +5 -0
  54. package/source/components/spinner/passport.json +3 -3
  55. package/source/components/switch/passport.json +23 -11
  56. package/source/components/switch/react/Switch.tsx +14 -0
  57. package/source/components/tabs/passport.json +11 -7
  58. package/source/components/tabs/react/Tabs.tsx +2 -0
  59. package/source/components/textarea/passport.json +14 -8
  60. package/source/components/textarea/react/Textarea.tsx +3 -0
  61. package/source/components/toaster/passport.json +8 -6
  62. package/source/components/toaster/react/Toaster.tsx +4 -0
  63. package/source/components/tooltip/passport.json +3 -3
  64. package/tools/mcp/README.md +126 -34
  65. package/tools/mcp/figma.cjs +84 -27
  66. package/tools/mcp/install.cjs +270 -0
  67. package/tools/mcp/secret-cli.cjs +174 -0
  68. package/tools/mcp/secret.cjs +289 -0
  69. package/tools/mcp/server.cjs +53 -14
  70. package/tools/passport/cli.cjs +8 -1
  71. package/tools/passport/extract.cjs +7 -3
  72. 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.5.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.5.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.5.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.5.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
 
@@ -72,12 +131,14 @@ Two routes, and the second needs no credentials.
72
131
 
73
132
  **By URL.** Pass a `figma.com/design/...` link. Called with just the URL it
74
133
  lists the frames in the file; called again with `frame` it returns that
75
- screen. Needs a token.
134
+ screen. A link that already carries a `node-id` — which is what Figma copies
135
+ when you right-click a frame — skips straight to that frame in one call.
136
+ Needs a token.
76
137
 
77
138
  **By plugin export.** In Figma, run **Grampro Kit → Export selection for an
78
139
  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.
140
+ directly, so this works on any file you can open, asks for no token, and
141
+ never touches the REST API — which also means it cannot be rate limited.
81
142
 
82
143
  Either way the answer is the same shape: the structure, the text, the
83
144
  regions whose names repeat — eight `Status cell` nodes are a column — and
@@ -94,21 +155,51 @@ is the body text, and a near-black navy scores high on saturation, so both
94
155
  "most common" and "most saturated" pick the wrong thing. Lightness and
95
156
  saturation together find the accent.
96
157
 
158
+ ### Rate limits
159
+
160
+ Figma's limits on file reads are cost-based, and the cost is the size of the
161
+ response. Asking for a whole file to answer a question about one frame is
162
+ what earns a 429, and the lockout is long — a `Retry-After` of 395274 has
163
+ been observed on a single token.
164
+
165
+ So nothing here fetches a whole file. Listing the frames uses `depth=1`,
166
+ which returns the document's outline rather than its contents, and the frame
167
+ itself is then fetched by id. A URL carrying a `node-id` skips the listing
168
+ altogether. Responses are held for five minutes, so the natural two-call
169
+ flow — list, then read — costs one listing and one frame.
170
+
171
+ If a token is already locked out, the plugin export route still works.
172
+
97
173
  ## Credentials
98
174
 
99
- **This is the weakest part of the current design, and it is temporary.**
175
+ The URL route needs a Figma personal access token. Store it where the
176
+ operating system keeps credentials:
100
177
 
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.
178
+ ```bash
179
+ npx @grampro/kit secret set figma
180
+ ```
104
181
 
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:
182
+ It prompts, with the echo off, and never takes the value as an argument —
183
+ arguments land in shell history and in every process listing on the machine.
184
+ Piping works too, for a script: `echo "$TOKEN" | npx @grampro/kit secret set figma`.
108
185
 
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.
186
+ | platform | where it goes |
187
+ | --- | --- |
188
+ | Windows | Data Protection API, tied to your Windows account |
189
+ | macOS | the keychain |
190
+ | Linux | Secret Service (GNOME Keyring, KWallet) |
191
+ | anything else | a 0600 file in your config directory, and it says so |
192
+
193
+ `secret list` shows what is stored, masked, and names the backend in use.
194
+ `secret remove figma` deletes it. Create the token at Figma → Settings →
195
+ Security → Personal access tokens, with `File content: read` and nothing
196
+ else.
197
+
198
+ The token is read from `FIGMA_TOKEN` in the environment first, then the
199
+ store, then a `.env` — the last only so that an upgrade does not break a
200
+ project set up the old way. Reading from a `.env` prints a note saying to
201
+ move it. A credential in a file inside a working tree is one `git add -A`
202
+ away from being published, which is why this moved.
112
203
 
113
204
  ## Suggested use
114
205
 
@@ -123,10 +214,11 @@ No dependencies. MCP is newline-delimited JSON-RPC over stdio, which is a
123
214
  few hundred lines, and a server shipped inside a library that every consumer
124
215
  installs should not drag an SDK along with it.
125
216
 
217
+ Start-up is about the cost of an empty Node process. An agent spawns the
218
+ server once per session, and `index.cjs` routes `mcp` before loading the
219
+ CLI's own dependencies, which are no use to a JSON-RPC loop and cost around
220
+ 70ms. The passports are indexed on the first question and not re-read after.
221
+
126
222
  The catalogue it serves is the one in this package, not the components
127
223
  copied into your project — so an agent can see a component before you
128
224
  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.
@@ -24,18 +24,33 @@ const MAX_DEPTH = 10;
24
24
  // ------------------------------------------------------------------ token
25
25
 
26
26
  /**
27
- * The token, from the environment or a .env beside the project.
27
+ * The token, from the environment, the OS secret store, or a `.env`.
28
28
  *
29
- * MCP clients pass environment through unevenly, so a .env is the fallback
30
- * that works everywhere. It is read, never logged.
29
+ * The store is where it belongs and `.env` is kept only so that anyone who
30
+ * set it up that way is not broken by an upgrade. Reading from a `.env` says
31
+ * so once, on stderr — stdout carries JSON-RPC and nothing else may touch it.
31
32
  */
33
+ let warnedAboutDotenv = false;
34
+
32
35
  function figmaToken() {
33
36
  if (process.env.FIGMA_TOKEN) return process.env.FIGMA_TOKEN.trim();
37
+
38
+ const stored = require("./secret.cjs").getSecret("figma");
39
+ if (stored) return stored;
40
+
34
41
  for (const dir of [process.cwd(), path.join(__dirname, "..", "..")]) {
35
42
  const file = path.join(dir, ".env");
36
43
  if (!fs.existsSync(file)) continue;
37
44
  const match = fs.readFileSync(file, "utf8").match(/^\s*FIGMA_TOKEN\s*=\s*(.+)$/m);
38
- if (match) return match[1].trim().replace(/^["']|["']$/g, "");
45
+ if (!match) continue;
46
+ if (!warnedAboutDotenv) {
47
+ warnedAboutDotenv = true;
48
+ process.stderr.write(
49
+ "grampro-kit: read FIGMA_TOKEN from .env. Move it out of the repository with " +
50
+ "`npx @grampro/kit secret set figma`, then delete the line.\n",
51
+ );
52
+ }
53
+ return match[1].trim().replace(/^["']|["']$/g, "");
39
54
  }
40
55
  return null;
41
56
  }
@@ -73,8 +88,27 @@ async function figmaGet(pathname, token) {
73
88
  );
74
89
  }
75
90
  if (res.status === 429) {
91
+ /*
92
+ * Figma's file-read limit is cost-based, and once a token is over it the
93
+ * window is long — tens of minutes, not the "try again shortly" that
94
+ * this message used to promise. The plugin route is the honest advice,
95
+ * because it does not touch the API at all.
96
+ */
97
+ /*
98
+ * Retry-After is meant to be seconds, and Figma has been seen to return
99
+ * 395274 on a file read — four and a half days, or six minutes if the
100
+ * value is milliseconds. Rather than state a figure that may be wrong by
101
+ * three orders of magnitude, the number is passed through as given.
102
+ */
103
+ const after = res.headers.get("retry-after");
104
+ const when = after
105
+ ? `Figma's Retry-After says ${after} (nominally seconds).`
106
+ : "That window is usually tens of minutes, not seconds.";
76
107
  throw new Error(
77
- "Figma is rate limiting this token. Wait a minute and try again; the file is cached for five minutes once it arrives.",
108
+ `Figma is rate limiting this token. ${when} Nothing here can shorten it. ` +
109
+ "To carry on now, use the kit's Figma plugin → 'Export selection for an agent' " +
110
+ "and pass the result as `design` — that route reads the file from inside Figma " +
111
+ "and never calls the API.",
78
112
  );
79
113
  }
80
114
  throw new Error(`Figma returned ${res.status}: ${body.slice(0, 160)}`);
@@ -300,27 +334,63 @@ async function figmaFrame({ url, design, frame, depth }) {
300
334
  if (!token) {
301
335
  return {
302
336
  error:
303
- "No FIGMA_TOKEN. Either run the kit's Figma plugin → 'Export selection for an agent' and pass the result as `design`, or create a token at Figma → Settings → Security → Personal access tokens (scope: File content, read) and set FIGMA_TOKEN.",
337
+ "No Figma token. Two ways forward, and the first needs no credentials: " +
338
+ "run the kit's Figma plugin → 'Export selection for an agent' and pass the result as `design`. " +
339
+ "Otherwise create a token at Figma → Settings → Security → Personal access tokens " +
340
+ "(scope: File content, read) and store it with `npx @grampro/kit secret set figma`.",
304
341
  };
305
342
  }
306
343
 
307
344
  const { fileKey, nodeId, error } = parseFigmaUrl(url);
308
345
  if (error) return { error };
309
346
 
347
+ /*
348
+ * Three shapes of request, cheapest first.
349
+ *
350
+ * A URL carrying a node-id — which is what Figma gives you when you copy a
351
+ * link to a frame — is one targeted call and nothing else. Otherwise the
352
+ * frames are listed with depth=1, which returns the outline of the document
353
+ * rather than the document, and the chosen frame is then fetched by id.
354
+ * Figma's limits are cost-based and the cost is the payload, so pulling the
355
+ * whole file to answer either question is what earns a 429.
356
+ */
310
357
  let file;
358
+ let roots;
311
359
  try {
312
- file = nodeId
313
- ? await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`, token)
314
- : await figmaGet(`/files/${fileKey}`, token);
360
+ if (nodeId) {
361
+ file = await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`, token);
362
+ roots = Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document);
363
+ } else {
364
+ const outline = await figmaGet(`/files/${fileKey}?depth=1`, token);
365
+ const tops = (outline.document.children || []).flatMap((page) => page.children || []);
366
+
367
+ if (!frame) {
368
+ return {
369
+ file: outline.name,
370
+ frames: tops.map((f) => ({
371
+ name: f.name,
372
+ id: f.id,
373
+ size: f.absoluteBoundingBox
374
+ ? [Math.round(f.absoluteBoundingBox.width), Math.round(f.absoluteBoundingBox.height)]
375
+ : undefined,
376
+ })),
377
+ next: "Call again with `frame` set to the one you want to build. Passing a URL that already carries a node-id skips this step.",
378
+ };
379
+ }
380
+
381
+ const match = tops.find((f) => f.name.toLowerCase() === String(frame).toLowerCase());
382
+ if (!match) {
383
+ return { error: `No frame called "${frame}".`, frames: tops.map((f) => f.name) };
384
+ }
385
+ file = await figmaGet(`/files/${fileKey}/nodes?ids=${encodeURIComponent(match.id)}`, token);
386
+ roots = Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document);
387
+ file.name = outline.name;
388
+ }
315
389
  } catch (e) {
316
390
  return { error: e.message };
317
391
  }
318
392
 
319
- const roots = nodeId
320
- ? Object.values(file.nodes || {}).filter(Boolean).map((n) => n.document)
321
- : (file.document.children || []).flatMap((page) => page.children || []);
322
-
323
- if (roots.length === 0) return { error: "Nothing to read at that URL." };
393
+ if (!roots || roots.length === 0) return { error: "Nothing to read at that URL." };
324
394
 
325
395
  /*
326
396
  * Without a frame named, the whole file would arrive at once — hundreds of
@@ -331,19 +401,6 @@ async function figmaFrame({ url, design, frame, depth }) {
331
401
  ? roots.filter((r) => r.name.toLowerCase() === String(frame).toLowerCase())
332
402
  : roots;
333
403
 
334
- if (!frame && roots.length > 1) {
335
- return {
336
- file: file.name,
337
- frames: roots.map((r) => ({
338
- name: r.name,
339
- size: r.absoluteBoundingBox
340
- ? [Math.round(r.absoluteBoundingBox.width), Math.round(r.absoluteBoundingBox.height)]
341
- : undefined,
342
- })),
343
- next: "Call again with `frame` set to the one you want to build.",
344
- };
345
- }
346
-
347
404
  if (wanted.length === 0) {
348
405
  return { error: `No frame called "${frame}".`, frames: roots.map((r) => r.name) };
349
406
  }