|
| 1 | +# Figma AI Toolkit ingestion |
| 2 | + |
| 3 | +## Goal |
| 4 | + |
| 5 | +Spectrum Hub's Design column is driven by one Figma library: S2 · Web |
| 6 | +(`xHBWBBIe2eo5vwoCeNrC4Q`). The AI components now shipping in Spectrum Web |
| 7 | +Components, and soon in `@react-spectrum/ai`, are designed in a second file — |
| 8 | +Spectrum AI Toolkit (`3WdX799d7ptEVA6RuTESXs`). Those rows currently read |
| 9 | +`figma: not-available` because no roster entry exists for them. |
| 10 | + |
| 11 | +This spec covers what has to be written so the Design column, the "See in Figma" |
| 12 | +page-nav widget, and the component-status Design pill resolve against either |
| 13 | +file. It does not cover the `@react-spectrum/ai` package ingestion, which is a |
| 14 | +separate change to `deps/rsp/`. |
| 15 | + |
| 16 | +## Constraint: no Figma Desktop Bridge plugin |
| 17 | + |
| 18 | +The S2 roster in `deps/figma/components.json` was produced by hand through a |
| 19 | +Figma MCP server backed by the Desktop Bridge plugin, as the header comment in |
| 20 | +[`deps/figma/fetch-figma-components.js`](../../../deps/figma/fetch-figma-components.js) |
| 21 | +records. The Desktop Bridge plugin is no longer an approved technology, so the |
| 22 | +AI Toolkit roster cannot be produced that way and the S2 roster can no longer be |
| 23 | +refreshed that way either. |
| 24 | + |
| 25 | +The replacement is the Figma REST API, which |
| 26 | +`fetch-figma-components.js` already sketches but never wired up. That changes |
| 27 | +`fetch-figma-components.js` from a sketch into a real, runnable script, and it |
| 28 | +introduces a credential this repository does not have today. |
| 29 | + |
| 30 | +## Current contract |
| 31 | + |
| 32 | +Everything below is what exists now, and it assumes a single Figma file |
| 33 | +throughout. |
| 34 | + |
| 35 | +| File | Shape | Written by | |
| 36 | +| --- | --- | --- | |
| 37 | +| `deps/figma/components.json` | `[{ name, figmaPageId }]`, 83 entries | Nothing. Hand-produced through MCP. | |
| 38 | +| `deps/figma/figma-secondary-status.json` | `[{ name, context }]`, 9 entries | Authored by hand. | |
| 39 | +| `deps/status/<slug>.json` | `{ web, figmaPageId? }` | `deps/build-status-index.js` | |
| 40 | + |
| 41 | +`figmaPageId` is stored colon-separated (`9230:3620`). The Figma URL form is |
| 42 | +hyphenated (`9230-3620`), and `figmaNodeUrl` converts between the two. |
| 43 | + |
| 44 | +The read path: |
| 45 | + |
| 46 | +1. `main()` in [`deps/build-status-index.js`](../../../deps/build-status-index.js) |
| 47 | + reads `deps/figma/components.json` and `deps/figma/figma-secondary-status.json`, |
| 48 | + and unions the two name lists into `figmaNames`. |
| 49 | +2. `joinRosters` folds `figmaNames` into canonical component rows through |
| 50 | + `canonicalNameForFigma`, which title-cases the Figma display name unless |
| 51 | + `deps/component-aliases.json` overrides it. |
| 52 | +3. `buildComponentSlices` builds a `figmaPageIdByName` map from the roster and |
| 53 | + writes the matching node id onto each component's slice, honoring a |
| 54 | + `web.figma.figmaPageSource` override from `deps/status-overrides.json`. |
| 55 | +4. In the browser, `fetchComponentSlice` in |
| 56 | + [`scripts/utils/component-slice.js`](../../../scripts/utils/component-slice.js) |
| 57 | + fetches one slice, and `figmaNodeUrl` in |
| 58 | + [`scripts/utils/figma.js`](../../../scripts/utils/figma.js) turns |
| 59 | + `figmaPageId` into a link against the hard-coded `FIGMA_FILE_URL`. |
| 60 | + |
| 61 | +The single hard-coded file URL in `scripts/utils/figma.js` is the reason a |
| 62 | +second file cannot simply be appended to the roster: every node id in the roster |
| 63 | +is assumed to live in S2 · Web, and a node id from the AI Toolkit would produce |
| 64 | +a link into the wrong file. Figma resolves such a URL to the file's default |
| 65 | +view rather than erroring, so the failure is silent. |
| 66 | + |
| 67 | +## What has to be written |
| 68 | + |
| 69 | +### 1. Roster entries carry their file key |
| 70 | + |
| 71 | +`deps/figma/components.json` gains a third field: |
| 72 | + |
| 73 | +```json |
| 74 | +[ |
| 75 | + { "name": "Accordion", "figmaPageId": "10093:987" }, |
| 76 | + { "name": "Prompt field", "figmaPageId": "3161:2278", "figmaFile": "3WdX799d7ptEVA6RuTESXs" } |
| 77 | +] |
| 78 | +``` |
| 79 | + |
| 80 | +`figmaFile` is omitted for S2 · Web entries and present for AI Toolkit entries. |
| 81 | +Omission means S2 · Web. Writing the field only where it differs from the |
| 82 | +default keeps the diff for this change to the new rows, and matches how |
| 83 | +`deps/rsp/components.json` omits `file` when it matches the component name. |
| 84 | + |
| 85 | +An alternative considered and rejected: a second roster file, |
| 86 | +`deps/figma/ai-components.json`. It would need a parallel secondary-status |
| 87 | +file, a second `readJson` call, a second union into `figmaNames`, and a second |
| 88 | +map in `buildComponentSlices` — all to express one field. |
| 89 | + |
| 90 | +### 2. `fetch-figma-components.js` becomes a runnable multi-file script |
| 91 | + |
| 92 | +Today the module exports one function, `fetchFigmaComponents(api)`, takes an |
| 93 | +injected caller, hard-codes `FIGMA_FILE`, and has no `main()`. It has to become |
| 94 | +a script in the shape of `deps/rsp/discover-components.js`: fetch, map, sort, |
| 95 | +write `components.json`, and run under |
| 96 | +`node deps/figma/fetch-figma-components.js`. |
| 97 | + |
| 98 | +Required changes: |
| 99 | + |
| 100 | +- Replace the `FIGMA_FILE` constant with a `FIGMA_FILES` list of |
| 101 | + `{ key, label }`, holding S2 · Web and the AI Toolkit. |
| 102 | +- Fetch each file's component sets, tag each result with its file key, and |
| 103 | + concatenate. Keep the existing filter that drops names starting with `.`, |
| 104 | + which are Figma-internal. |
| 105 | +- Set `figmaFile` only when the key is not S2 · Web, per the default above. |
| 106 | +- Sort the merged roster by name, so the committed file has a stable diff |
| 107 | + independent of fetch order. |
| 108 | +- Read the token from `process.env.FIGMA_TOKEN` and send it as the |
| 109 | + `X-Figma-Token` request header. Exit non-zero with a clear message when the |
| 110 | + variable is absent, rather than writing a partial roster. |
| 111 | +- Fail closed on a short result, as `deps/rsp/extract-props.js` does: refuse to |
| 112 | + overwrite `components.json` when the fetched roster is less than half the size |
| 113 | + of the committed one. A revoked token or an unpublished library must not be |
| 114 | + able to empty the Design column. |
| 115 | + |
| 116 | +**Credential.** A Figma personal access token scoped to read the two files. |
| 117 | +`GET /v1/files/:key/component_sets` reads published library content, so the |
| 118 | +token needs `library_content:read`; a raw file read, if the fallback in the next |
| 119 | +paragraph is needed, needs `file_content:read`. The token is not in this |
| 120 | +repository, this developer's environment, or CI today, so it has to be issued |
| 121 | +and stored as a `FIGMA_TOKEN` repository secret before any of this runs. |
| 122 | + |
| 123 | +**Verify before building on it.** `GET /v1/files/:key/component_sets` returns |
| 124 | +only component sets **published to a team library**. If the AI Toolkit's |
| 125 | +component sets are not published, the endpoint returns an empty list and the |
| 126 | +script writes nothing for that file. Confirm the endpoint returns a non-empty |
| 127 | +list for `3WdX799d7ptEVA6RuTESXs` as the first step of implementation. If it |
| 128 | +returns nothing, the fallback is `GET /v1/files/:key` plus a walk of the |
| 129 | +document tree for `COMPONENT_SET` nodes, which is a larger response and a |
| 130 | +different parser, and which needs `file_content:read` instead. |
| 131 | + |
| 132 | +### 3. `build-status-index.js` passes the file key to the slice |
| 133 | + |
| 134 | +`buildComponentSlices` currently builds one map: |
| 135 | + |
| 136 | +```js |
| 137 | +const figmaPageIdByName = new Map(figmaRoster.map((entry) => [entry.name, entry.figmaPageId])); |
| 138 | +``` |
| 139 | + |
| 140 | +It has to carry both fields, keeping the existing `figmaPageSource` override |
| 141 | +behavior unchanged — an override names a roster entry, and that entry now |
| 142 | +supplies a file key as well as a node id: |
| 143 | + |
| 144 | +```js |
| 145 | +const figmaEntryByName = new Map(figmaRoster.map((entry) => [entry.name, entry])); |
| 146 | +``` |
| 147 | + |
| 148 | +The slice gains `figmaFile`, written only when the roster entry has one: |
| 149 | + |
| 150 | +```json |
| 151 | +{ "web": { "…": "…" }, "figmaPageId": "3161:2278", "figmaFile": "3WdX799d7ptEVA6RuTESXs" } |
| 152 | +``` |
| 153 | + |
| 154 | +Slices for S2 · Web components do not change at all, so regenerating the index |
| 155 | +produces a diff limited to the new AI rows. |
| 156 | + |
| 157 | +Nothing else in `build-status-index.js` changes. `joinRosters`, |
| 158 | +`canonicalNameForFigma`, `readSecondaries`, and `buildImplComponentNames` all |
| 159 | +operate on names, and `buildImplComponentNames` already skips the figma column. |
| 160 | + |
| 161 | +### 4. `figmaNodeUrl` takes the file key |
| 162 | + |
| 163 | +[`scripts/utils/figma.js`](../../../scripts/utils/figma.js) replaces its single |
| 164 | +`FIGMA_FILE_URL` constant with a map of file key to design URL, and |
| 165 | +`figmaNodeUrl` takes a second argument: |
| 166 | + |
| 167 | +```js |
| 168 | +export function figmaNodeUrl(figmaPageId, figmaFile) |
| 169 | +``` |
| 170 | + |
| 171 | +`figmaFile` is optional and defaults to the S2 · Web key, matching the roster |
| 172 | +default in step 1. An unrecognized key returns `null` rather than guessing a |
| 173 | +file, so a roster typo removes the widget instead of linking somewhere wrong. |
| 174 | + |
| 175 | +This is a signature change with two callers, and both pass the new argument from |
| 176 | +the same slice: |
| 177 | + |
| 178 | +- `decorateSeeInFigma`, in the same file, which already holds the slice. |
| 179 | +- [`blocks/component-status/component-status.js:108`](../../../blocks/component-status/component-status.js), which calls |
| 180 | + `figmaNodeUrl(componentData.figmaPageId)` inside `buildPills`. |
| 181 | + |
| 182 | +`decorateSeeInFigma` also builds its `aria-label` from |
| 183 | +`componentData.web?.figma?.figmaPageSource ?? componentSlug`. That text names a |
| 184 | +component, not a file, so it does not change. |
| 185 | + |
| 186 | +### 5. Name collisions between the two files |
| 187 | + |
| 188 | +`figmaEntryByName` is keyed by display name across both files, so a component |
| 189 | +set named the same in S2 · Web and the AI Toolkit silently resolves to whichever |
| 190 | +entry the merged roster wrote last. Today's single-file roster cannot have this |
| 191 | +problem; a two-file roster can. |
| 192 | + |
| 193 | +`fetch-figma-components.js` has to detect a name present in more than one file |
| 194 | +and warn, naming both files, the way `writeComponentSlices` warns on an |
| 195 | +accidental slug collision. Resolution is an entry in the `figma` section of |
| 196 | +`deps/component-aliases.json`, which already renames Figma display names into |
| 197 | +canonical names. |
| 198 | + |
| 199 | +The S2 roster's 83 current names share nothing with the AI vocabulary |
| 200 | +(`chat`, `prompt`, `message`, `thread`, `response`, `attachment`, `suggestion`), |
| 201 | +so no collision is expected on the first run. The warning is there to catch the |
| 202 | +AI Toolkit growing a `Button` later. |
| 203 | + |
| 204 | +### 6. Aliases for the AI cohort |
| 205 | + |
| 206 | +The SWC AI components already in the index use names that do not normalize onto |
| 207 | +the RSP or Figma names for the same component. The pairs visible today, from the |
| 208 | +untracked slices in `deps/status/`: |
| 209 | + |
| 210 | +| SWC slug | Likely RSP export | Needs alias | |
| 211 | +| --- | --- | --- | |
| 212 | +| `prompt-field` | `PromptField` | No | |
| 213 | +| `user-message` | `UserMessage` | No | |
| 214 | +| `message-feedback` | `MessageFeedback` | No | |
| 215 | +| `response-status` | `ResponseStatus` | No | |
| 216 | +| `message-sources` | `MessageSource` | Yes — plural | |
| 217 | +| `conversation-thread` | `Thread` | Yes | |
| 218 | +| `conversation-turn` | `ThreadItem` | Yes | |
| 219 | +| `suggestion-group` | `MessageSuggestionList` | Yes | |
| 220 | + |
| 221 | +The AI Toolkit's own display names are unknown until the roster is fetched, so |
| 222 | +the `figma` aliases cannot be written in advance. Fetch the roster first, then |
| 223 | +reconcile all three vocabularies in one pass. Until that pass lands, the AI rows |
| 224 | +will appear as duplicates — one row per vocabulary — rather than one row with |
| 225 | +three filled cells. |
| 226 | + |
| 227 | +### 7. Automation |
| 228 | + |
| 229 | +There is no `extract-figma-*.yml` workflow; `.github/workflows/` has one for RSP |
| 230 | +and one for SWC only. Once `fetch-figma-components.js` runs unattended, it |
| 231 | +should get one modeled on |
| 232 | +[`.github/workflows/extract-rsp-properties.yml`](../../../.github/workflows/extract-rsp-properties.yml): |
| 233 | +a daily schedule plus `workflow_dispatch`, a single long-lived |
| 234 | +`chore/figma-components` branch, `FIGMA_TOKEN` from repository secrets, and a |
| 235 | +`node deps/build-status-index.js` step so the committed index matches the |
| 236 | +committed roster. It stages `deps/figma/components.json`, |
| 237 | +`deps/status-index.json`, `deps/status/`, and `deps/impl-component-names.js`. |
| 238 | + |
| 239 | +The workflow is separable from the rest of this spec. The roster can be |
| 240 | +refreshed by hand with the same script in the meantime. |
| 241 | + |
| 242 | +## Tests |
| 243 | + |
| 244 | +Per [`test-driven-development`](../../skills/test-driven-development/SKILL.md), |
| 245 | +each of these is written before the change it covers. |
| 246 | + |
| 247 | +**`test/extractions/figma-roster.node.test.js`** — new, for the extraction |
| 248 | +script, with mocked fetches and no live API, matching how |
| 249 | +`test/extractions/build-status-index.node.test.js` covers the builder: |
| 250 | + |
| 251 | +- Merges two files' component sets into one sorted roster. |
| 252 | +- Omits `figmaFile` for S2 · Web entries and sets it for AI Toolkit entries. |
| 253 | +- Drops component sets whose name starts with `.`. |
| 254 | +- Warns, naming both files, when one display name appears in both. |
| 255 | +- Exits non-zero without writing when `FIGMA_TOKEN` is absent. |
| 256 | +- Refuses to write a roster less than half the committed size. |
| 257 | + |
| 258 | +**`test/extractions/build-status-index.node.test.js`** — extend the existing |
| 259 | +`buildComponentSlices` cases, which today pass roster fixtures such as |
| 260 | +`[{ name: 'Action Button', figmaPageId: '1:1' }]`: |
| 261 | +
|
| 262 | +- A roster entry with `figmaFile` writes it onto the slice. |
| 263 | +- A roster entry without `figmaFile` writes a slice with no `figmaFile` key, |
| 264 | + unchanged from today. |
| 265 | +- A `figmaPageSource` override resolves to the target entry's file key, not the |
| 266 | + overridden component's. |
| 267 | +
|
| 268 | +**`test/blocks/figma.test.js`** — extend the `figmaNodeUrl` describe block: |
| 269 | +
|
| 270 | +- No `figmaFile` builds an S2 · Web URL, which is the existing assertion. |
| 271 | +- An AI Toolkit key builds an AI Toolkit URL. |
| 272 | +- An unrecognized key returns `null`. |
| 273 | +- `decorateSeeInFigma` links into the AI Toolkit when the slice names it. |
| 274 | +
|
| 275 | +**`test/blocks/component-status.test.js`** — one case asserting the Design pill |
| 276 | +links into the AI Toolkit for a slice carrying `figmaFile`, alongside the |
| 277 | +existing `figmaPageId: '123:456'` cases. |
| 278 | +
|
| 279 | +Fixtures in `test/blocks/page-nav.test.js`, `test/blocks/page-hero.test.js`, and |
| 280 | +`test/a11y/mocks.js` hard-code `figmaPageId: '9230:3620'` with no `figmaFile`. |
| 281 | +They exercise the S2 · Web default and need no change, which is itself the |
| 282 | +check that the default holds. |
| 283 | +
|
| 284 | +## Documentation to update |
| 285 | +
|
| 286 | +- [`deps/docs/STATUS-FILES.md`](../../../deps/docs/STATUS-FILES.md) — the |
| 287 | + generated-files table and the `figmaPageSource` section, both of which |
| 288 | + describe the slice as carrying a node id alone. |
| 289 | +- [`deps/docs/DATA-CONTRACT.md`](../../../deps/docs/DATA-CONTRACT.md) — the |
| 290 | + Figma column's presence rule, which is unchanged, and the slice shape, which |
| 291 | + is not. |
| 292 | +- [`deps/README.md`](../../../deps/README.md) — the generated-files table, which |
| 293 | + does not list `deps/figma/components.json` at all today because nothing |
| 294 | + generated it. It does now. |
| 295 | +- [`deps/figma/fetch-figma-components.js`](../../../deps/figma/fetch-figma-components.js) |
| 296 | + — its header comment describes the MCP process this spec replaces. |
| 297 | +
|
| 298 | +## Open questions |
| 299 | +
|
| 300 | +1. **Are the AI Toolkit's component sets published to a team library?** This |
| 301 | + decides whether `component_sets` works or the heavier file-tree walk is |
| 302 | + needed. Answer it first; it changes step 2's parser and the token's scope. |
| 303 | +2. **Who issues `FIGMA_TOKEN`, and against which account?** A personal token |
| 304 | + tied to one person's Figma seat becomes a single point of failure for the |
| 305 | + daily workflow. |
| 306 | +3. **Does the AI Toolkit carry node ids for components that S2 · Web also |
| 307 | + designs?** If so, the alias pass in step 6 has to decide which file wins the |
| 308 | + Design link for a shared row. `figmaPageSource` in |
| 309 | + `deps/status-overrides.json` is the existing mechanism, and it would now |
| 310 | + select a file as well as a page. |
0 commit comments