winden-tokens 0.1.0 → 0.2.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.
- package/README.md +53 -0
- package/cli.mjs +6 -0
- package/mcp-tokens.mjs +381 -0
- package/mcp.mjs +1056 -0
- package/package.json +9 -3
- package/server.mjs +166 -23
package/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# winden-tokens
|
|
2
|
+
|
|
3
|
+
Run the [Winden Tokens](https://windentokens.com) Figma plugin in a full-size browser window, driving the Figma file you have open.
|
|
4
|
+
|
|
5
|
+
A Figma plugin panel is small; a design system with several hundred variables is not. This serves the same interface to a browser tab on your own machine.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g winden-tokens
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Node.js 18 or newer. To run it without installing: `npx winden-tokens`.
|
|
14
|
+
|
|
15
|
+
## Use
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
winden-tokens
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That starts a local server and opens the browser. Then open the Winden Tokens plugin in Figma — the tab fills with the variables of your open file.
|
|
22
|
+
|
|
23
|
+
Keep the plugin window open: it is the only thing that can reach the Figma API, and the browser tab is a remote control with no document of its own. Order does not matter, both sides reconnect on their own.
|
|
24
|
+
|
|
25
|
+
| | |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `--port N` | Use a different port. The Figma plugin can only connect to the port its manifest declares. |
|
|
28
|
+
| `--no-open` | Do not open a browser. |
|
|
29
|
+
| `--help` | All options. |
|
|
30
|
+
|
|
31
|
+
## Manage tokens from Claude
|
|
32
|
+
|
|
33
|
+
The package also installs an MCP server, so Claude can read and edit the variables in your open Figma file:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
claude mcp add winden-tokens -- winden-tokens-mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
It needs `winden-tokens` running and the plugin open in Figma, the same as the browser view. It can list collections, read a variable and everything it references, create variables, groups and collections, and set values. If the relay or the plugin is not there, a tool fails immediately and says which.
|
|
40
|
+
|
|
41
|
+
Renaming and deleting a variable are not reversible from Claude's side — Figma's own undo is the only recovery.
|
|
42
|
+
|
|
43
|
+
## Your tokens stay on your machine
|
|
44
|
+
|
|
45
|
+
The server listens on `127.0.0.1` only, so nothing on your network can reach it. It checks the origin of every connection before accepting one, so a web page you happen to have open cannot connect and issue commands into your Figma file. It stores nothing — it forwards messages and forgets them.
|
|
46
|
+
|
|
47
|
+
## Links
|
|
48
|
+
|
|
49
|
+
- [windentokens.com](https://windentokens.com) — the plugin, and what it does
|
|
50
|
+
- [GitHub](https://github.com/krstivoja/winden-tokens) — source, issues
|
|
51
|
+
- [Figma Community](https://windentokens.com) — install the plugin
|
|
52
|
+
|
|
53
|
+
MIT © [DPlugins](https://dplugins.com)
|
package/cli.mjs
CHANGED
|
@@ -59,6 +59,12 @@ const HELP = `
|
|
|
59
59
|
manifest.json to list the new port, or the plugin will never connect.
|
|
60
60
|
Use it to dodge a port conflict only if you can rebuild the plugin too.
|
|
61
61
|
|
|
62
|
+
Also in this package
|
|
63
|
+
winden-tokens-mcp an MCP server that reads and edits the same file's
|
|
64
|
+
tokens from an MCP client. It needs THIS relay and the
|
|
65
|
+
Figma plugin window to be running, and refuses to start
|
|
66
|
+
if either is missing. See specs/devnotes.md.
|
|
67
|
+
|
|
62
68
|
Security
|
|
63
69
|
The relay binds 127.0.0.1 only, and refuses any WebSocket whose Origin is
|
|
64
70
|
not the page it served itself. A WebSocket handshake is not subject to
|
package/mcp-tokens.mjs
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Winden Tokens MCP — everything that is pure.
|
|
3
|
+
*
|
|
4
|
+
* The transport half (./mcp.mjs) is a relay socket, a stdio server and a pile
|
|
5
|
+
* of timeouts, and is awkward to test without a Figma file. This half is the
|
|
6
|
+
* part with the actual opinions in it — how a `data-loaded` snapshot is read,
|
|
7
|
+
* how a name resolves to a variable, what "references" means — and it is
|
|
8
|
+
* ordinary data in, ordinary data out. It is unit tested in
|
|
9
|
+
* tests/bridge/mcp-tokens.test.mjs.
|
|
10
|
+
*
|
|
11
|
+
* ---------------------------------------------------------------------------
|
|
12
|
+
* THE SNAPSHOT
|
|
13
|
+
* ---------------------------------------------------------------------------
|
|
14
|
+
*
|
|
15
|
+
* Exactly the payload the plugin broadcasts as `data-loaded` (see `fetchData`
|
|
16
|
+
* and `buildUiState` in src/plugin/code.ts). Nothing here may invent fields the
|
|
17
|
+
* plugin does not send:
|
|
18
|
+
*
|
|
19
|
+
* {
|
|
20
|
+
* type: 'data-loaded',
|
|
21
|
+
* collections: [{ id, name, modes: [{ modeId, name }] }],
|
|
22
|
+
* variables: [{ id, collectionId, name, resolvedType,
|
|
23
|
+
* value, // first mode, ALREADY FORMATTED
|
|
24
|
+
* valuesByMode }], // { [modeId]: formatted string }
|
|
25
|
+
* shadeGroups: [...] // not surfaced by these tools
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* EVERY VALUE IS A STRING, and the plugin formatted it (`formatValue`):
|
|
29
|
+
*
|
|
30
|
+
* '#ff0088' a colour, always 6-digit hex (alpha is dropped by
|
|
31
|
+
* the plugin's own formatter for opaque colours)
|
|
32
|
+
* '16' a number
|
|
33
|
+
* 'true' / 'false' a boolean
|
|
34
|
+
* '{color/brand/500}' AN ALIAS to the variable with that NAME
|
|
35
|
+
* '→ VariableID:1:2' an alias whose target could not be read back
|
|
36
|
+
* 'undefined' no value set for that mode
|
|
37
|
+
*
|
|
38
|
+
* So a reference is discovered by parsing `{...}` out of a formatted string.
|
|
39
|
+
* That is not a shortcut — it is the only representation the wire carries, and
|
|
40
|
+
* it is the same representation `parseValue` accepts when writing one back.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** A `{name}` alias in a formatted value, or null. */
|
|
44
|
+
export function referenceTarget(formattedValue) {
|
|
45
|
+
if (typeof formattedValue !== 'string') return null;
|
|
46
|
+
const match = formattedValue.match(/^\{(.+)\}$/);
|
|
47
|
+
return match ? match[1] : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The group part of a variable name: everything before the last `/`.
|
|
52
|
+
*
|
|
53
|
+
* A "group" in Figma is not an object. It is a naming convention that Figma's
|
|
54
|
+
* own UI renders as a folder. `color/brand/500` is in group `color/brand`.
|
|
55
|
+
* A name with no `/` is in no group.
|
|
56
|
+
*/
|
|
57
|
+
export function groupOf(name) {
|
|
58
|
+
const cut = String(name ?? '').lastIndexOf('/');
|
|
59
|
+
return cut === -1 ? null : name.slice(0, cut);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every group and ancestor group present in a list of names, with counts. */
|
|
63
|
+
export function groupsOf(names) {
|
|
64
|
+
const counts = new Map();
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
const parts = String(name ?? '').split('/');
|
|
67
|
+
// The last part is the leaf variable name, never a group.
|
|
68
|
+
for (let i = 1; i < parts.length; i++) {
|
|
69
|
+
const prefix = parts.slice(0, i).join('/');
|
|
70
|
+
counts.set(prefix, (counts.get(prefix) ?? 0) + 1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [...counts.entries()]
|
|
74
|
+
.map(([name, variables]) => ({ name, variables }))
|
|
75
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True when `name` is inside `group` (or is deeper inside it). */
|
|
79
|
+
export function inGroup(name, group) {
|
|
80
|
+
if (!group) return true;
|
|
81
|
+
return String(name ?? '').startsWith(`${group}/`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const TYPES = ['COLOR', 'FLOAT', 'STRING', 'BOOLEAN'];
|
|
85
|
+
|
|
86
|
+
export function normaliseType(type) {
|
|
87
|
+
if (type === undefined || type === null || type === '') return null;
|
|
88
|
+
const upper = String(type).toUpperCase();
|
|
89
|
+
if (!TYPES.includes(upper)) {
|
|
90
|
+
throw new Error(`Unknown variable type ${JSON.stringify(type)}. Figma has exactly four: ${TYPES.join(', ')}.`);
|
|
91
|
+
}
|
|
92
|
+
return upper;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolve a collection by id or by name.
|
|
97
|
+
*
|
|
98
|
+
* Ids are preferred and are what every tool returns, but a model reading a
|
|
99
|
+
* conversation has names, so names work too. An ambiguous name is an error
|
|
100
|
+
* rather than a guess: silently picking the first of two collections called
|
|
101
|
+
* "Colors" would put a variable somewhere the user did not ask for.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveCollection(snapshot, ref) {
|
|
104
|
+
const collections = snapshot.collections ?? [];
|
|
105
|
+
if (!ref) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`No collection given. Call list_collections first; pass the id (preferred) or the exact name.`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const byId = collections.find((c) => c.id === ref);
|
|
112
|
+
if (byId) return byId;
|
|
113
|
+
|
|
114
|
+
const exact = collections.filter((c) => c.name === ref);
|
|
115
|
+
if (exact.length === 1) return exact[0];
|
|
116
|
+
if (exact.length > 1) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`${exact.length} collections are called ${JSON.stringify(ref)}. Pass an id instead: ${exact.map((c) => c.id).join(', ')}.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const loose = collections.filter((c) => c.name.toLowerCase() === String(ref).toLowerCase());
|
|
123
|
+
if (loose.length === 1) return loose[0];
|
|
124
|
+
|
|
125
|
+
throw new Error(
|
|
126
|
+
`No collection ${JSON.stringify(ref)}. This file has: ${collections.map((c) => `${c.name} (${c.id})`).join(', ') || '(none)'}.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Resolve a variable by id or by full name (`group/sub/leaf`). */
|
|
131
|
+
export function resolveVariable(snapshot, ref) {
|
|
132
|
+
const variables = snapshot.variables ?? [];
|
|
133
|
+
if (!ref) {
|
|
134
|
+
throw new Error(`No variable given. Pass the id from list_variables, or the variable's full name.`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const byId = variables.find((v) => v.id === ref);
|
|
138
|
+
if (byId) return byId;
|
|
139
|
+
|
|
140
|
+
const exact = variables.filter((v) => v.name === ref);
|
|
141
|
+
if (exact.length === 1) return exact[0];
|
|
142
|
+
if (exact.length > 1) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`${exact.length} variables are named ${JSON.stringify(ref)} (in different collections). Pass an id: ` +
|
|
145
|
+
exact.map((v) => `${v.id} in ${collectionName(snapshot, v.collectionId)}`).join(', ')
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const loose = variables.filter((v) => v.name.toLowerCase() === String(ref).toLowerCase());
|
|
150
|
+
if (loose.length === 1) return loose[0];
|
|
151
|
+
|
|
152
|
+
const near = variables
|
|
153
|
+
.filter((v) => v.name.toLowerCase().includes(String(ref).toLowerCase()))
|
|
154
|
+
.slice(0, 8)
|
|
155
|
+
.map((v) => v.name);
|
|
156
|
+
|
|
157
|
+
throw new Error(
|
|
158
|
+
`No variable ${JSON.stringify(ref)}.` +
|
|
159
|
+
(near.length ? ` Did you mean: ${near.join(', ')}?` : ` Use list_variables to see what exists.`)
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function collectionName(snapshot, collectionId) {
|
|
164
|
+
const found = (snapshot.collections ?? []).find((c) => c.id === collectionId);
|
|
165
|
+
return found ? found.name : `(unknown collection ${collectionId})`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function collectionOf(snapshot, variable) {
|
|
169
|
+
return (snapshot.collections ?? []).find((c) => c.id === variable.collectionId) ?? null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolve a mode by name or modeId, within one collection.
|
|
174
|
+
*
|
|
175
|
+
* Done here rather than left to the plugin on purpose. The plugin's
|
|
176
|
+
* `resolveModeIdForVariable` FALLS BACK to the variable's first mode when it
|
|
177
|
+
* does not recognise the mode it was given — sensible for a UI where the user
|
|
178
|
+
* can see what happened, silently wrong for a tool call, where "Dark" landing
|
|
179
|
+
* in "Light" would be discovered days later.
|
|
180
|
+
*/
|
|
181
|
+
export function resolveMode(collection, ref) {
|
|
182
|
+
const modes = collection?.modes ?? [];
|
|
183
|
+
if (modes.length === 0) {
|
|
184
|
+
throw new Error(`Collection ${collection?.name} has no modes, which should be impossible. Refusing to guess.`);
|
|
185
|
+
}
|
|
186
|
+
if (ref === undefined || ref === null || ref === '') {
|
|
187
|
+
if (modes.length === 1) return modes[0];
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Collection ${JSON.stringify(collection.name)} has ${modes.length} modes (${modes.map((m) => m.name).join(', ')}). ` +
|
|
190
|
+
`Say which one — a value written to the wrong mode looks right until someone switches theme.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const byId = modes.find((m) => m.modeId === ref);
|
|
195
|
+
if (byId) return byId;
|
|
196
|
+
|
|
197
|
+
const byName = modes.filter((m) => m.name === ref);
|
|
198
|
+
if (byName.length === 1) return byName[0];
|
|
199
|
+
|
|
200
|
+
const loose = modes.filter((m) => m.name.toLowerCase() === String(ref).toLowerCase());
|
|
201
|
+
if (loose.length === 1) return loose[0];
|
|
202
|
+
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Collection ${JSON.stringify(collection.name)} has no mode ${JSON.stringify(ref)}. It has: ` +
|
|
205
|
+
modes.map((m) => `${m.name} (${m.modeId})`).join(', ')
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `{ [modeName]: formattedValue }` for one variable. */
|
|
210
|
+
export function valuesByModeName(snapshot, variable) {
|
|
211
|
+
const collection = collectionOf(snapshot, variable);
|
|
212
|
+
const out = {};
|
|
213
|
+
for (const [modeId, value] of Object.entries(variable.valuesByMode ?? {})) {
|
|
214
|
+
const mode = collection?.modes.find((m) => m.modeId === modeId);
|
|
215
|
+
out[mode ? mode.name : modeId] = value;
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Collections plus the two things a caller always needs next. */
|
|
221
|
+
export function collectionsOverview(snapshot) {
|
|
222
|
+
const variables = snapshot.variables ?? [];
|
|
223
|
+
return (snapshot.collections ?? []).map((collection) => {
|
|
224
|
+
const own = variables.filter((v) => v.collectionId === collection.id);
|
|
225
|
+
return {
|
|
226
|
+
id: collection.id,
|
|
227
|
+
name: collection.name,
|
|
228
|
+
modes: collection.modes.map((m) => ({ id: m.modeId, name: m.name })),
|
|
229
|
+
variableCount: own.length,
|
|
230
|
+
groups: groupsOf(own.map((v) => v.name)),
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Filter the snapshot's variables.
|
|
237
|
+
*
|
|
238
|
+
* @param {object} snapshot
|
|
239
|
+
* @param {object} [filters]
|
|
240
|
+
* @param {string} [filters.collection] id or name
|
|
241
|
+
* @param {string} [filters.group] name prefix, e.g. 'color/brand'
|
|
242
|
+
* @param {string} [filters.type] COLOR | FLOAT | STRING | BOOLEAN
|
|
243
|
+
* @param {string} [filters.name_contains] case-insensitive substring
|
|
244
|
+
*/
|
|
245
|
+
export function filterVariables(snapshot, filters = {}) {
|
|
246
|
+
const type = normaliseType(filters.type);
|
|
247
|
+
const collection = filters.collection ? resolveCollection(snapshot, filters.collection) : null;
|
|
248
|
+
const group = filters.group ? String(filters.group).replace(/\/+$/, '') : null;
|
|
249
|
+
const needle = filters.name_contains ? String(filters.name_contains).toLowerCase() : null;
|
|
250
|
+
|
|
251
|
+
return (snapshot.variables ?? []).filter((v) => {
|
|
252
|
+
if (collection && v.collectionId !== collection.id) return false;
|
|
253
|
+
if (type && v.resolvedType !== type) return false;
|
|
254
|
+
if (group && !inGroup(v.name, group)) return false;
|
|
255
|
+
if (needle && !v.name.toLowerCase().includes(needle)) return false;
|
|
256
|
+
return true;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The row shape `list_variables` returns. */
|
|
261
|
+
export function summariseVariable(snapshot, variable) {
|
|
262
|
+
return {
|
|
263
|
+
id: variable.id,
|
|
264
|
+
name: variable.name,
|
|
265
|
+
type: variable.resolvedType,
|
|
266
|
+
collection: collectionName(snapshot, variable.collectionId),
|
|
267
|
+
values: valuesByModeName(snapshot, variable),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Everything `get_variable` reports, including both directions of the
|
|
273
|
+
* reference graph.
|
|
274
|
+
*
|
|
275
|
+
* `references` is per mode, because a variable can alias one token in Light and
|
|
276
|
+
* a different one in Dark, and a single flat list would hide exactly the case
|
|
277
|
+
* worth looking at.
|
|
278
|
+
*/
|
|
279
|
+
export function describeVariable(snapshot, variable) {
|
|
280
|
+
const variables = snapshot.variables ?? [];
|
|
281
|
+
const collection = collectionOf(snapshot, variable);
|
|
282
|
+
const values = valuesByModeName(snapshot, variable);
|
|
283
|
+
|
|
284
|
+
const references = [];
|
|
285
|
+
for (const [modeName, value] of Object.entries(values)) {
|
|
286
|
+
const target = referenceTarget(value);
|
|
287
|
+
if (!target) continue;
|
|
288
|
+
const resolved = variables.filter((v) => v.name === target);
|
|
289
|
+
references.push({
|
|
290
|
+
mode: modeName,
|
|
291
|
+
referencesName: target,
|
|
292
|
+
referencesId: resolved.length === 1 ? resolved[0].id : null,
|
|
293
|
+
referencesCollection: resolved.length === 1 ? collectionName(snapshot, resolved[0].collectionId) : null,
|
|
294
|
+
note:
|
|
295
|
+
resolved.length === 0
|
|
296
|
+
? 'That name is not among the local variables — the alias points at a variable from a library, or at one that has been deleted.'
|
|
297
|
+
: resolved.length > 1
|
|
298
|
+
? `${resolved.length} local variables share that name, so which one this is cannot be told from the snapshot.`
|
|
299
|
+
: undefined,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const referencedBy = [];
|
|
304
|
+
for (const other of variables) {
|
|
305
|
+
if (other.id === variable.id) continue;
|
|
306
|
+
const modes = [];
|
|
307
|
+
for (const [modeName, value] of Object.entries(valuesByModeName(snapshot, other))) {
|
|
308
|
+
if (referenceTarget(value) === variable.name) modes.push(modeName);
|
|
309
|
+
}
|
|
310
|
+
if (modes.length) {
|
|
311
|
+
referencedBy.push({
|
|
312
|
+
id: other.id,
|
|
313
|
+
name: other.name,
|
|
314
|
+
collection: collectionName(snapshot, other.collectionId),
|
|
315
|
+
inModes: modes,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const sameName = variables.filter((v) => v.name === variable.name);
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
id: variable.id,
|
|
324
|
+
name: variable.name,
|
|
325
|
+
type: variable.resolvedType,
|
|
326
|
+
group: groupOf(variable.name),
|
|
327
|
+
collection: collection ? { id: collection.id, name: collection.name } : null,
|
|
328
|
+
values,
|
|
329
|
+
references,
|
|
330
|
+
referencedBy,
|
|
331
|
+
referencedByCount: referencedBy.length,
|
|
332
|
+
...(sameName.length > 1
|
|
333
|
+
? {
|
|
334
|
+
warning:
|
|
335
|
+
`${sameName.length} variables in this file are named ${JSON.stringify(variable.name)}. ` +
|
|
336
|
+
`A reference is written as {name}, so "what references it" cannot distinguish them, and writing ` +
|
|
337
|
+
`{${variable.name}} elsewhere resolves to whichever the plugin finds first.`,
|
|
338
|
+
}
|
|
339
|
+
: {}),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* What deleting this variable takes with it, gathered BEFORE the delete.
|
|
345
|
+
*
|
|
346
|
+
* Two distinct blast radii, and neither is visible from the delete's own
|
|
347
|
+
* `update-success`:
|
|
348
|
+
* - variables that alias it. Figma stores an alias by id, so deleting the
|
|
349
|
+
* target leaves them dangling.
|
|
350
|
+
* - generated shades. `deleteVariable` in src/plugin/code.ts reads the
|
|
351
|
+
* variable's shade-generator config and removes EVERY shade it manages, so
|
|
352
|
+
* one delete can take a whole ramp with it.
|
|
353
|
+
*/
|
|
354
|
+
export function deletionImpact(snapshot, variable) {
|
|
355
|
+
const detail = describeVariable(snapshot, variable);
|
|
356
|
+
|
|
357
|
+
// `shadeGroups` rows are `{ sourceVariableId, deleteIds, config, … }` — see
|
|
358
|
+
// `buildShadeGroups` in src/plugin/code.ts. `config.generatedShades` is the
|
|
359
|
+
// list `deleteVariable` actually walks and removes, so that is the list to
|
|
360
|
+
// report; `deleteIds` (what the UI's own delete would sweep) is the wider
|
|
361
|
+
// net and is only a fallback.
|
|
362
|
+
const shadeGroup = (snapshot.shadeGroups ?? []).find((g) => g?.sourceVariableId === variable.id);
|
|
363
|
+
const generated = shadeGroup?.config?.generatedShades ?? [];
|
|
364
|
+
const byId = new Map((snapshot.variables ?? []).map((v) => [v.id, v.name]));
|
|
365
|
+
|
|
366
|
+
const managedShades = (generated.length ? generated.map((s) => s?.id) : (shadeGroup?.deleteIds ?? []))
|
|
367
|
+
.filter(Boolean)
|
|
368
|
+
.map((id) => ({ id, name: byId.get(id) ?? '(already gone)' }));
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
referencedBy: detail.referencedBy,
|
|
372
|
+
managedShades,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** `group` + `name` → the full variable name Figma stores. */
|
|
377
|
+
export function groupedName(group, name) {
|
|
378
|
+
const clean = String(group ?? '').replace(/^\/+|\/+$/g, '');
|
|
379
|
+
if (!clean) return name;
|
|
380
|
+
return `${clean}/${name}`;
|
|
381
|
+
}
|