@toghen/figma 0.0.0-stage → 0.1.1

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 (118) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -2
  3. package/cli/bin.mjs +19 -0
  4. package/cli/bridge.ts +243 -0
  5. package/cli/check.ts +62 -0
  6. package/cli/figcraft.ts +764 -0
  7. package/cli/figcraftApply.ts +1067 -0
  8. package/cli/figcraftFile.ts +476 -0
  9. package/cli/figcraftLibrary.ts +147 -0
  10. package/cli/figcraftRecord.ts +121 -0
  11. package/cli/figcraftScene.ts +661 -0
  12. package/cli/main.ts +218 -0
  13. package/cli/members.ts +65 -0
  14. package/cli/run.ts +756 -0
  15. package/component-files.md +665 -0
  16. package/dist/code.js +24161 -0
  17. package/dist/manifest.json +23 -0
  18. package/dist/ui.html +6918 -0
  19. package/guide.md +985 -0
  20. package/main/code.ts +50 -0
  21. package/main/components/apply.ts +704 -0
  22. package/main/components/endOfRun.ts +168 -0
  23. package/main/components/errors.ts +17 -0
  24. package/main/components/fieldWriter.ts +497 -0
  25. package/main/components/file.ts +263 -0
  26. package/main/components/receipt.ts +228 -0
  27. package/main/components/resolve.ts +1193 -0
  28. package/main/components/run.ts +273 -0
  29. package/main/components/snapshot.ts +341 -0
  30. package/main/components/stamp.ts +312 -0
  31. package/main/components/update.ts +465 -0
  32. package/main/dispatcher.ts +33 -0
  33. package/main/engines/batchRunner.ts +200 -0
  34. package/main/engines/ioAdapter.ts +132 -0
  35. package/main/engines/operationExecutor.ts +512 -0
  36. package/main/engines/propertyMapper.ts +221 -0
  37. package/main/engines/queryEngine.ts +45 -0
  38. package/main/engines/styleSyncEngine.ts +444 -0
  39. package/main/engines/variableSyncEngine.ts +561 -0
  40. package/main/handlers/auditSelection.ts +60 -0
  41. package/main/handlers/boundLayers.ts +51 -0
  42. package/main/handlers/cancelOperation.ts +18 -0
  43. package/main/handlers/executeApplyBindings.ts +69 -0
  44. package/main/handlers/executeScaffold.ts +59 -0
  45. package/main/handlers/executeStyleSync.ts +121 -0
  46. package/main/handlers/executeVariableSync.ts +82 -0
  47. package/main/handlers/exportDocument.ts +114 -0
  48. package/main/handlers/getSelection.ts +43 -0
  49. package/main/handlers/index.ts +30 -0
  50. package/main/handlers/inspectCorrespondence.ts +172 -0
  51. package/main/handlers/loadConventions.ts +86 -0
  52. package/main/handlers/loadDocument.ts +253 -0
  53. package/main/handlers/lock.ts +270 -0
  54. package/main/handlers/planApply.ts +157 -0
  55. package/main/handlers/planApplyBindings.ts +79 -0
  56. package/main/handlers/planScaffold.ts +84 -0
  57. package/main/handlers/planStyleSync.ts +49 -0
  58. package/main/handlers/planVariableSync.ts +49 -0
  59. package/main/handlers/readSelectionBindings.ts +160 -0
  60. package/main/handlers/resizeUi.ts +105 -0
  61. package/main/handlers/runComponent.ts +65 -0
  62. package/main/handlers/templates.ts +462 -0
  63. package/main/handlers/tokenGraph.ts +122 -0
  64. package/main/runtimeState.ts +66 -0
  65. package/main/utils/abortController.ts +11 -0
  66. package/main/utils/boundVariables.ts +84 -0
  67. package/main/utils/buildPayloads.ts +85 -0
  68. package/main/utils/correspondence.ts +658 -0
  69. package/main/utils/designSource.ts +122 -0
  70. package/main/utils/figmaCollectionIndex.ts +120 -0
  71. package/main/utils/figmaStyleAdapter.ts +268 -0
  72. package/main/utils/figmaTokenAdapter.ts +255 -0
  73. package/main/utils/flattenDesignSystem.ts +38 -0
  74. package/main/utils/folds.ts +203 -0
  75. package/main/utils/fontStack.ts +57 -0
  76. package/main/utils/lists.ts +4 -0
  77. package/main/utils/lockStore.ts +72 -0
  78. package/main/utils/moves.ts +67 -0
  79. package/main/utils/pageRoots.ts +58 -0
  80. package/main/utils/planSymbols.ts +96 -0
  81. package/main/utils/purpose.ts +351 -0
  82. package/main/utils/recentStore.ts +166 -0
  83. package/main/utils/resolverModes.ts +271 -0
  84. package/main/utils/rootRecord.ts +137 -0
  85. package/main/utils/shardedData.ts +142 -0
  86. package/main/utils/standing.ts +106 -0
  87. package/main/utils/styleTarget.ts +51 -0
  88. package/main/utils/sum.ts +612 -0
  89. package/main/utils/symbolicIdRegistry.ts +26 -0
  90. package/main/utils/syncState.ts +133 -0
  91. package/main/utils/templateStore.ts +73 -0
  92. package/main/utils/tokenPath.ts +28 -0
  93. package/main/utils/tokenStamps.ts +91 -0
  94. package/manifest.json +15 -0
  95. package/package.json +64 -4
  96. package/reference.md +1609 -0
  97. package/shared/components/artboard.ts +126 -0
  98. package/shared/components/axes.ts +420 -0
  99. package/shared/components/bindings.ts +64 -0
  100. package/shared/components/blueprint.ts +248 -0
  101. package/shared/components/check.ts +705 -0
  102. package/shared/components/diff.ts +668 -0
  103. package/shared/components/fields.ts +404 -0
  104. package/shared/components/groups.ts +306 -0
  105. package/shared/components/index.ts +140 -0
  106. package/shared/components/members.ts +280 -0
  107. package/shared/components/model.ts +273 -0
  108. package/shared/components/order.ts +53 -0
  109. package/shared/components/read.ts +480 -0
  110. package/shared/components/stack.ts +124 -0
  111. package/shared/components/steps.ts +186 -0
  112. package/shared/components/validate.ts +565 -0
  113. package/shared/conventions.ts +28 -0
  114. package/shared/inputs.ts +40 -0
  115. package/shared/operations.ts +233 -0
  116. package/shared/pendingNames.ts +39 -0
  117. package/shared/protocol.ts +588 -0
  118. package/shared/tokenTypeMap.ts +21 -0
@@ -0,0 +1,561 @@
1
+ import type { Resolver } from '@toghen/lib';
2
+ import type { TokenTypeConversionMap } from '../../shared/tokenTypeMap.js';
3
+ import { countSkipsByCause, type Operation, type SkipCounts, type SyncPlan } from '../../shared/operations.js';
4
+ import { indexFigmaCollections } from '../utils/figmaCollectionIndex.js';
5
+ import { flatMap } from '../utils/lists.js';
6
+ import {
7
+ compareToken,
8
+ describeRouting,
9
+ driftedModes as driftedModesOf,
10
+ staleModes as staleModesOf,
11
+ unwritableCells,
12
+ } from '../utils/correspondence.js';
13
+ import { builtEarlier, closedBecause, standingOf, type CollectionStanding } from '../utils/standing.js';
14
+ import { MAX_MODES } from '../utils/resolverModes.js';
15
+ import { layoutSaid } from '../utils/folds.js';
16
+ import { movesOf } from '../utils/moves.js';
17
+ import { keepsNoStamp } from '../components/stamp.js';
18
+ import { buildSum, type Sum, type SumCollection } from '../utils/sum.js';
19
+
20
+ export interface PlanSummary {
21
+ [key: string]: number | SkipCounts;
22
+ total: number;
23
+ creates: number;
24
+ updates: number;
25
+ skips: number;
26
+ /** `skips` by cause: their sum is `skips`. */
27
+ skipsByCause: SkipCounts;
28
+ /** Paths Figma holds a different value for than the last sync wrote. */
29
+ drifted: number;
30
+ modes: number;
31
+ warnings: number;
32
+ renames: number;
33
+ /** Variables whose scopes the plan writes, apart from creating them. */
34
+ scopes: number;
35
+ /** Steps that lose something in Figma: modes the document no longer has. */
36
+ breaking: number;
37
+ }
38
+
39
+ export interface PlanOptions {
40
+ /** Whether the plan carries the steps that lose something in Figma, or only lists them. */
41
+ allowBreaking?: boolean;
42
+ /** Whether a mode Figma holds another value for than the sync wrote is written with the document's, or left alone. */
43
+ overwriteDrift?: boolean;
44
+ }
45
+
46
+ /** @see SyncPlan — shared with style sync, which has the same contract. */
47
+ export type VariablePlan = SyncPlan;
48
+
49
+ /**
50
+ * Moves every `SET_VARIABLE_VALUE` after every `CREATE_VARIABLE`, keeping each
51
+ * group's order.
52
+ *
53
+ * Paths are planned in document order, so an alias can be planned before the
54
+ * variable it points at is created, and the runner resolves a symbolic id only
55
+ * once its producer has run. A stable partition is all that fixes: the one
56
+ * dependency between those two kinds is set-after-create, and everything else
57
+ * stays where document order put it. A general sort would reorder rows for no
58
+ * dependency at all.
59
+ */
60
+ function createsBeforeSets(operations: Operation[]): Operation[] {
61
+ const sets = operations.filter((op) => op.kind === 'SET_VARIABLE_VALUE');
62
+ const rest = operations.filter((op) => op.kind !== 'SET_VARIABLE_VALUE');
63
+
64
+ let lastCreate = -1;
65
+ rest.forEach((op, index) => {
66
+ if (op.kind === 'CREATE_VARIABLE') lastCreate = index;
67
+ });
68
+ if (lastCreate < 0) return operations;
69
+
70
+ return [...rest.slice(0, lastCreate + 1), ...sets, ...rest.slice(lastCreate + 1)];
71
+ }
72
+
73
+ function scopesOperation(variableId: string, scopes: VariableScope[], path: string): Operation {
74
+ return { kind: 'SET_VARIABLE_SCOPES', variableId, scopes, path };
75
+ }
76
+
77
+ /** Writes the web code syntax. Android and iOS are not written: their spellings are not Toghen's to invent. */
78
+ function codeSyntaxOperation(variableId: string, value: string, path: string): Operation {
79
+ return { kind: 'SET_CODE_SYNTAX', variableId, platform: 'WEB', value, path };
80
+ }
81
+
82
+ export class VariableSyncEngine {
83
+ /**
84
+ * Builds a variable sync plan: the sum, a base collection and a collection per modifier.
85
+ *
86
+ * Where every token lives, and what each mode of it holds, is `utils/sum.ts`;
87
+ * the comparison with Figma lives in `utils/correspondence.ts`, which the
88
+ * Inspector reads too. This method decides only what to *do* about the
89
+ * answer — see that file for how a variable's stamp tells a hand edit from a
90
+ * document that moved.
91
+ *
92
+ * `collectionName` names the base collection. A modifier's collection is named
93
+ * after the modifier.
94
+ *
95
+ * A token whose home changed since the last sync (`utils/moves.ts`) is one breaking step: the old variable is
96
+ * removed and a new one created, aliases to the old one written again.
97
+ *
98
+ * A mode a modifier's collection holds and the modifier no longer has is a
99
+ * breaking step: it is always listed (`BREAKING`), and the plan carries the
100
+ * deletion (`DELETE_MODE`, after every value is written) only with `allowBreaking`.
101
+ */
102
+ async buildPlan(
103
+ resolver: Resolver,
104
+ collectionName: string,
105
+ conversionMap: TokenTypeConversionMap,
106
+ options: PlanOptions = {},
107
+ ): Promise<VariablePlan> {
108
+ const deletions: Operation[] = [];
109
+ const operations: Operation[] = [];
110
+ // A plan that stops before its first write: one warning, nothing to run.
111
+ const stopped = (message: string): VariablePlan => ({
112
+ operations: [{ kind: 'WARNING', message, stops: true }],
113
+ });
114
+
115
+ let sum: Sum;
116
+ try {
117
+ sum = buildSum(resolver, collectionName, conversionMap);
118
+ } catch (error) {
119
+ return stopped(error instanceof Error ? error.message : String(error));
120
+ }
121
+
122
+ // Figma refuses the 11th mode of a collection, after everything before it is written. Found here, a dry run
123
+ // finds it too. Every collection over the limit is said, so a document is fixed in one pass.
124
+ const over = sum.collections.filter((collection) => collection.modes.length > MAX_MODES);
125
+ if (over.length > 0) {
126
+ return {
127
+ operations: over.map((collection) => ({
128
+ kind: 'WARNING' as const,
129
+ message:
130
+ `"${collection.name}" has ${collection.modes.length} contexts for Figma and Figma allows ${MAX_MODES} modes per collection; ` +
131
+ `keep at most ${MAX_MODES} of them in figma.include.`,
132
+ stops: true as const,
133
+ })),
134
+ };
135
+ }
136
+
137
+ if (sum.tokens.length === 0) {
138
+ return { operations };
139
+ }
140
+
141
+ const sharing = sum.collections.slice(1).find((collection) => collection.name === collectionName);
142
+ if (sharing) {
143
+ return stopped(
144
+ `The base collection is named "${collectionName}", which is also the name of the modifier ` +
145
+ `"${sharing.name}" and its collection. Name the base collection something else.`,
146
+ );
147
+ }
148
+
149
+ const figmaIndex = await indexFigmaCollections();
150
+ const standing = standingOf(sum, figmaIndex);
151
+
152
+ // The base collection holds the tokens no modifier defines. If an earlier
153
+ // sync took its name, nothing is written at all: the run stops before it starts.
154
+ const base = standing.get(collectionName) as CollectionStanding;
155
+ if (builtEarlier(base)) {
156
+ return stopped((closedBecause(base) as { message: string }).message);
157
+ }
158
+
159
+ for (const spec of sum.collections) {
160
+ const here = standing.get(spec.name) as CollectionStanding;
161
+ const closed = closedBecause(here);
162
+ if (closed) {
163
+ operations.push({ kind: 'WARNING', message: `${closed.message} The tokens that live in it are not written.` });
164
+ } else if (!here.existing) {
165
+ operations.push({
166
+ kind: 'CREATE_COLLECTION',
167
+ symbolicId: here.id,
168
+ name: spec.name,
169
+ // Default first: Figma's default mode is always modes[0], and it can be
170
+ // neither assigned nor reordered afterwards, so creation order is the
171
+ // only non-destructive chance to get it right.
172
+ modes: spec.modes,
173
+ });
174
+ } else {
175
+ const reconciled = this.reconcileModes(here, spec, options.allowBreaking === true);
176
+ operations.push(...reconciled.operations);
177
+ deletions.push(...reconciled.deletions);
178
+ }
179
+ }
180
+
181
+ // A collection holding variables stamped for this document's tokens, which no collection of the sum is named for
182
+ // any more: its modifier is gone. Removing a whole collection is not designed, so it is left alone.
183
+ const named = new Set(sum.collections.map((collection) => collection.name));
184
+ const vanished = new Set<string>();
185
+ for (const [token, stamped] of Object.entries(figmaIndex.variablesByToken)) {
186
+ if (!token.startsWith(`${sum.dsName}.`)) continue;
187
+ for (const variable of stamped) {
188
+ const collection = figmaIndex.byId[variable.variableCollectionId];
189
+ if (collection && !named.has(collection.name)) vanished.add(collection.name);
190
+ }
191
+ }
192
+ for (const name of vanished) {
193
+ operations.push({
194
+ kind: 'WARNING',
195
+ message:
196
+ `Collection "${name}" holds variables this document wrote, but nothing in the document is named ` +
197
+ `"${name}" any more (a modifier of that name is gone). It is left alone.`,
198
+ });
199
+ }
200
+
201
+ // A person's copy of a variable this document's sync wrote carries its stamp, and is left alone: listed, so that a
202
+ // copy nobody noticed does not become a question later.
203
+ for (const { copy, of } of figmaIndex.copies) {
204
+ const token = of.stamp?.is.token;
205
+ if (token === undefined || !token.startsWith(`${sum.dsName}.`)) continue;
206
+ operations.push({
207
+ kind: 'SKIP',
208
+ reason: `"${copy.name}" (${copy.id}) is a copy of Toghen's "${of.name}", left alone`,
209
+ path: token,
210
+ cause: 'copy',
211
+ reported: true,
212
+ });
213
+ }
214
+
215
+ // No silent mixing: a component whose parts are not all folded is said, with the token or the condition that stopped each axis.
216
+ for (const layout of sum.layouts) {
217
+ const said = layoutSaid(layout);
218
+ if (said) operations.push({ kind: 'WARNING', message: said });
219
+ }
220
+
221
+ // What the purposes of the variables have to say apart from a token's refusal: a part that declares a list its variable ignores.
222
+ for (const message of sum.warnings) operations.push({ kind: 'WARNING', message });
223
+
224
+ // A token whose home changed since the last sync: the variable stamped for it is in the old collection. It is
225
+ // removed and a new one made (Figma has no move for a variable): the new variable is linked as a first one, and
226
+ // every value is written. An alias to the old one is written again, since the document's alias names the new one.
227
+ const moves = movesOf(sum, standing, figmaIndex);
228
+
229
+ for (const token of sum.tokens) {
230
+ const compared = compareToken({ token, sum, standing, figmaIndex });
231
+ const { routing, variables } = compared;
232
+ const path = token.path;
233
+
234
+ if (routing.kind === 'type-conflict') {
235
+ operations.push({
236
+ kind: 'WARNING',
237
+ message:
238
+ `Token has different types across contexts (${routing.types.join(', ')}); ` +
239
+ `a Figma variable has one type, so it cannot be synced.`,
240
+ path,
241
+ });
242
+ continue;
243
+ }
244
+ if (routing.kind === 'unsupported') {
245
+ operations.push({
246
+ kind: 'SKIP',
247
+ reason: `"${path}": unsupported token type ${routing.tokenType || 'none'}`,
248
+ path,
249
+ cause: 'unsupported-type',
250
+ });
251
+ continue;
252
+ }
253
+ // A style's token is the style plan's: this plan lists only the tokens it is for (ruling 11b).
254
+ if (routing.kind === 'style') continue;
255
+ if (routing.kind === 'missing-route') {
256
+ operations.push({
257
+ kind: 'WARNING',
258
+ message: `Missing variableType route for token type ${routing.tokenType}`,
259
+ path,
260
+ });
261
+ continue;
262
+ }
263
+ if (routing.kind === 'refused') {
264
+ // A list of scopes or an opacity Figma cannot hold is a skip, said once, as a value it cannot hold is.
265
+ operations.push(
266
+ routing.skip
267
+ ? { kind: 'SKIP', reason: describeRouting(routing), path, cause: 'refused-routing', reported: true }
268
+ : { kind: 'WARNING', message: describeRouting(routing), path },
269
+ );
270
+ continue;
271
+ }
272
+
273
+ // A token is written whole or not at all: a chain with a link in a
274
+ // collection nothing may be written to would be half a chain.
275
+ const refused = variables.find((variable) => variable.link.action === 'refuse');
276
+ if (refused && refused.link.action === 'refuse') {
277
+ operations.push({ kind: 'WARNING', message: refused.link.reason, path });
278
+ const closed = closedBecause(standing.get(refused.collection) as CollectionStanding);
279
+ operations.push({
280
+ kind: 'SKIP',
281
+ reason: `"${path}" is not written: ${closed?.skip ?? 'a variable it links to has another type'}`,
282
+ path,
283
+ cause: closed ? 'closed-collection' : 'type-conflict',
284
+ });
285
+ continue;
286
+ }
287
+
288
+ // A value Figma cannot hold is a skip, one row for each cell, and the token is not half written: the plan goes on.
289
+ const unwritable = unwritableCells(compared);
290
+ if (unwritable.length > 0) {
291
+ for (const cell of unwritable) {
292
+ operations.push({
293
+ kind: 'SKIP',
294
+ reason: `"${path}" cannot be written in ${cell.context}: ${cell.reason}`,
295
+ path,
296
+ cause: 'cannot-write',
297
+ });
298
+ }
299
+ continue;
300
+ }
301
+
302
+ const stale = variables.map((variable) => staleModesOf(variable, options.overwriteDrift));
303
+ // Taken on purpose, drift is written and not reported.
304
+ const drifted = options.overwriteDrift ? [] : [...new Set(flatMap(variables, (variable) => driftedModesOf(variable)))];
305
+ // Scopes follow the drift rule too: a list a designer changed is kept, and said.
306
+ const scopeDrift = options.overwriteDrift ? [] : variables.filter((variable) => variable.scopes.state === 'drift');
307
+ const writesScopes = (variable: (typeof variables)[number]): boolean =>
308
+ variable.scopes.state === 'write' || (variable.scopes.state === 'drift' && options.overwriteDrift === true);
309
+
310
+ const driftMessages = [
311
+ ...(drifted.length > 0
312
+ ? [
313
+ `Changed in Figma since the last sync (${drifted.join(', ')}). ` +
314
+ `Figma no longer holds the value that was written, so this is a local edit — ` +
315
+ `it is left alone rather than overwritten; pass --overwrite-drift to write the document's value.`,
316
+ ]
317
+ : []),
318
+ ...scopeDrift.map(
319
+ (variable) =>
320
+ `The scopes ${variables.length > 1 ? `of "${variable.name}" ` : ''}were changed in Figma since the last sync ` +
321
+ `(Figma holds ${(variable.scopes.held ?? []).join(', ') || 'none'}; the document asks for ${variable.scopes.planned.join(', ') || 'none'}). ` +
322
+ `They are left alone rather than overwritten; pass --overwrite-drift to write the document's scopes.`,
323
+ ),
324
+ ];
325
+ const driftMessage = driftMessages.length > 0 ? driftMessages.join(' ') : undefined;
326
+
327
+ for (const message of new Set(flatMap(variables, (variable) => variable.aliasWarnings))) {
328
+ operations.push({ kind: 'WARNING', message, path });
329
+ }
330
+
331
+ // The step that loses something: listed always, until it is taken, and carried only when the request allows
332
+ // breaking, after every value (and every alias to the old variable) is written.
333
+ for (const variable of variables) {
334
+ for (const move of moves.get(variable.key) ?? []) {
335
+ // A person's copy of the old variable loses its stamp with it, so it is never taken for the original moved.
336
+ const copies = figmaIndex.copies.filter(({ of }) => of.id === move.variableId).map(({ copy }) => copy);
337
+ operations.push({
338
+ kind: 'BREAKING',
339
+ message: `"${move.name}" moves from "${move.from}" to "${move.to}"; the old variable is removed${copies.length > 0 ? `; ${keepsNoStamp(copies)}` : ''}.`,
340
+ moved: { collection: move.from, name: move.oldName },
341
+ });
342
+ if (options.allowBreaking) {
343
+ deletions.push({
344
+ kind: 'DELETE_VARIABLE',
345
+ variableId: move.variableId,
346
+ variableName: move.oldName,
347
+ collectionName: move.from,
348
+ ...(variable.link.action === 'create' ? { after: variable.link.symbolicId } : {}),
349
+ ...(copies.length > 0 ? { strip: copies.map((copy) => copy.id) } : {}),
350
+ });
351
+ }
352
+ }
353
+ }
354
+
355
+ // A variable that moved records its own id on the next write to it; where the plan writes nothing to it that
356
+ // stamps it, the stamp is written on its own.
357
+ variables.forEach((variable, index) => {
358
+ const link = variable.link;
359
+ if (
360
+ link.action === 'use' &&
361
+ figmaIndex.variableById[link.variableId]?.moved &&
362
+ stale[index].length === 0 &&
363
+ !writesScopes(variable)
364
+ ) {
365
+ operations.push({ kind: 'STAMP', target: 'variable', id: link.variableId, path: variable.key });
366
+ }
367
+ });
368
+
369
+ if (stale.every((modes) => modes.length === 0)) {
370
+ // The values are the document's and Figma has not moved, but the variable
371
+ // may carry a name that is not today's (the collection prefix it had,
372
+ // `Demo/color/brand`, or a designer's rename) or lack the code syntax.
373
+ // Neither is a value, so both are written here and neither makes the path stale.
374
+ for (const variable of variables) {
375
+ if (variable.link.action !== 'use') continue;
376
+ if (variable.link.rename) {
377
+ operations.push({
378
+ kind: 'RENAME_VARIABLE',
379
+ variableId: variable.link.variableId,
380
+ name: variable.link.rename,
381
+ path: variable.key,
382
+ });
383
+ }
384
+ if (!variable.codeSyntaxHeld) {
385
+ operations.push(codeSyntaxOperation(variable.link.variableId, variable.codeSyntax, variable.key));
386
+ }
387
+ if (writesScopes(variable)) operations.push(scopesOperation(variable.link.variableId, variable.scopes.planned, variable.key));
388
+ }
389
+ // One SKIP per clean path, not one per mode — a large document would
390
+ // otherwise bury the real work under hundreds of rows. A drifted path
391
+ // is skipped too, but calling it "unchanged" would be a lie: something
392
+ // did change, in the file rather than the document.
393
+ //
394
+ // The explanation rides on the skip rather than beside it. A separate
395
+ // WARNING made one drifted token into two rows, and made the summary
396
+ // report the same count twice as `4 drifted · 4 warnings`.
397
+ operations.push({
398
+ kind: 'SKIP',
399
+ reason: driftMessage ? 'drifted' : 'unchanged',
400
+ ...(driftMessage ? { message: driftMessage } : {}),
401
+ path,
402
+ cause: driftMessage ? 'drifted' : 'unchanged',
403
+ });
404
+ continue;
405
+ }
406
+
407
+ // Drift and stale work can land on the same path — `light` edited in
408
+ // Figma while `dark` moved in the document. There is no SKIP to carry the
409
+ // explanation then, because the path *does* have work to do, so it stands
410
+ // on its own. Dropping it here is how the careless version of this change
411
+ // would lose a drift report entirely.
412
+ if (driftMessage) {
413
+ operations.push({ kind: 'WARNING', message: driftMessage, path });
414
+ }
415
+
416
+ variables.forEach((variable, index) => {
417
+ const here = standing.get(variable.collection) as CollectionStanding;
418
+ const link = variable.link;
419
+ if (link.action === 'refuse') return;
420
+
421
+ let variableId: string;
422
+ if (link.action === 'create') {
423
+ variableId = link.symbolicId;
424
+ operations.push({
425
+ kind: 'CREATE_VARIABLE',
426
+ symbolicId: variableId,
427
+ collectionId: here.id,
428
+ path: variable.name,
429
+ variableType: routing.variableType,
430
+ // A variable is made for what it is for, never with Figma's default of every field.
431
+ scopes: variable.scopes.planned,
432
+ tokenPath: variable.key,
433
+ });
434
+ operations.push(codeSyntaxOperation(variableId, variable.codeSyntax, variable.key));
435
+ } else {
436
+ variableId = link.variableId;
437
+ if (link.rename) {
438
+ operations.push({ kind: 'RENAME_VARIABLE', variableId, name: link.rename, path: variable.key });
439
+ }
440
+ if (!variable.codeSyntaxHeld) {
441
+ operations.push(codeSyntaxOperation(variableId, variable.codeSyntax, variable.key));
442
+ }
443
+ if (writesScopes(variable)) operations.push(scopesOperation(variableId, variable.scopes.planned, variable.key));
444
+ }
445
+
446
+ for (const { modeName, value } of stale[index]) {
447
+ operations.push({
448
+ kind: 'SET_VARIABLE_VALUE',
449
+ variableId,
450
+ modeId: here.modeId(modeName),
451
+ value,
452
+ label: `${variable.name} [${variable.collection}: ${modeName}]`,
453
+ tokenPath: variable.key,
454
+ modeName,
455
+ });
456
+ }
457
+ });
458
+ }
459
+
460
+ // What stays offered to every field is said once, so that it is a choice and not an oversight.
461
+ const everywhere = sum.tokens
462
+ .filter((token) => token.routing.kind === 'variable' && token.purpose?.source === 'type' && token.purpose.scopes[0] === 'ALL_SCOPES')
463
+ .map((token) => token.path);
464
+ if (everywhere.length > 0) {
465
+ const many = everywhere.length > 1;
466
+ operations.push({
467
+ kind: 'NOTE',
468
+ message: `${everywhere.length} variable${many ? 's are' : ' is'} offered to every field: ${everywhere.join(', ')}; declare figma.scopes to narrow ${many ? 'them' : 'it'}.`,
469
+ });
470
+ }
471
+
472
+ return { operations: [...createsBeforeSets(operations), ...deletions] };
473
+ }
474
+
475
+ /**
476
+ * Adds the modes the modifier has and the collection lacks, lists the modes the collection holds and the
477
+ * modifier no longer has as breaking, and reports the one mode problem that cannot be fixed.
478
+ *
479
+ * The deletions come back apart: they run last, after every value of the remaining modes is written.
480
+ * A collection whose Figma default mode is gone from the modifier is closed and never gets here.
481
+ * The default mode is the first of `modes`, and that is a context the modifier still has, so no mode
482
+ * listed for deletion is ever the default one.
483
+ */
484
+ private reconcileModes(
485
+ existing: CollectionStanding,
486
+ spec: SumCollection,
487
+ allowBreaking: boolean,
488
+ ): { operations: Operation[]; deletions: Operation[] } {
489
+ const collection = existing.existing as NonNullable<CollectionStanding['existing']>;
490
+ const operations: Operation[] = [];
491
+ const deletions: Operation[] = [];
492
+
493
+ for (const modeName of spec.modes) {
494
+ if (!collection.modes.some((mode) => mode.name === modeName)) {
495
+ operations.push({
496
+ kind: 'ADD_MODE',
497
+ collectionId: collection.id,
498
+ collectionName: collection.name,
499
+ modeName,
500
+ });
501
+ }
502
+ }
503
+
504
+ const figmaDefault = collection.modes[0]?.name;
505
+ for (const gone of existing.goneModes) {
506
+ operations.push({
507
+ kind: 'BREAKING',
508
+ message:
509
+ `the mode "${gone.name}" of collection "${collection.name}" is gone: values in it are lost, ` +
510
+ `and a frame or variant pinned to it falls back to "${figmaDefault}"`,
511
+ });
512
+ if (allowBreaking) {
513
+ deletions.push({
514
+ kind: 'DELETE_MODE',
515
+ collectionId: collection.id,
516
+ collectionName: collection.name,
517
+ modeId: gone.modeId,
518
+ modeName: gone.name,
519
+ });
520
+ }
521
+ }
522
+
523
+ // Figma's default mode is modes[0] and cannot be reassigned or reordered;
524
+ // the only way to change it destroys a mode and everything in it. So this
525
+ // reports rather than acts.
526
+ if (figmaDefault && figmaDefault !== spec.modes[0]) {
527
+ operations.push({
528
+ kind: 'WARNING',
529
+ message:
530
+ `Figma's default mode for collection "${collection.name}" is "${figmaDefault}", but the document's ` +
531
+ `default is "${spec.modes[0]}". Figma cannot reorder modes or reassign the ` +
532
+ `default, so nodes bound to these variables will resolve to "${figmaDefault}" unless ` +
533
+ `the collection is recreated.`,
534
+ });
535
+ }
536
+
537
+ return { operations, deletions };
538
+ }
539
+
540
+ summarizePlan(operations: Operation[]): PlanSummary {
541
+ return {
542
+ total: operations.length,
543
+ // A **subset** of `skips`, not a figure beside it: a drifted path is
544
+ // skipped, but for the opposite reason — not "nothing to do" but
545
+ // "something happened here that the document does not know about". So
546
+ // `52 skips · 4 drifted` reads as four of the fifty-two, and the two do
547
+ // not sum. An earlier comment here claimed they were counted apart,
548
+ // which they never were.
549
+ drifted: operations.filter((op) => op.kind === 'SKIP' && op.reason === 'drifted').length,
550
+ creates: operations.filter((op) => op.kind === 'CREATE_COLLECTION' || op.kind === 'CREATE_VARIABLE').length,
551
+ updates: operations.filter((op) => op.kind === 'SET_VARIABLE_VALUE' || op.kind === 'STAMP').length,
552
+ skips: operations.filter((op) => op.kind === 'SKIP').length,
553
+ skipsByCause: countSkipsByCause(operations),
554
+ modes: operations.filter((op) => op.kind === 'ADD_MODE').length,
555
+ warnings: operations.filter((op) => op.kind === 'WARNING').length,
556
+ renames: operations.filter((op) => op.kind === 'RENAME_VARIABLE').length,
557
+ scopes: operations.filter((op) => op.kind === 'SET_VARIABLE_SCOPES').length,
558
+ breaking: operations.filter((op) => op.kind === 'BREAKING').length,
559
+ };
560
+ }
561
+ }
@@ -0,0 +1,60 @@
1
+ import type { Command, QueryScope, Response } from '../../shared/protocol.js';
2
+ import { PropertyMapper } from '../engines/propertyMapper.js';
3
+ import { resolveScopeRoots } from '../utils/pageRoots.js';
4
+ import { getState } from '../runtimeState.js';
5
+
6
+ type AuditSelectionCommand = Extract<Command, { type: 'AUDIT_SELECTION' }>;
7
+
8
+ function postToUi(message: Response): void {
9
+ figma.ui.postMessage(message);
10
+ }
11
+
12
+ function collectFromRoots(roots: readonly SceneNode[]): SceneNode[] {
13
+ const out: SceneNode[] = [];
14
+
15
+ const walk = (node: SceneNode): void => {
16
+ out.push(node);
17
+ if ('children' in node) {
18
+ for (const child of node.children) {
19
+ walk(child);
20
+ }
21
+ }
22
+ };
23
+
24
+ for (const root of roots) {
25
+ walk(root);
26
+ }
27
+
28
+ return out;
29
+ }
30
+
31
+ async function resolveNodes(scope: QueryScope): Promise<SceneNode[]> {
32
+ return collectFromRoots(await resolveScopeRoots(scope));
33
+ }
34
+
35
+ export async function AUDIT_SELECTION(payload: AuditSelectionCommand['payload']): Promise<void> {
36
+ const state = getState();
37
+
38
+ if (state.conventions.length === 0) {
39
+ postToUi({
40
+ type: 'ERROR',
41
+ payload: {
42
+ message: 'Load conventions before running audit.',
43
+ code: 'INVALID_CONVENTIONS',
44
+ commandType: 'AUDIT_SELECTION',
45
+ },
46
+ });
47
+ return;
48
+ }
49
+
50
+ const mapper = new PropertyMapper(state.conventions);
51
+ const nodes = await resolveNodes(payload.scope);
52
+ const results = await Promise.all(nodes.map((node) => mapper.auditNode(node)));
53
+
54
+ postToUi({
55
+ type: 'AUDIT_RESULT',
56
+ payload: {
57
+ nodes: results,
58
+ },
59
+ });
60
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The layers a token's variable is bound to, on the current page, and
3
+ * selecting them.
4
+ *
5
+ * The current page only: under `documentAccess: dynamic-page` a whole-file scan
6
+ * loads every page, and the screen says "on this page" rather than claiming a
7
+ * number it did not read.
8
+ */
9
+
10
+ import type { Command, Response } from '../../shared/protocol.js';
11
+ import { boundVariableIds } from '../utils/boundVariables.js';
12
+
13
+ type Payload<T extends Command['type']> = Extract<Command, { type: T }>['payload'];
14
+
15
+ export async function COUNT_BOUND_LAYERS(payload: Payload<'COUNT_BOUND_LAYERS'>): Promise<void> {
16
+ const wanted = new Set(
17
+ (await figma.variables.getLocalVariablesAsync())
18
+ .filter((variable) => variable.name === payload.variableName)
19
+ .map((variable) => variable.id),
20
+ );
21
+ const ids: string[] = [];
22
+ if (wanted.size > 0) {
23
+ const visit = (node: SceneNode) => {
24
+ if (boundVariableIds(node).some((id) => wanted.has(id))) ids.push(node.id);
25
+ if ('children' in node) for (const child of node.children) visit(child);
26
+ };
27
+ for (const node of figma.currentPage.children) visit(node);
28
+ }
29
+ postToUi({ type: 'BOUND_LAYERS', payload: { variableName: payload.variableName, ids, pageName: figma.currentPage.name } });
30
+ }
31
+
32
+ /** Selects the layers with these ids that are on the current page, and brings them into view. */
33
+ export async function SELECT_NODES(payload: Payload<'SELECT_NODES'>): Promise<void> {
34
+ const nodes: SceneNode[] = [];
35
+ for (const id of payload.ids) {
36
+ const node = await figma.getNodeByIdAsync(id);
37
+ if (node && 'visible' in node && onCurrentPage(node)) nodes.push(node as SceneNode);
38
+ }
39
+ figma.currentPage.selection = nodes;
40
+ if (nodes.length > 0) figma.viewport.scrollAndZoomIntoView(nodes);
41
+ }
42
+
43
+ function onCurrentPage(node: BaseNode): boolean {
44
+ let current: BaseNode | null = node;
45
+ while (current && current.type !== 'PAGE') current = current.parent;
46
+ return current?.id === figma.currentPage.id;
47
+ }
48
+
49
+ function postToUi(response: Response): void {
50
+ figma.ui.postMessage(response);
51
+ }