Skip to content

Commit f8bd6fa

Browse files
feat(figma): integrate selected platform references with matching data
- Keep discovery, resolution, status slices, and browser links consistent. - Migrate the Figma roster and its derived outputs with the new consumers. - Retain the S2-only RSP catalog until the final refresh. Source-commit: fe220cb0 Source-commit: faf8d9e3 Source-commit: 6f6005f5 Source-commit: b4430da7 Source-commit: 96a06566 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 9560c7e commit f8bd6fa

164 files changed

Lines changed: 5423 additions & 643 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
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.

‎.github/workflows/validate-figma-discovery.yml‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,18 @@ jobs:
2727
with:
2828
node-version-file: .nvmrc
2929

30-
- name: Test Figma discovery
30+
- name: Test Figma discovery and resolution
3131
run: node --test test/extractions/figma-*.node.test.js test/extractions/build-status-index.node.test.js
3232

3333
- name: Discover published Figma component sets
3434
env:
3535
FIGMA_TOKEN: ${{ secrets.FIGMA_TOKEN }}
3636
run: node deps/figma/discover-components.js
3737

38+
- name: Rebuild the combined status index
39+
run: node deps/build-status-index.js
40+
3841
- name: Report generated changes
3942
run: |
40-
git diff --stat -- deps/figma/components.json
41-
git status --short -- deps/figma/components.json
43+
git diff --stat -- deps/figma/components.json deps/status-index.json deps/status deps/impl-component-names.js
44+
git status --short -- deps/figma/components.json deps/status-index.json deps/status deps/impl-component-names.js

‎blocks/component-status/component-status.js‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import { STATUSES } from '../../scripts/utils/status-model.js';
1717
import { getImplementationById } from '../../scripts/utils/implementations.js';
1818
import { getSvgRef } from '../../scripts/utils/svg.js';
1919
import { resolveImplementation } from '../../scripts/utils/go-to-impl.js';
20-
import { figmaNodeUrl } from '../../scripts/utils/figma.js';
21-
import { implAndSlugFromPath } from '../../scripts/utils/component-path.js';
20+
import { getFigmaLink } from '../../scripts/utils/figma.js';
21+
import { componentContextFromPath } from '../../scripts/utils/component-path.js';
2222
import { fetchComponentSlice } from '../../scripts/utils/component-slice.js';
2323
import { getConfig, removeForAudience } from '../../scripts/ak.js';
2424

@@ -48,9 +48,11 @@ const STATUS_ICONS = {
4848
* `/…/components/<slug>` under a registered code implementation (rsp/swc, never figma).
4949
*/
5050
export function resolveContext(pathname) {
51-
const { impl, slug } = implAndSlugFromPath(pathname);
52-
if (!slug || !getImplementationById(impl)) { return null; }
53-
return { impl, slug };
51+
const context = componentContextFromPath(pathname);
52+
if (!context || (context.platform === 'web' && !getImplementationById(context.impl))) {
53+
return null;
54+
}
55+
return context;
5456
}
5557

5658
/**
@@ -92,27 +94,32 @@ function buildPill({ kind, label: prefix }, cell, link) {
9294
* an empty array when the path doesn't resolve or the component has no slice.
9395
*
9496
* @param {string} pathname
95-
* @param {{ web: object, figmaPageId?: string } | null} componentData - this component's slice.
97+
* @param {{ web?: object, ios?: object, android?: object } | null} componentData
98+
* This component's platform-specific status slice.
9699
* @returns {HTMLElement[]}
97100
*/
98101
export function buildPills(pathname, componentData) {
99102
const context = resolveContext(pathname);
100103
if (!context || !componentData) { return []; }
101-
const web = componentData.web ?? {};
104+
const cells = componentData[context.platform];
105+
if (context.platform !== 'web' && !cells?.figma) { return []; }
102106

103107
// Development reuses the page-nav widgets' URL logic exactly; Design links straight to
104108
// the node id the build already resolved (no client-side Figma roster search needed).
105109
// The current impl's own cell may carry `upstreamName` (an aliased/shared page's real
106110
// upstream name) — see resolveImplementation.
107-
const impl = resolveImplementation(pathname, web[context.impl]?.upstreamName ?? null);
108-
const figmaHref = figmaNodeUrl(componentData.figmaPageId);
111+
const impl = context.platform === 'web'
112+
? resolveImplementation(pathname, cells?.[context.impl]?.upstreamName ?? null)
113+
: null;
114+
const figmaLink = getFigmaLink(pathname, componentData);
109115
const links = {
110116
dev: impl ? { href: impl.href, dest: `${impl.label} documentation` } : null,
111-
design: figmaHref ? { href: figmaHref, dest: 'Figma' } : null,
117+
design: figmaLink ? { href: figmaLink.href, dest: 'Figma' } : null,
112118
};
113119

114120
return PILLS
115-
.map((pill) => buildPill(pill, web[pill.column ?? context.impl], links[pill.kind]))
121+
.filter((pill) => context.platform === 'web' || pill.kind === 'design')
122+
.map((pill) => buildPill(pill, cells?.[pill.column ?? context.impl], links[pill.kind]))
116123
.filter(Boolean);
117124
}
118125

0 commit comments

Comments
 (0)