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 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
+ }