@mp3wizard/figma-console-mcp 1.32.2 → 1.34.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 (141) hide show
  1. package/README.md +26 -17
  2. package/dist/cloudflare/core/cloud-websocket-connector.js +18 -0
  3. package/dist/cloudflare/core/design-code-tools.js +60 -17
  4. package/dist/cloudflare/core/design-system-manifest.js +19 -14
  5. package/dist/cloudflare/core/design-system-tools.js +43 -34
  6. package/dist/cloudflare/core/diagnose-tool.js +4 -0
  7. package/dist/cloudflare/core/enrichment/enrichment-service.js +11 -5
  8. package/dist/cloudflare/core/enrichment/style-resolver.js +38 -18
  9. package/dist/cloudflare/core/figma-api.js +118 -54
  10. package/dist/cloudflare/core/figma-tools.js +179 -63
  11. package/dist/cloudflare/core/port-discovery.js +404 -31
  12. package/dist/cloudflare/core/tokens/alias-resolver.js +75 -5
  13. package/dist/cloudflare/core/tokens/config.js +10 -0
  14. package/dist/cloudflare/core/tokens/dialect.js +232 -0
  15. package/dist/cloudflare/core/tokens/figma-converter.js +144 -16
  16. package/dist/cloudflare/core/tokens/formatters/css-vars.js +21 -12
  17. package/dist/cloudflare/core/tokens/formatters/dtcg.js +106 -30
  18. package/dist/cloudflare/core/tokens/formatters/json.js +28 -10
  19. package/dist/cloudflare/core/tokens/formatters/scss.js +19 -13
  20. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +15 -9
  21. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +14 -9
  22. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +11 -5
  23. package/dist/cloudflare/core/tokens/index.js +2 -1
  24. package/dist/cloudflare/core/tokens/parsers/dtcg.js +32 -5
  25. package/dist/cloudflare/core/tokens/schemas.js +4 -0
  26. package/dist/cloudflare/core/tokens-tools.js +1017 -88
  27. package/dist/cloudflare/core/version-tools.js +44 -3
  28. package/dist/cloudflare/core/websocket-connector.js +42 -0
  29. package/dist/cloudflare/core/websocket-server.js +99 -8
  30. package/dist/cloudflare/core/write-tools.js +355 -86
  31. package/dist/cloudflare/index.js +7 -7
  32. package/dist/core/design-code-tools.d.ts.map +1 -1
  33. package/dist/core/design-code-tools.js +60 -17
  34. package/dist/core/design-code-tools.js.map +1 -1
  35. package/dist/core/design-system-manifest.d.ts +1 -0
  36. package/dist/core/design-system-manifest.d.ts.map +1 -1
  37. package/dist/core/design-system-manifest.js +19 -14
  38. package/dist/core/design-system-manifest.js.map +1 -1
  39. package/dist/core/design-system-tools.d.ts.map +1 -1
  40. package/dist/core/design-system-tools.js +43 -34
  41. package/dist/core/design-system-tools.js.map +1 -1
  42. package/dist/core/diagnose-tool.d.ts +8 -0
  43. package/dist/core/diagnose-tool.d.ts.map +1 -1
  44. package/dist/core/diagnose-tool.js +4 -0
  45. package/dist/core/diagnose-tool.js.map +1 -1
  46. package/dist/core/enrichment/enrichment-service.d.ts.map +1 -1
  47. package/dist/core/enrichment/enrichment-service.js +11 -5
  48. package/dist/core/enrichment/enrichment-service.js.map +1 -1
  49. package/dist/core/enrichment/style-resolver.d.ts +7 -2
  50. package/dist/core/enrichment/style-resolver.d.ts.map +1 -1
  51. package/dist/core/enrichment/style-resolver.js +38 -18
  52. package/dist/core/enrichment/style-resolver.js.map +1 -1
  53. package/dist/core/figma-api.d.ts +18 -9
  54. package/dist/core/figma-api.d.ts.map +1 -1
  55. package/dist/core/figma-api.js +118 -54
  56. package/dist/core/figma-api.js.map +1 -1
  57. package/dist/core/figma-connector.d.ts +12 -0
  58. package/dist/core/figma-connector.d.ts.map +1 -1
  59. package/dist/core/figma-tools.d.ts.map +1 -1
  60. package/dist/core/figma-tools.js +179 -63
  61. package/dist/core/figma-tools.js.map +1 -1
  62. package/dist/core/port-discovery.d.ts +40 -0
  63. package/dist/core/port-discovery.d.ts.map +1 -1
  64. package/dist/core/port-discovery.js +404 -31
  65. package/dist/core/port-discovery.js.map +1 -1
  66. package/dist/core/tokens/alias-resolver.d.ts +45 -3
  67. package/dist/core/tokens/alias-resolver.d.ts.map +1 -1
  68. package/dist/core/tokens/alias-resolver.js +75 -5
  69. package/dist/core/tokens/alias-resolver.js.map +1 -1
  70. package/dist/core/tokens/config.d.ts +28 -0
  71. package/dist/core/tokens/config.d.ts.map +1 -1
  72. package/dist/core/tokens/config.js +10 -0
  73. package/dist/core/tokens/config.js.map +1 -1
  74. package/dist/core/tokens/dialect.d.ts +107 -0
  75. package/dist/core/tokens/dialect.d.ts.map +1 -0
  76. package/dist/core/tokens/dialect.js +233 -0
  77. package/dist/core/tokens/dialect.js.map +1 -0
  78. package/dist/core/tokens/figma-converter.d.ts +23 -2
  79. package/dist/core/tokens/figma-converter.d.ts.map +1 -1
  80. package/dist/core/tokens/figma-converter.js +144 -16
  81. package/dist/core/tokens/figma-converter.js.map +1 -1
  82. package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -1
  83. package/dist/core/tokens/formatters/css-vars.js +21 -12
  84. package/dist/core/tokens/formatters/css-vars.js.map +1 -1
  85. package/dist/core/tokens/formatters/dtcg.d.ts +2 -2
  86. package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -1
  87. package/dist/core/tokens/formatters/dtcg.js +106 -30
  88. package/dist/core/tokens/formatters/dtcg.js.map +1 -1
  89. package/dist/core/tokens/formatters/json.d.ts.map +1 -1
  90. package/dist/core/tokens/formatters/json.js +28 -10
  91. package/dist/core/tokens/formatters/json.js.map +1 -1
  92. package/dist/core/tokens/formatters/scss.d.ts.map +1 -1
  93. package/dist/core/tokens/formatters/scss.js +19 -13
  94. package/dist/core/tokens/formatters/scss.js.map +1 -1
  95. package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -1
  96. package/dist/core/tokens/formatters/style-dictionary-v3.js +15 -9
  97. package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -1
  98. package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -1
  99. package/dist/core/tokens/formatters/tailwind-v4.js +14 -9
  100. package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -1
  101. package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -1
  102. package/dist/core/tokens/formatters/tokens-studio.js +11 -5
  103. package/dist/core/tokens/formatters/tokens-studio.js.map +1 -1
  104. package/dist/core/tokens/index.d.ts +2 -1
  105. package/dist/core/tokens/index.d.ts.map +1 -1
  106. package/dist/core/tokens/index.js +2 -1
  107. package/dist/core/tokens/index.js.map +1 -1
  108. package/dist/core/tokens/parsers/dtcg.js +32 -5
  109. package/dist/core/tokens/parsers/dtcg.js.map +1 -1
  110. package/dist/core/tokens/schemas.d.ts +3 -0
  111. package/dist/core/tokens/schemas.d.ts.map +1 -1
  112. package/dist/core/tokens/schemas.js +4 -0
  113. package/dist/core/tokens/schemas.js.map +1 -1
  114. package/dist/core/tokens/types.d.ts +57 -1
  115. package/dist/core/tokens/types.d.ts.map +1 -1
  116. package/dist/core/tokens/types.js.map +1 -1
  117. package/dist/core/tokens-tools.d.ts +250 -7
  118. package/dist/core/tokens-tools.d.ts.map +1 -1
  119. package/dist/core/tokens-tools.js +1017 -88
  120. package/dist/core/tokens-tools.js.map +1 -1
  121. package/dist/core/version-tools.d.ts.map +1 -1
  122. package/dist/core/version-tools.js +44 -3
  123. package/dist/core/version-tools.js.map +1 -1
  124. package/dist/core/websocket-connector.d.ts +38 -0
  125. package/dist/core/websocket-connector.d.ts.map +1 -1
  126. package/dist/core/websocket-connector.js +42 -0
  127. package/dist/core/websocket-connector.js.map +1 -1
  128. package/dist/core/websocket-server.d.ts +23 -0
  129. package/dist/core/websocket-server.d.ts.map +1 -1
  130. package/dist/core/websocket-server.js +99 -8
  131. package/dist/core/websocket-server.js.map +1 -1
  132. package/dist/core/write-tools.d.ts.map +1 -1
  133. package/dist/core/write-tools.js +355 -86
  134. package/dist/core/write-tools.js.map +1 -1
  135. package/dist/local.d.ts +0 -1
  136. package/dist/local.d.ts.map +1 -1
  137. package/dist/local.js +253 -63
  138. package/dist/local.js.map +1 -1
  139. package/figma-desktop-bridge/code.js +382 -28
  140. package/figma-desktop-bridge/ui.html +578 -292
  141. package/package.json +2 -2
@@ -8,13 +8,18 @@
8
8
  * return TokenFormatNotImplementedError with a helpful message
9
9
  * directing users to DTCG.
10
10
  * - figma_import_tokens: working for DTCG JSON input with full
11
- * diff-aware merge. Apply phase pushes value updates (toUpdate) to
12
- * Figma via the plugin bridge verified end-to-end against real
13
- * multi-mode design systems. toCreate / toDelete / alias-target
14
- * updates surface in the diff plan but are not yet wired through
15
- * the apply phase (use figma_setup_design_tokens /
16
- * figma_batch_create_variables / figma_delete_variable manually for
17
- * those for now).
11
+ * diff-aware merge and a complete apply phase:
12
+ * toUpdate value updates batched via the plugin bridge,
13
+ * including alias-target updates ({ type: "VARIABLE_ALIAS", id })
14
+ * when the reference resolves to an existing or just-created
15
+ * variable.
16
+ * toCreate missing collections (created with the token file's
17
+ * full mode list) and missing variables in one batched script;
18
+ * literal values first, alias values in a second pass so
19
+ * within-batch alias targets exist. TIMING/EASING cannot be
20
+ * created via the Plugin API and are skipped with a warning.
21
+ * • toDelete — STRICTLY gated behind strategy: "replace". Merge
22
+ * (default) preserves Figma-only variables and only reports them.
18
23
  *
19
24
  * Both tools auto-discover `tokens.config.json` at the project root and use
20
25
  * its source/generated/modes/conflictResolution settings as defaults. They
@@ -23,14 +28,14 @@
23
28
  import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync, } from "node:fs";
24
29
  import { dirname, isAbsolute, join, resolve } from "node:path";
25
30
  import { createChildLogger } from "./logger.js";
26
- import { convertFigmaVariablesToDocument, ExportTokensInputSchema, ImportTokensInputSchema, format as formatTokenDocument, loadTokensConfig, parse as parseTokenPayload, resolveOutputTargets, } from "./tokens/index.js";
31
+ import { buildTokenLookup, canonicalizeTokenValueForComparison, clamp01, convertFigmaVariablesToDocument, ExportTokensInputSchema, ImportTokensInputSchema, format as formatTokenDocument, hexToRawRgba, loadTokensConfig, parse as parseTokenPayload, resolveOutputTargets, stripRawColorFromValues, } from "./tokens/index.js";
27
32
  const logger = createChildLogger({ component: "tokens-tools" });
28
33
  /**
29
34
  * MCP version stamp embedded in DTCG `$extensions["figma-console-mcp"].mcpVersion`
30
35
  * on every exported token document. Kept in sync with package.json by
31
36
  * scripts/release.sh — see step 3 of the release flow.
32
37
  */
33
- const MCP_VERSION = "1.32.0";
38
+ const MCP_VERSION = "1.34.0";
34
39
  const EXPORT_TOOL_DESCRIPTION = `Export Figma variables to design token files in your codebase. Bidirectional with figma_import_tokens — together they replace Style Dictionary and Tokens Studio's export pipeline for the popular styling methods.
35
40
 
36
41
  FULLY-IMPLEMENTED OUTPUT FORMATS:
@@ -49,12 +54,14 @@ ZERO-ARG USAGE: With a tokens.config.json at your project root, just call the to
49
54
 
50
55
  MERGE STRATEGY: Default \`strategy: "merge"\` only writes tokens that actually changed in Figma since the last sync. Use \`dry-run\` to preview what would change. Use \`replace\` to wipe and rewrite (rare; for resetting drift).
51
56
 
52
- ROUND-TRIP SAFETY: Figma variable IDs are preserved in DTCG \`$extensions["figma-console-mcp"]\` so renames on either side don't create duplicates. The same metadata enables non-destructive incremental sync via figma_import_tokens.`;
57
+ DTCG DIALECT (\`dtcgDialect\`, applies to dtcg/json-flat/json-nested outputs): legacy (default): hex-string colors, maximum compatibility (Style Dictionary v4, Tokens Studio). 2025: DTCG 2025.10 object colors/dimensions (Style Dictionary v5+). figma_import_tokens accepts BOTH dialects regardless of this setting.
58
+
59
+ ROUND-TRIP SAFETY: Figma variable IDs are preserved in DTCG \`$extensions["figma-console-mcp"]\` so renames on either side don't create duplicates. The same metadata enables non-destructive incremental sync via figma_import_tokens. Variable scopes (when non-default) and per-platform codeSyntax (when set) are stashed there too and round-trip through import.`;
53
60
  const IMPORT_TOOL_DESCRIPTION = `Push design tokens from your codebase into Figma as variables. Bidirectional with figma_export_tokens.
54
61
 
55
62
  ACCEPTS: DTCG JSON (canonical, fully supported including round-trip metadata preservation). Tokens Studio JSON, CSS custom properties, Tailwind v4 @theme, SCSS, and Style Dictionary v3 are scaffolded but return a NotImplementedError — convert to DTCG first via figma_export_tokens or hand-author DTCG. Use \`format: "auto"\` to sniff the input.
56
63
 
57
- APPLY PHASE: Value updates (toUpdate entries) are pushed to Figma via the plugin bridge in a batched single round-trip — verified end-to-end on multi-mode collections. Partial-success semantics: per-variable errors surface in applyResult.errors[] without failing the batch. toCreate (new variables), toDelete (Figma-only tokens), and alias-target updates are reported in the diff plan but not yet wired through the apply phaseuse figma_setup_design_tokens / figma_batch_create_variables / figma_delete_variable manually for those operations, or wait for a future minor version.
64
+ APPLY PHASE (full bidirectional sync): toCreate entries create missing collections (with the token file's full mode list) and missing variables in one batched plugin round-trip — literal values first, alias values in a second pass so aliases between newly-created variables resolve. toUpdate entries push value updates in a batched round-trip, INCLUDING alias-target updates: when a token's value is a reference, it's written as a Figma variable alias if the reference resolves to an existing or just-created variable (unresolvable references skip with a warning). toDelete entries are STRICTLY gated behind \`strategy: "replace"\` — in replace mode, Figma variables absent from the token file are permanently deleted (a loud warning lists the count); merge (default) preserves them and only reports. TIMING/EASING variables cannot be created or written via the Plugin API and are skipped with a warning. Variable scopes and per-platform codeSyntax (from \`$extensions["figma-console-mcp"].scopes/.codeSyntax\`) are diffed and applied too absent fields mean "no opinion" (Figma-side metadata is preserved), an explicit value is authoritative. Partial-success semantics: per-item errors surface in applyResult.errors[] without failing the batch.
58
65
 
59
66
  DIFF-AWARE: Default \`strategy: "merge"\` diffs against current Figma state and applies only deltas. The hacked-color scenario — designer edits one hex value in their CSS — produces exactly one Figma API update, not a full collection rewrite. Match priority: Figma variable ID (in \`$extensions["figma-console-mcp"].variableId\`), then exact token path, then value fingerprint.
60
67
 
@@ -181,6 +188,7 @@ async function handleExport(args, getDesktopConnector, opts) {
181
188
  splitByCollection: args.splitByCollection ?? target.splitByCollection,
182
189
  prefix: args.prefix ?? target.prefix,
183
190
  resolveAliases: args.resolveAliases ?? target.resolveAliases,
191
+ dtcgDialect: args.dtcgDialect ?? target.dtcgDialect,
184
192
  transforms: {
185
193
  colorFormat: args.colorFormat ?? target.transforms?.colorFormat,
186
194
  sizeUnit: args.sizeUnit ?? target.transforms?.sizeUnit,
@@ -313,17 +321,93 @@ async function handleImport(args, getDesktopConnector, opts) {
313
321
  // 6. Compute the diff plan.
314
322
  const diff = computeDiffPlan(figmaDoc, merged);
315
323
  const dryRun = args.dryRun === true || args.strategy === "dry-run";
316
- // 7. Apply phase: when not dry-run, push toUpdate entries to Figma via
317
- // the plugin's executeCodeViaUI. Create + delete are stubbed for a
318
- // future phase (value updates cover the common designer workflow:
319
- // edit a hex value in JSON, push to Figma).
324
+ const strategy = args.strategy === "replace" ? "replace" : "merge";
325
+ // 7. Apply phase: when not dry-run, mutate Figma via the plugin bridge in
326
+ // three ordered sub-phases. Order matters: creates run FIRST so that
327
+ // alias-target updates in the update phase can point at just-created
328
+ // variables; deletes run LAST (and only under strategy "replace").
320
329
  let applyResult = null;
321
- if (!dryRun && diff.toUpdate.length > 0) {
330
+ let deleteWarning;
331
+ if (!dryRun) {
332
+ const acc = {
333
+ applied: 0,
334
+ created: 0,
335
+ createdCollections: 0,
336
+ renamed: 0,
337
+ deleted: 0,
338
+ failed: 0,
339
+ errors: [],
340
+ };
341
+ let anyPhaseRan = false;
322
342
  const collectionModeMap = buildCollectionModeMap(figmaPayload);
323
- const updates = buildUpdatePayloads(diff.toUpdate, figmaDoc, merged, collectionModeMap, parseWarnings);
324
- if (updates.length > 0) {
325
- applyResult = await applyUpdates(connector, updates);
343
+ const toCreateKeys = new Set(diff.toCreate.map((e) => e.path));
344
+ // Mutable map the alias resolver closes over — populated by the create
345
+ // phase so later alias resolutions see freshly-created variable IDs.
346
+ const createdIdByKey = new Map();
347
+ const resolveAliasId = makeAliasIdResolver(figmaDoc, merged, toCreateKeys, createdIdByKey);
348
+ // 7a. CREATE phase — missing collections (with the token file's full
349
+ // mode list) and missing variables. Literal values are set at
350
+ // creation; alias values apply in a second in-script pass after ALL
351
+ // variables exist, so aliases among created variables resolve.
352
+ if (diff.toCreate.length > 0) {
353
+ const createPlan = buildCreatePlan(diff.toCreate, merged, figmaPayload, resolveAliasId, parseWarnings);
354
+ if (createPlan.newCollections.length > 0 ||
355
+ createPlan.existingCollections.length > 0) {
356
+ anyPhaseRan = true;
357
+ const createResult = await applyCreates(connector, createPlan);
358
+ acc.created += createResult.created;
359
+ acc.createdCollections += createResult.createdCollections;
360
+ acc.failed += createResult.failed;
361
+ acc.errors.push(...createResult.errors);
362
+ for (const [key, id] of createResult.createdIdByKey) {
363
+ createdIdByKey.set(key, id);
364
+ }
365
+ }
366
+ }
367
+ // 7b. UPDATE phase — value updates including alias-target updates
368
+ // (references resolved to { type: "VARIABLE_ALIAS", id }) AND
369
+ // renames (ID-matched tokens whose path moved: variable.name is set
370
+ // to the new '/'-joined path instead of create+delete).
371
+ if (diff.toUpdate.length > 0 || diff.toRename.length > 0) {
372
+ const renameEntries = diff.toRename.map((r) => ({
373
+ path: r.from, // Figma-side lookup (old key)
374
+ codePath: r.path, // code-side lookup (new key)
375
+ newName: r.newName,
376
+ before: undefined,
377
+ after: undefined,
378
+ changes: r.changes,
379
+ }));
380
+ const updates = buildUpdatePayloads([...renameEntries, ...diff.toUpdate], figmaDoc, merged, collectionModeMap, parseWarnings, resolveAliasId);
381
+ if (updates.length > 0) {
382
+ anyPhaseRan = true;
383
+ const renameIds = new Set(updates.filter((u) => u.newName !== undefined).map((u) => u.variableId));
384
+ const updateResult = await applyUpdates(connector, updates);
385
+ const renamedFailed = updateResult.errors.filter((e) => renameIds.has(e.variableId)).length;
386
+ const renamedOk = renameIds.size - renamedFailed;
387
+ acc.renamed += renamedOk;
388
+ // `applied` counts successful non-rename update entries; a rename
389
+ // entry (even one that also carried value changes) counts once,
390
+ // under `renamed`.
391
+ acc.applied += updateResult.applied - renamedOk;
392
+ acc.failed += updateResult.failed;
393
+ acc.errors.push(...updateResult.errors);
394
+ }
395
+ }
396
+ // 7c. DELETE phase — STRICTLY gated behind strategy "replace". Merge
397
+ // (the default) never deletes; it only reports Figma-only tokens.
398
+ if (strategy === "replace" && diff.toDelete.length > 0) {
399
+ anyPhaseRan = true;
400
+ const deleteResult = await applyDeletes(connector, diff.toDelete, figmaDoc);
401
+ acc.deleted += deleteResult.deleted;
402
+ acc.failed += deleteResult.failed;
403
+ acc.errors.push(...deleteResult.errors);
404
+ if (deleteResult.deleted > 0) {
405
+ deleteWarning = `⚠️ REPLACE STRATEGY: permanently deleted ${deleteResult.deleted} Figma variable(s) that were not present in the token file. Recover via Figma's version history / Edit > Undo if this was unintended.`;
406
+ parseWarnings.push(deleteWarning);
407
+ }
326
408
  }
409
+ if (anyPhaseRan)
410
+ applyResult = acc;
327
411
  }
328
412
  // Slim the diff for the response: full entries blow past LLM context for
329
413
  // large design systems. Show counts + a sample of first N entries from
@@ -334,6 +418,7 @@ async function handleImport(args, getDesktopConnector, opts) {
334
418
  summary: {
335
419
  toCreate: diff.toCreate.length,
336
420
  toUpdate: diff.toUpdate.length,
421
+ toRename: diff.toRename.length,
337
422
  toDelete: diff.toDelete.length,
338
423
  unchanged: diff.unchanged,
339
424
  },
@@ -343,11 +428,17 @@ async function handleImport(args, getDesktopConnector, opts) {
343
428
  type: e.type,
344
429
  })),
345
430
  toUpdate: diff.toUpdate.slice(0, SAMPLE_LIMIT),
431
+ toRename: diff.toRename.slice(0, SAMPLE_LIMIT).map((e) => ({
432
+ from: e.from,
433
+ to: e.path,
434
+ variableId: e.variableId,
435
+ })),
346
436
  toDelete: diff.toDelete.slice(0, SAMPLE_LIMIT),
347
437
  },
348
438
  truncated: {
349
439
  toCreate: diff.toCreate.length > SAMPLE_LIMIT,
350
440
  toUpdate: diff.toUpdate.length > SAMPLE_LIMIT,
441
+ toRename: diff.toRename.length > SAMPLE_LIMIT,
351
442
  toDelete: diff.toDelete.length > SAMPLE_LIMIT,
352
443
  },
353
444
  };
@@ -361,16 +452,38 @@ async function handleImport(args, getDesktopConnector, opts) {
361
452
  applyNote: dryRun
362
453
  ? "Dry-run only — no Figma mutations performed."
363
454
  : applyResult
364
- ? `Applied ${applyResult.applied} value update(s) to Figma. ${applyResult.failed} failed.`
365
- : diff.toUpdate.length === 0
455
+ ? [
456
+ applyResult.createdCollections > 0
457
+ ? `Created ${applyResult.createdCollections} collection(s).`
458
+ : null,
459
+ applyResult.created > 0
460
+ ? `Created ${applyResult.created} variable(s).`
461
+ : null,
462
+ applyResult.renamed > 0
463
+ ? `Renamed ${applyResult.renamed} variable(s).`
464
+ : null,
465
+ `Applied ${applyResult.applied} value update(s).`,
466
+ applyResult.deleted > 0
467
+ ? `Deleted ${applyResult.deleted} variable(s) (replace strategy).`
468
+ : null,
469
+ `${applyResult.failed} failed.`,
470
+ ]
471
+ .filter(Boolean)
472
+ .join(" ")
473
+ : diff.toUpdate.length === 0 &&
474
+ diff.toCreate.length === 0 &&
475
+ diff.toRename.length === 0
366
476
  ? "Nothing to apply — all tokens already in sync."
367
- : "Updates were detected but skipped (likely all aliases or unresolved values).",
368
- toCreatePhase2Note: diff.toCreate.length > 0
369
- ? `${diff.toCreate.length} create(s) detected — create-phase mutations ship in a future phase. Use figma_setup_design_tokens / figma_batch_create_variables manually for now.`
370
- : undefined,
371
- toDeletePhase2Note: diff.toDelete.length > 0
372
- ? `${diff.toDelete.length} Figma-only token(s) preserved (merge strategy). Use strategy: "replace" to delete them, or figma_delete_variable manually.`
373
- : undefined,
477
+ : "Changes were detected but skipped (likely all unresolvable aliases or non-writable TIMING/EASING types — see warnings).",
478
+ deleteNote: deleteWarning
479
+ ? deleteWarning
480
+ : diff.toDelete.length > 0
481
+ ? strategy === "replace" && dryRun
482
+ ? `${diff.toDelete.length} Figma-only token(s) would be PERMANENTLY DELETED on apply (replace strategy + dry-run).`
483
+ : strategy === "replace"
484
+ ? `${diff.toDelete.length} Figma-only token(s) targeted for deletion (replace strategy) — see applyResult for outcome.`
485
+ : `${diff.toDelete.length} Figma-only token(s) preserved (merge strategy). Use strategy: "replace" to delete them, or figma_delete_variable manually.`
486
+ : undefined,
374
487
  inputFileCount: inputFiles.length,
375
488
  parsedSetCount: merged.sets.length,
376
489
  parsedTokenCount: merged.sets.reduce((n, s) => n + s.tokens.length, 0),
@@ -378,6 +491,10 @@ async function handleImport(args, getDesktopConnector, opts) {
378
491
  applyResult: applyResult
379
492
  ? {
380
493
  applied: applyResult.applied,
494
+ created: applyResult.created,
495
+ createdCollections: applyResult.createdCollections,
496
+ renamed: applyResult.renamed,
497
+ deleted: applyResult.deleted,
381
498
  failed: applyResult.failed,
382
499
  errors: applyResult.errors.slice(0, 10),
383
500
  }
@@ -537,8 +654,21 @@ function mergeDocuments(docs) {
537
654
  * Compute a diff plan between Figma's current state (left) and the code's
538
655
  * proposed state (right). Returns a structured diff plan; value-update
539
656
  * mutations are applied via the plugin bridge below.
657
+ *
658
+ * MATCH PRIORITY (as the tool description promises): Figma variable ID
659
+ * first, then exact set::path. A code-side token whose path has no Figma
660
+ * counterpart but whose $extensions["figma-console-mcp"].variableId matches
661
+ * a live variable is a RENAME — routed to toRename (name change + any
662
+ * value/meta changes), and its Figma-side counterpart is EXCLUDED from
663
+ * toDelete. Without this, a path rename in the code file would create a
664
+ * duplicate variable under merge, and under replace would permanently
665
+ * delete the original (detaching all its bindings).
666
+ *
667
+ * Exported for test coverage of the dialect-normalized comparison
668
+ * (round-trip exports in both DTCG dialects must diff as unchanged) and
669
+ * rename classification.
540
670
  */
541
- function computeDiffPlan(figmaDoc, codeDoc) {
671
+ export function computeDiffPlan(figmaDoc, codeDoc) {
542
672
  // Build lookup maps by path for both sides.
543
673
  const figmaTokens = new Map();
544
674
  for (const set of figmaDoc.sets) {
@@ -552,38 +682,97 @@ function computeDiffPlan(figmaDoc, codeDoc) {
552
682
  codeTokens.set(`${set.name}::${t.path.join(".")}`, t);
553
683
  }
554
684
  }
685
+ // ID-first index: live Figma variableId → its diff key + token.
686
+ const figmaByVariableId = new Map();
687
+ for (const [key, token] of figmaTokens) {
688
+ const id = token.extensions?.["figma-console-mcp"]?.variableId;
689
+ if (typeof id === "string")
690
+ figmaByVariableId.set(id, { key, token });
691
+ }
555
692
  const toCreate = [];
556
693
  const toUpdate = [];
694
+ const toRename = [];
695
+ // Figma-side keys consumed by a rename — excluded from toDelete.
696
+ const renamedFigmaKeys = new Set();
557
697
  const toDelete = [];
558
698
  let unchanged = 0;
559
699
  for (const [key, codeToken] of codeTokens) {
560
700
  const figmaToken = figmaTokens.get(key);
561
701
  if (!figmaToken) {
702
+ // No path match — try the ID-first match before classifying as a
703
+ // create. Guards: the matched Figma path must not ALSO exist in the
704
+ // code doc (then the ID was copy-pasted, not renamed), and each Figma
705
+ // variable can be claimed by at most one rename.
706
+ const extId = codeToken.extensions?.["figma-console-mcp"]?.variableId;
707
+ const idMatch = typeof extId === "string" ? figmaByVariableId.get(extId) : undefined;
708
+ if (idMatch &&
709
+ !codeTokens.has(idMatch.key) &&
710
+ !renamedFigmaKeys.has(idMatch.key)) {
711
+ renamedFigmaKeys.add(idMatch.key);
712
+ const valuesChanged = !valuesEqual(idMatch.token.values, codeToken.values);
713
+ const meta = diffVariableMeta(idMatch.token, codeToken);
714
+ toRename.push({
715
+ path: key,
716
+ from: idMatch.key,
717
+ variableId: extId,
718
+ newName: codeToken.path.join("/"),
719
+ changes: {
720
+ values: valuesChanged,
721
+ scopes: meta.scopesChanged,
722
+ codeSyntax: meta.codeSyntaxChanged,
723
+ },
724
+ });
725
+ continue;
726
+ }
562
727
  toCreate.push({
563
728
  path: key,
564
729
  type: codeToken.type,
565
730
  value: codeToken.values,
566
731
  });
732
+ continue;
567
733
  }
568
- else if (!valuesEqual(figmaToken.values, codeToken.values)) {
569
- toUpdate.push({
570
- path: key,
571
- before: figmaToken.values,
572
- after: codeToken.values,
573
- });
734
+ const valuesChanged = !valuesEqual(figmaToken.values, codeToken.values);
735
+ const meta = diffVariableMeta(figmaToken, codeToken);
736
+ if (valuesChanged || meta.scopesChanged || meta.codeSyntaxChanged) {
737
+ if (valuesChanged && !meta.scopesChanged && !meta.codeSyntaxChanged) {
738
+ // Value-only update — historical entry shape, no `changes` field, so
739
+ // pre-existing consumers/tests see exactly what they always saw.
740
+ toUpdate.push({
741
+ path: key,
742
+ // Figma-side values carry the transient rawColor floats — strip
743
+ // them so diff samples in the tool response stay shaped as before.
744
+ before: stripRawColorFromValues(figmaToken.values),
745
+ after: codeToken.values,
746
+ });
747
+ }
748
+ else {
749
+ toUpdate.push({
750
+ path: key,
751
+ before: valuesChanged
752
+ ? stripRawColorFromValues(figmaToken.values)
753
+ : meta.before,
754
+ after: valuesChanged ? codeToken.values : meta.after,
755
+ changes: {
756
+ values: valuesChanged,
757
+ scopes: meta.scopesChanged,
758
+ codeSyntax: meta.codeSyntaxChanged,
759
+ },
760
+ });
761
+ }
574
762
  }
575
763
  else {
576
764
  unchanged++;
577
765
  }
578
766
  }
579
767
  for (const key of figmaTokens.keys()) {
580
- if (!codeTokens.has(key)) {
768
+ if (!codeTokens.has(key) && !renamedFigmaKeys.has(key)) {
581
769
  // Reports as "would delete if strategy=replace" but defaults to
582
- // preserve under merge strategy.
770
+ // preserve under merge strategy. Keys consumed by a rename are NOT
771
+ // deletions — the variable lives on under its new name.
583
772
  toDelete.push({ path: key });
584
773
  }
585
774
  }
586
- return { toCreate, toUpdate, toDelete, unchanged };
775
+ return { toCreate, toUpdate, toRename, toDelete, unchanged };
587
776
  }
588
777
  /**
589
778
  * Structural equality for a token's mode-keyed values map. Order-independent
@@ -593,6 +782,13 @@ function computeDiffPlan(figmaDoc, codeDoc) {
593
782
  * Recursive for composite values (typography, shadow) — those have nested
594
783
  * objects too. Aliases are equal when both have the same `reference` string;
595
784
  * literals are equal by deep value comparison.
785
+ *
786
+ * Each side is canonicalized to a dialect-agnostic form before comparing
787
+ * (see canonicalizeTokenValueForComparison): a DTCG 2025.10 color object
788
+ * equals the same color's legacy hex string (both quantized to 1/255 per
789
+ * channel), `{ value: 16, unit: "px" }` equals bare 16, and the transient
790
+ * rawColor field is ignored. Without this, importing a 2025-dialect file
791
+ * would report EVERY color as toUpdate on EVERY import, forever.
596
792
  */
597
793
  function valuesEqual(a, b) {
598
794
  const aKeys = Object.keys(a).sort();
@@ -602,8 +798,9 @@ function valuesEqual(a, b) {
602
798
  for (let i = 0; i < aKeys.length; i++) {
603
799
  if (aKeys[i] !== bKeys[i])
604
800
  return false;
605
- if (!deepEqual(a[aKeys[i]], b[bKeys[i]]))
801
+ if (!deepEqual(canonicalizeTokenValueForComparison(a[aKeys[i]]), canonicalizeTokenValueForComparison(b[bKeys[i]]))) {
606
802
  return false;
803
+ }
607
804
  }
608
805
  return true;
609
806
  }
@@ -635,6 +832,125 @@ function deepEqual(a, b) {
635
832
  }
636
833
  return true;
637
834
  }
835
+ /**
836
+ * Compare variable metadata (scopes + codeSyntax) between the Figma-side and
837
+ * code-side tokens.
838
+ *
839
+ * Semantics (deliberately merge-friendly):
840
+ * - Code-side ABSENT field = "no opinion" — never a change, so token files
841
+ * that predate this feature (or hand-authored files without extensions)
842
+ * can't silently reset Figma-side scopes/codeSyntax.
843
+ * - Scopes compare order-insensitively; []/["ALL_SCOPES"]/absent all
844
+ * normalize to the default (export omits the default, so a round-trip of
845
+ * an ALL_SCOPES variable is absent on both sides → unchanged).
846
+ * - codeSyntax compares by deep equality ({} counts as an explicit "clear
847
+ * every platform" opinion; absent counts as no opinion).
848
+ */
849
+ function diffVariableMeta(figmaToken, codeToken) {
850
+ const figmaExt = figmaToken.extensions?.["figma-console-mcp"] ?? {};
851
+ const codeExt = codeToken.extensions?.["figma-console-mcp"] ?? {};
852
+ let scopesChanged = false;
853
+ if (Array.isArray(codeExt.scopes)) {
854
+ const codeScopes = normalizeScopesForComparison(codeExt.scopes);
855
+ const figmaScopes = normalizeScopesForComparison(figmaExt.scopes);
856
+ scopesChanged = !deepEqual(codeScopes, figmaScopes);
857
+ }
858
+ let codeSyntaxChanged = false;
859
+ if (codeExt.codeSyntax !== undefined &&
860
+ codeExt.codeSyntax !== null &&
861
+ typeof codeExt.codeSyntax === "object" &&
862
+ !Array.isArray(codeExt.codeSyntax)) {
863
+ codeSyntaxChanged = !deepEqual(codeExt.codeSyntax, figmaExt.codeSyntax ?? {});
864
+ }
865
+ const before = {};
866
+ const after = {};
867
+ if (scopesChanged) {
868
+ before.scopes = figmaExt.scopes ?? ["ALL_SCOPES"];
869
+ after.scopes = codeExt.scopes;
870
+ }
871
+ if (codeSyntaxChanged) {
872
+ before.codeSyntax = figmaExt.codeSyntax ?? {};
873
+ after.codeSyntax = codeExt.codeSyntax;
874
+ }
875
+ return { scopesChanged, codeSyntaxChanged, before, after };
876
+ }
877
+ /**
878
+ * Normalize a scopes array to a sorted, default-collapsed form: absent,
879
+ * empty, and ["ALL_SCOPES"] all mean "the default scoping" in Figma.
880
+ */
881
+ function normalizeScopesForComparison(scopes) {
882
+ if (!Array.isArray(scopes) || scopes.length === 0)
883
+ return ["ALL_SCOPES"];
884
+ const cleaned = scopes.filter((s) => typeof s === "string");
885
+ if (cleaned.length === 0)
886
+ return ["ALL_SCOPES"];
887
+ if (cleaned.length === 1 && cleaned[0] === "ALL_SCOPES")
888
+ return ["ALL_SCOPES"];
889
+ return [...cleaned].sort();
890
+ }
891
+ /**
892
+ * Build a resolver that maps a DTCG alias reference (set-qualified
893
+ * `{theme.color.primary}`, bare `{color.primary}` when unambiguous, or the
894
+ * converter's `{__library:VariableID:...}` cross-library form) to a Figma
895
+ * variable ID.
896
+ *
897
+ * Resolution priority for a matched code-side token:
898
+ * 1. a variable created earlier in THIS apply run (createdIdByKey — the
899
+ * resolver closes over the mutable map, so the create phase's results
900
+ * are visible to the later update phase)
901
+ * 2. the live Figma snapshot's variable ID for the same set::path
902
+ * 3. "pending" when the target is queued in this run's toCreate batch
903
+ * 4. the token's own $extensions variableId (stale-but-recorded fallback)
904
+ * References that don't match a code-side token fall back to the live Figma
905
+ * snapshot lookup. Exported for test coverage.
906
+ */
907
+ export function makeAliasIdResolver(figmaDoc, codeDoc, toCreateKeys, createdIdByKey) {
908
+ const codeLookup = buildTokenLookup(codeDoc);
909
+ const figmaLookup = buildTokenLookup(figmaDoc);
910
+ // "SetName::dot.path" → live Figma variable ID.
911
+ const figmaIdByKey = new Map();
912
+ for (const set of figmaDoc.sets) {
913
+ for (const t of set.tokens) {
914
+ const id = t.extensions?.["figma-console-mcp"]?.variableId;
915
+ if (typeof id === "string") {
916
+ figmaIdByKey.set(`${set.name}::${t.path.join(".")}`, id);
917
+ }
918
+ }
919
+ }
920
+ return (reference) => {
921
+ const bare = reference.replace(/^\{|\}$/g, "");
922
+ // Cross-library references preserve the original Figma variable ID —
923
+ // usable directly as an alias target.
924
+ if (bare.startsWith("__library:")) {
925
+ return { id: bare.slice("__library:".length) };
926
+ }
927
+ const codeEntry = codeLookup.get(bare);
928
+ if (codeEntry) {
929
+ const key = `${codeEntry.setName}::${codeEntry.token.path.join(".")}`;
930
+ const createdId = createdIdByKey.get(key);
931
+ if (createdId)
932
+ return { id: createdId };
933
+ const liveId = figmaIdByKey.get(key);
934
+ if (liveId)
935
+ return { id: liveId };
936
+ if (toCreateKeys.has(key))
937
+ return { pending: key };
938
+ const extId = codeEntry.token.extensions?.["figma-console-mcp"]?.variableId;
939
+ if (typeof extId === "string")
940
+ return { id: extId };
941
+ return null;
942
+ }
943
+ // Not in the code document — the reference may point at a Figma-only
944
+ // token (present in the live snapshot but absent from the import file).
945
+ const figmaEntry = figmaLookup.get(bare);
946
+ if (figmaEntry) {
947
+ const id = figmaEntry.token.extensions?.["figma-console-mcp"]?.variableId;
948
+ if (typeof id === "string")
949
+ return { id };
950
+ }
951
+ return null;
952
+ };
953
+ }
638
954
  /**
639
955
  * Build a quick lookup of (collectionId, modeName) → modeId from the raw
640
956
  * Figma payload. Needed because our internal model is keyed by mode name
@@ -655,22 +971,36 @@ function buildCollectionModeMap(payload) {
655
971
  * Convert a TokenValue back to Figma's native value shape. Required for the
656
972
  * Plugin API's setValueForMode call.
657
973
  *
658
- * - color hex string "#RRGGBB(AA)" → { r, g, b, a } floats in [0, 1]
659
- * - FLOAT-typed number number
974
+ * - color hex string "#RRGGBB(AA)" → { r, g, b, a } floats in [0, 1].
975
+ * Non-hex color strings ("rgb(255,0,0)", "transparent", named colors
976
+ * like "salmon") return skip-invalid instead of throwing — a throw here
977
+ * would abort the import mid-apply.
978
+ * - DTCG 2025.10 color objects → { r, g, b, a }: srgb components map
979
+ * directly (clamped to [0, 1]); non-srgb colorSpaces (display-p3,
980
+ * oklch, …) fall back to the object's `hex` field when present, else
981
+ * skip-invalid; hex-only objects are accepted too
982
+ * - FLOAT-typed number → number ("16px"-style strings and DTCG
983
+ * { value, unit } objects are parsed to their numeric part; anything
984
+ * that still comes out NaN is skipped rather than pushed to Figma)
660
985
  * - STRING-typed string → string
661
986
  * - BOOLEAN → boolean
662
- * - Alias references are not yet supported in the apply phase (would need
663
- * to resolve the target variable ID); the discriminated return signals
664
- * this case so the caller surfaces a warning rather than silently
665
- * dropping the update.
987
+ * - Alias references return `skip-alias` so the CALLER resolves them
988
+ * both apply paths pass the reference through their alias-ID resolver
989
+ * (just-created variable live snapshot pending in batch recorded
990
+ * $extensions ID) and write { type: "VARIABLE_ALIAS", id }. Only when
991
+ * the resolver comes up empty does the reference stay skipped, with a
992
+ * warning rather than a silent drop.
993
+ *
994
+ * Exported for test coverage of the value-conversion edge cases.
666
995
  */
667
- function tokenValueToFigma(value, resolvedType) {
996
+ export function tokenValueToFigma(value, resolvedType) {
668
997
  if (value.reference) {
669
- // A future minor version will resolve the referenced variable's Figma ID and emit
670
- // { type: "VARIABLE_ALIAS", id }. For now, skip alias updates so we
671
- // don't accidentally wipe a reference with a literal — and surface a
672
- // warning so the user knows the diff plan promised an update that
673
- // didn't actually apply.
998
+ // References aren't converted here the caller resolves them to a
999
+ // Figma variable ID via its alias-ID resolver and writes a real
1000
+ // { type: "VARIABLE_ALIAS", id } payload. Returning skip-alias hands
1001
+ // the reference back for that resolution; if the resolver can't find
1002
+ // a target, the caller surfaces a warning instead of silently wiping
1003
+ // the reference with a literal.
674
1004
  return { kind: "skip-alias", reference: value.reference };
675
1005
  }
676
1006
  if (value.literal === undefined || value.literal === null) {
@@ -678,10 +1008,42 @@ function tokenValueToFigma(value, resolvedType) {
678
1008
  }
679
1009
  let figmaValue;
680
1010
  if (resolvedType === "COLOR" && typeof value.literal === "string") {
681
- figmaValue = hexToRgba(value.literal);
1011
+ // Guarded: hexToRgba throws on anything that isn't a hex color
1012
+ // ("rgb(255,0,0)", "transparent", "salmon", "oklch(...)"). A throw here
1013
+ // would abort the whole import AFTER the create phase already mutated
1014
+ // Figma — so malformed colors become a per-value skip instead.
1015
+ try {
1016
+ figmaValue = hexToRgba(value.literal);
1017
+ }
1018
+ catch (err) {
1019
+ return {
1020
+ kind: "skip-invalid",
1021
+ reason: `cannot convert ${JSON.stringify(value.literal)} to a Figma color (${err instanceof Error ? err.message : String(err)})`,
1022
+ };
1023
+ }
1024
+ }
1025
+ else if (resolvedType === "COLOR" &&
1026
+ typeof value.literal === "object" &&
1027
+ value.literal !== null &&
1028
+ !Array.isArray(value.literal)) {
1029
+ // DTCG 2025.10 object-form color. Without this branch the object fell
1030
+ // through to String(literal) → "[object Object]" pushed at a COLOR
1031
+ // variable.
1032
+ const converted = colorObjectToFigmaRgba(value.literal);
1033
+ if (converted.kind !== "value")
1034
+ return converted;
1035
+ figmaValue = converted.value;
682
1036
  }
683
1037
  else if (resolvedType === "FLOAT") {
684
- figmaValue = typeof value.literal === "number" ? value.literal : Number(value.literal);
1038
+ const parsed = parseNumericLiteral(value.literal);
1039
+ if (Number.isNaN(parsed)) {
1040
+ // Never push NaN into setValueForMode — skip with a reason instead.
1041
+ return {
1042
+ kind: "skip-invalid",
1043
+ reason: `cannot convert ${JSON.stringify(value.literal)} to a number for a FLOAT variable`,
1044
+ };
1045
+ }
1046
+ figmaValue = parsed;
685
1047
  }
686
1048
  else if (resolvedType === "BOOLEAN") {
687
1049
  figmaValue = Boolean(value.literal);
@@ -691,39 +1053,120 @@ function tokenValueToFigma(value, resolvedType) {
691
1053
  }
692
1054
  return { kind: "value", value: figmaValue };
693
1055
  }
694
- function hexToRgba(hex) {
695
- const cleaned = hex.replace(/^#/, "");
696
- let r;
697
- let g;
698
- let b;
699
- let a = 1;
700
- if (cleaned.length === 3) {
701
- r = parseInt(cleaned[0] + cleaned[0], 16) / 255;
702
- g = parseInt(cleaned[1] + cleaned[1], 16) / 255;
703
- b = parseInt(cleaned[2] + cleaned[2], 16) / 255;
704
- }
705
- else if (cleaned.length === 6) {
706
- r = parseInt(cleaned.slice(0, 2), 16) / 255;
707
- g = parseInt(cleaned.slice(2, 4), 16) / 255;
708
- b = parseInt(cleaned.slice(4, 6), 16) / 255;
709
- }
710
- else if (cleaned.length === 8) {
711
- r = parseInt(cleaned.slice(0, 2), 16) / 255;
712
- g = parseInt(cleaned.slice(2, 4), 16) / 255;
713
- b = parseInt(cleaned.slice(4, 6), 16) / 255;
714
- a = parseInt(cleaned.slice(6, 8), 16) / 255;
1056
+ /**
1057
+ * Convert a DTCG 2025.10 color object literal to Figma's { r, g, b, a }
1058
+ * floats. Handles, in priority order:
1059
+ * 1. srgb (or unspecified) colorSpace + 3 numeric components → direct
1060
+ * mapping, each channel clamped to [0, 1]; optional `alpha` (default 1)
1061
+ * 2. any object with a `hex` string field (covers non-srgb colorSpaces
1062
+ * like display-p3/oklch that carry the hex interop fallback, and
1063
+ * hex-only objects) hexToRgba, with the `alpha` field taking
1064
+ * precedence over hex-embedded alpha when present
1065
+ * 3. non-srgb colorSpace without a hex fallback → skip-invalid (we can't
1066
+ * do color-space conversion, and guessing would corrupt the variable)
1067
+ */
1068
+ function colorObjectToFigmaRgba(obj) {
1069
+ const colorSpace = typeof obj.colorSpace === "string" ? obj.colorSpace : undefined;
1070
+ const comps = obj.components;
1071
+ if ((colorSpace === undefined || colorSpace === "srgb") &&
1072
+ Array.isArray(comps) &&
1073
+ comps.length === 3 &&
1074
+ comps.every((c) => typeof c === "number")) {
1075
+ const [r, g, b] = comps.map(clamp01);
1076
+ const a = typeof obj.alpha === "number" ? clamp01(obj.alpha) : 1;
1077
+ return { kind: "value", value: { r, g, b, a } };
715
1078
  }
716
- else {
717
- throw new Error(`[figma-console-mcp] Invalid hex color "${hex}" — expected 3, 6, or 8 hex digits.`);
1079
+ if (typeof obj.hex === "string") {
1080
+ try {
1081
+ const rgba = hexToRgba(obj.hex);
1082
+ if (typeof obj.alpha === "number")
1083
+ rgba.a = clamp01(obj.alpha);
1084
+ return { kind: "value", value: rgba };
1085
+ }
1086
+ catch {
1087
+ return {
1088
+ kind: "skip-invalid",
1089
+ reason: `color object has an unparseable hex field ${JSON.stringify(obj.hex)}`,
1090
+ };
1091
+ }
1092
+ }
1093
+ if (colorSpace !== undefined && colorSpace !== "srgb") {
1094
+ return {
1095
+ kind: "skip-invalid",
1096
+ reason: `unsupported colorSpace "${colorSpace}" without hex fallback`,
1097
+ };
1098
+ }
1099
+ return {
1100
+ kind: "skip-invalid",
1101
+ reason: `cannot convert ${JSON.stringify(obj)} to a Figma color — expected srgb components or a hex field`,
1102
+ };
1103
+ }
1104
+ /**
1105
+ * Parse a token literal into a number for FLOAT variables. Handles:
1106
+ * - plain numbers → as-is
1107
+ * - unit-bearing dimension strings ("16px", "1.5rem", "-4pt") → numeric part
1108
+ * - DTCG dimension AND duration objects: { value: 16, unit: "px" } → 16,
1109
+ * { value: 300, unit: "ms" } → 300, and { value: 0.3, unit: "s" } → 300
1110
+ * — seconds MULTIPLY to milliseconds so the write path agrees with the
1111
+ * diff canonicalization (canonicalizeTokenValueForComparison, which
1112
+ * treats {0.3, "s"} ≡ {300, "ms"} ≡ 300). If the write path took the
1113
+ * raw 0.3 instead, the diff would report a change, apply would write a
1114
+ * 1000x-wrong value, and every subsequent import would rewrite it
1115
+ * forever. Other units (px/rem/…) keep the raw value — Figma FLOAT is
1116
+ * unitless. True TIMING-typed variables are skipped before import ever
1117
+ * reaches this path (Plugin API can't write them), so plain FLOAT is
1118
+ * the only consumer here.
1119
+ * Anything unparseable returns NaN — callers must skip (never push NaN
1120
+ * into setValueForMode).
1121
+ */
1122
+ function parseNumericLiteral(literal) {
1123
+ if (typeof literal === "number")
1124
+ return literal;
1125
+ if (typeof literal === "string") {
1126
+ const match = literal
1127
+ .trim()
1128
+ .match(/^(-?(?:\d+\.?\d*|\.\d+))\s*(px|rem|em|pt|dp|ms|s|%)?$/i);
1129
+ if (match)
1130
+ return Number(match[1]);
1131
+ return Number(literal);
1132
+ }
1133
+ if (literal !== null &&
1134
+ typeof literal === "object" &&
1135
+ "value" in literal) {
1136
+ const obj = literal;
1137
+ const inner = parseNumericLiteral(obj.value);
1138
+ // s → ms, matching the diff-side canonicalization (see doc above).
1139
+ if (obj.unit === "s")
1140
+ return inner * 1000;
1141
+ return inner;
1142
+ }
1143
+ return Number(literal);
1144
+ }
1145
+ /**
1146
+ * Parse a hex color string to Figma rgba floats. Validates through the
1147
+ * dialect module's colorLiteralToCanonicalHex (via hexToRawRgba) so ONLY
1148
+ * genuine 3/6/8-digit hex strings pass — the previous implementation
1149
+ * dispatched on string LENGTH alone, so named colors like "red" (3 chars)
1150
+ * or "salmon" (6 chars) produced NaN channels that reached setValueForMode.
1151
+ * A bare hex without the leading "#" is still tolerated (historical
1152
+ * behavior). Throws on anything else; tokenValueToFigma catches and turns
1153
+ * it into a skip-invalid.
1154
+ */
1155
+ function hexToRgba(hex) {
1156
+ const trimmed = hex.trim();
1157
+ const normalized = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
1158
+ const rgba = hexToRawRgba(normalized);
1159
+ if (!rgba) {
1160
+ throw new Error(`[figma-console-mcp] Invalid hex color ${JSON.stringify(hex)} — expected #RGB, #RRGGBB, or #RRGGBBAA.`);
718
1161
  }
719
- return { r, g, b, a };
1162
+ return rgba;
720
1163
  }
721
1164
  /**
722
1165
  * Walk the toUpdate diff entries and translate each into a VariableUpdate.
723
1166
  * Tokens that lack a Figma variable ID (never been synced) or have no
724
1167
  * resolvable value for any mode get skipped with a warning.
725
1168
  */
726
- function buildUpdatePayloads(toUpdate, figmaDoc, codeDoc, collectionModeMap, warnings) {
1169
+ function buildUpdatePayloads(toUpdate, figmaDoc, codeDoc, collectionModeMap, warnings, resolveAliasId) {
727
1170
  // Build lookups: setName::tokenPath → (figmaToken, codeToken)
728
1171
  const figmaLookup = new Map();
729
1172
  for (const set of figmaDoc.sets) {
@@ -739,7 +1182,9 @@ function buildUpdatePayloads(toUpdate, figmaDoc, codeDoc, collectionModeMap, war
739
1182
  }
740
1183
  const updates = [];
741
1184
  for (const entry of toUpdate) {
742
- const codeMatch = codeLookup.get(entry.path);
1185
+ // Rename entries look up the code token at its NEW path (codePath) and
1186
+ // the Figma variable at its OLD path (path).
1187
+ const codeMatch = codeLookup.get(entry.codePath ?? entry.path);
743
1188
  const figmaMatch = figmaLookup.get(entry.path);
744
1189
  if (!codeMatch || !figmaMatch)
745
1190
  continue;
@@ -755,11 +1200,33 @@ function buildUpdatePayloads(toUpdate, figmaDoc, codeDoc, collectionModeMap, war
755
1200
  warnings.push(`Cannot update ${entry.path} — collection ${collectionId} not found in current Figma state.`);
756
1201
  continue;
757
1202
  }
758
- // Map our token type → Figma resolvedType. Both DTCG type names and
759
- // Figma names need to align here.
760
- const resolvedType = inferFigmaResolvedType(figmaToken.type);
1203
+ // Map our token type → Figma resolvedType. Prefer the actual
1204
+ // Figma-native type recorded at export time
1205
+ // (extensions.figmaResolvedType) — critical so FLOAT variables whose
1206
+ // token type was name-inferred as "duration" keep writing as FLOAT,
1207
+ // while true TIMING/EASING variables are recognized. Fall back to
1208
+ // inferring from the DTCG type when the extension is absent.
1209
+ const recordedType = figmaToken.extensions?.["figma-console-mcp"]?.figmaResolvedType;
1210
+ const figmaNativeType = typeof recordedType === "string"
1211
+ ? recordedType
1212
+ : inferFigmaResolvedType(figmaToken.type);
1213
+ if (figmaNativeType === "TIMING" || figmaNativeType === "EASING") {
1214
+ // The Figma Plugin API cannot create or setValueForMode on
1215
+ // TIMING/EASING variables — only BOOLEAN/COLOR/FLOAT/STRING are
1216
+ // writable. Sending these would be rejected (or worse); skip loudly.
1217
+ warnings.push(`Skipped ${entry.path} — Figma Plugin API cannot write ${figmaNativeType === "TIMING" ? "Timing" : "Easing"} variables (only BOOLEAN/COLOR/FLOAT/STRING are writable). Update this variable in the Figma UI instead.`);
1218
+ continue;
1219
+ }
1220
+ const resolvedType = figmaNativeType;
1221
+ // Value updates apply unless the diff explicitly flagged this entry as
1222
+ // metadata-only (changes.values === false) — re-pushing unchanged values
1223
+ // would be wasteful and could trip alias-resolution warnings.
1224
+ const wantValues = !entry.changes || entry.changes.values;
761
1225
  const valuesByMode = {};
762
- for (const [modeName, value] of Object.entries(codeMatch.token.values)) {
1226
+ const valueEntries = wantValues
1227
+ ? Object.entries(codeMatch.token.values)
1228
+ : [];
1229
+ for (const [modeName, value] of valueEntries) {
763
1230
  const modeId = modeMap.get(modeName);
764
1231
  if (!modeId) {
765
1232
  warnings.push(`Cannot update ${entry.path} (mode "${modeName}") — modeId not found in Figma collection.`);
@@ -767,27 +1234,83 @@ function buildUpdatePayloads(toUpdate, figmaDoc, codeDoc, collectionModeMap, war
767
1234
  }
768
1235
  const conversion = tokenValueToFigma(value, resolvedType);
769
1236
  if (conversion.kind === "skip-alias") {
770
- warnings.push(`Skipped ${entry.path} (mode "${modeName}") — alias reference "${conversion.reference}" updates are not yet supported in the apply phase. To update this token, edit the alias target's value instead, or hard-code a literal hex value in the source file.`);
1237
+ // Alias-target update: resolve the reference to a Figma variable ID
1238
+ // and write it as a native variable alias. Creates run before
1239
+ // updates, so references to just-created variables resolve too.
1240
+ const resolved = resolveAliasId?.(conversion.reference);
1241
+ if (resolved && "id" in resolved) {
1242
+ valuesByMode[modeId] = { type: "VARIABLE_ALIAS", id: resolved.id };
1243
+ continue;
1244
+ }
1245
+ warnings.push(`Skipped ${entry.path} (mode "${modeName}") — alias reference "${conversion.reference}" could not be resolved to an existing or newly-created Figma variable. Fix the reference, or edit the alias target's value instead.`);
1246
+ continue;
1247
+ }
1248
+ if (conversion.kind === "skip-invalid") {
1249
+ warnings.push(`Skipped ${entry.path} (mode "${modeName}") — ${conversion.reason}.`);
771
1250
  continue;
772
1251
  }
773
1252
  if (conversion.kind === "skip-empty")
774
1253
  continue;
775
1254
  valuesByMode[modeId] = conversion.value;
776
1255
  }
777
- if (Object.keys(valuesByMode).length === 0)
1256
+ // Metadata ops (scopes / codeSyntax) flagged by the diff phase.
1257
+ let scopes;
1258
+ if (entry.changes?.scopes) {
1259
+ const codeScopes = codeMatch.token.extensions?.["figma-console-mcp"]?.scopes;
1260
+ if (Array.isArray(codeScopes) && codeScopes.length > 0) {
1261
+ scopes = codeScopes;
1262
+ }
1263
+ else {
1264
+ // Explicit empty/["ALL_SCOPES"] normalizes to the Figma default.
1265
+ scopes = ["ALL_SCOPES"];
1266
+ }
1267
+ }
1268
+ let codeSyntaxOps;
1269
+ if (entry.changes?.codeSyntax) {
1270
+ const codeCS = codeMatch.token.extensions?.["figma-console-mcp"]?.codeSyntax ?? {};
1271
+ const figmaCS = figmaToken.extensions?.["figma-console-mcp"]?.codeSyntax ?? {};
1272
+ const toSet = {};
1273
+ for (const [platform, value] of Object.entries(codeCS)) {
1274
+ if (typeof value === "string" && figmaCS[platform] !== value) {
1275
+ toSet[platform] = value;
1276
+ }
1277
+ }
1278
+ const toRemove = Object.keys(figmaCS).filter((p) => !(p in codeCS));
1279
+ if (Object.keys(toSet).length > 0 || toRemove.length > 0) {
1280
+ codeSyntaxOps = {
1281
+ ...(Object.keys(toSet).length > 0 ? { set: toSet } : {}),
1282
+ ...(toRemove.length > 0 ? { remove: toRemove } : {}),
1283
+ };
1284
+ }
1285
+ }
1286
+ if (Object.keys(valuesByMode).length === 0 &&
1287
+ scopes === undefined &&
1288
+ codeSyntaxOps === undefined &&
1289
+ entry.newName === undefined) {
778
1290
  continue;
1291
+ }
779
1292
  updates.push({
780
1293
  variableId,
781
1294
  variableName: figmaToken.path.join("/"),
782
1295
  resolvedType,
783
1296
  valuesByMode,
1297
+ ...(entry.newName !== undefined ? { newName: entry.newName } : {}),
1298
+ ...(scopes !== undefined ? { scopes } : {}),
1299
+ ...(codeSyntaxOps !== undefined ? { codeSyntax: codeSyntaxOps } : {}),
784
1300
  });
785
1301
  }
786
1302
  return updates;
787
1303
  }
788
1304
  /**
789
- * Map our internal TokenType to Figma's variable resolvedType. The Plugin API
790
- * only has 4 resolved types — collapse our richer set onto them.
1305
+ * Map our internal TokenType to Figma's variable resolvedType.
1306
+ *
1307
+ * duration/cubicBezier map to the Config-2026 TIMING/EASING variable types
1308
+ * — but note the Figma Plugin API CANNOT create or setValueForMode on
1309
+ * TIMING/EASING variables (only BOOLEAN/COLOR/FLOAT/STRING are writable),
1310
+ * so callers must skip those with a warning instead of pushing them.
1311
+ * buildUpdatePayloads prefers the extension-recorded figmaResolvedType over
1312
+ * this inference, so FLOAT variables whose token type was name-inferred as
1313
+ * "duration" still write correctly as FLOAT.
791
1314
  */
792
1315
  function inferFigmaResolvedType(type) {
793
1316
  if (type === "color")
@@ -796,7 +1319,11 @@ function inferFigmaResolvedType(type) {
796
1319
  return "BOOLEAN";
797
1320
  if (type === "string" || type === "fontFamily")
798
1321
  return "STRING";
799
- return "FLOAT"; // dimension, number, fontWeight, duration, etc.
1322
+ if (type === "duration")
1323
+ return "TIMING";
1324
+ if (type === "cubicBezier")
1325
+ return "EASING";
1326
+ return "FLOAT"; // dimension, number, fontWeight, etc.
800
1327
  }
801
1328
  /**
802
1329
  * Push variable updates to Figma via executeCodeViaUI. The plugin runs the
@@ -817,11 +1344,33 @@ async function applyUpdates(connector, updates) {
817
1344
  results.push({ id: u.variableId, success: false, error: "Variable not found in current file" });
818
1345
  continue;
819
1346
  }
1347
+ // Rename FIRST — a rename entry may carry no value writes at all.
1348
+ if (u.newName) {
1349
+ variable.name = u.newName;
1350
+ }
820
1351
  let appliedModes = 0;
821
1352
  for (const modeId in u.valuesByMode) {
822
1353
  variable.setValueForMode(modeId, u.valuesByMode[modeId]);
823
1354
  appliedModes++;
824
1355
  }
1356
+ // Metadata writes — scopes replace wholesale; codeSyntax applies
1357
+ // per-platform sets then removals (removeVariableCodeSyntax is the
1358
+ // Plugin API's deletion primitive).
1359
+ if (u.scopes) {
1360
+ variable.scopes = u.scopes;
1361
+ }
1362
+ if (u.codeSyntax) {
1363
+ if (u.codeSyntax.set) {
1364
+ for (const platform in u.codeSyntax.set) {
1365
+ variable.setVariableCodeSyntax(platform, u.codeSyntax.set[platform]);
1366
+ }
1367
+ }
1368
+ if (u.codeSyntax.remove) {
1369
+ for (const platform of u.codeSyntax.remove) {
1370
+ variable.removeVariableCodeSyntax(platform);
1371
+ }
1372
+ }
1373
+ }
825
1374
  results.push({ id: u.variableId, name: variable.name, success: true, appliedModes });
826
1375
  } catch (err) {
827
1376
  results.push({ id: u.variableId, success: false, error: String(err && err.message || err) });
@@ -857,3 +1406,383 @@ async function applyUpdates(connector, updates) {
857
1406
  errors,
858
1407
  };
859
1408
  }
1409
+ const WRITABLE_RESOLVED_TYPES = new Set(["COLOR", "FLOAT", "STRING", "BOOLEAN"]);
1410
+ /**
1411
+ * Translate the diff's toCreate entries into a CreatePlan.
1412
+ *
1413
+ * - Sets with no matching Figma collection (matched by the round-trip
1414
+ * figmaCollectionId first, then by name) become newCollections carrying
1415
+ * the set's FULL mode list.
1416
+ * - Variables missing from an existing collection go to that collection,
1417
+ * with values pre-resolved to modeIds; values for modes the collection
1418
+ * doesn't have are skipped with a warning.
1419
+ * - resolvedType honors $extensions.figmaResolvedType when recorded, else
1420
+ * falls back to inferFigmaResolvedType. TIMING/EASING variables are
1421
+ * skipped with a warning — the Figma Plugin API cannot create them.
1422
+ * - Literal values convert via tokenValueToFigma (both DTCG dialects);
1423
+ * alias values resolve via the alias-ID resolver (existing target →
1424
+ * "alias", within-batch target → "alias-pending", unresolvable → skip
1425
+ * with warning).
1426
+ *
1427
+ * Exported for test coverage.
1428
+ */
1429
+ export function buildCreatePlan(toCreate, codeDoc, figmaPayload, resolveAliasId, warnings) {
1430
+ // Code-side lookup: "SetName::dot.path" → { set, token }.
1431
+ const codeByKey = new Map();
1432
+ for (const set of codeDoc.sets) {
1433
+ for (const token of set.tokens) {
1434
+ codeByKey.set(`${set.name}::${token.path.join(".")}`, { set, token });
1435
+ }
1436
+ }
1437
+ // Figma collection lookup by ID and by name.
1438
+ const collectionById = new Map();
1439
+ const collectionByName = new Map();
1440
+ for (const c of figmaPayload.collections) {
1441
+ collectionById.set(c.id, c);
1442
+ collectionByName.set(c.name, c);
1443
+ }
1444
+ const newBySet = new Map();
1445
+ const existingById = new Map();
1446
+ for (const entry of toCreate) {
1447
+ const match = codeByKey.get(entry.path);
1448
+ if (!match)
1449
+ continue; // Defensive — toCreate keys come from codeDoc.
1450
+ const { set, token } = match;
1451
+ // Which Figma collection does this set map to? Round-trip collection ID
1452
+ // wins; name match second; otherwise the set needs a new collection.
1453
+ const existingCollection = (set.meta?.figmaCollectionId
1454
+ ? collectionById.get(set.meta.figmaCollectionId)
1455
+ : undefined) ?? collectionByName.get(set.name);
1456
+ // Resolve the Figma-native type, honoring the recorded resolvedType.
1457
+ const recorded = token.extensions?.["figma-console-mcp"]?.figmaResolvedType;
1458
+ const figmaNativeType = typeof recorded === "string" &&
1459
+ ["COLOR", "FLOAT", "STRING", "BOOLEAN", "TIMING", "EASING"].includes(recorded)
1460
+ ? recorded
1461
+ : inferFigmaResolvedType(token.type);
1462
+ if (!WRITABLE_RESOLVED_TYPES.has(figmaNativeType)) {
1463
+ warnings.push(`Skipped create for ${entry.path} — Figma Plugin API cannot create ${figmaNativeType === "TIMING" ? "Timing" : "Easing"} variables (only BOOLEAN/COLOR/FLOAT/STRING are creatable). Create this variable in the Figma UI instead.`);
1464
+ continue;
1465
+ }
1466
+ const resolvedType = figmaNativeType;
1467
+ // Mode-name → modeId map for existing collections.
1468
+ const modeIdByName = new Map();
1469
+ if (existingCollection) {
1470
+ for (const m of existingCollection.modes ?? []) {
1471
+ modeIdByName.set(m.name, m.modeId);
1472
+ }
1473
+ }
1474
+ const values = [];
1475
+ for (const [modeName, value] of Object.entries(token.values)) {
1476
+ let modeKeying;
1477
+ if (existingCollection) {
1478
+ const modeId = modeIdByName.get(modeName);
1479
+ if (!modeId) {
1480
+ warnings.push(`Cannot set ${entry.path} (mode "${modeName}") — mode not found in existing Figma collection "${set.name}". Add the mode in Figma first (figma_add_mode).`);
1481
+ continue;
1482
+ }
1483
+ modeKeying = { modeId };
1484
+ }
1485
+ else {
1486
+ modeKeying = { modeName };
1487
+ }
1488
+ if (value?.reference) {
1489
+ const resolved = resolveAliasId(value.reference);
1490
+ if (!resolved) {
1491
+ warnings.push(`Skipped ${entry.path} (mode "${modeName}") — alias reference "${value.reference}" could not be resolved to an existing or newly-created Figma variable.`);
1492
+ continue;
1493
+ }
1494
+ if ("id" in resolved) {
1495
+ values.push({ ...modeKeying, kind: "alias", targetId: resolved.id });
1496
+ }
1497
+ else {
1498
+ values.push({
1499
+ ...modeKeying,
1500
+ kind: "alias-pending",
1501
+ targetKey: resolved.pending,
1502
+ });
1503
+ }
1504
+ continue;
1505
+ }
1506
+ const conversion = tokenValueToFigma(value, resolvedType);
1507
+ if (conversion.kind === "skip-invalid") {
1508
+ warnings.push(`Skipped ${entry.path} (mode "${modeName}") — ${conversion.reason}.`);
1509
+ continue;
1510
+ }
1511
+ if (conversion.kind !== "value")
1512
+ continue; // skip-empty / skip-alias (handled above)
1513
+ values.push({ ...modeKeying, kind: "literal", value: conversion.value });
1514
+ }
1515
+ // Stashed variable metadata rides along to creation. Scopes: only
1516
+ // meaningful (non-default) arrays; codeSyntax: only non-empty maps.
1517
+ const ext = token.extensions?.["figma-console-mcp"] ?? {};
1518
+ const createScopes = Array.isArray(ext.scopes) &&
1519
+ ext.scopes.length > 0 &&
1520
+ !(ext.scopes.length === 1 && ext.scopes[0] === "ALL_SCOPES")
1521
+ ? ext.scopes.filter((s) => typeof s === "string")
1522
+ : undefined;
1523
+ const createCodeSyntax = ext.codeSyntax &&
1524
+ typeof ext.codeSyntax === "object" &&
1525
+ !Array.isArray(ext.codeSyntax) &&
1526
+ Object.keys(ext.codeSyntax).length > 0
1527
+ ? ext.codeSyntax
1528
+ : undefined;
1529
+ const def = {
1530
+ key: entry.path,
1531
+ name: token.path.join("/"),
1532
+ resolvedType,
1533
+ ...(token.description ? { description: token.description } : {}),
1534
+ values,
1535
+ ...(createScopes && createScopes.length > 0
1536
+ ? { scopes: createScopes }
1537
+ : {}),
1538
+ ...(createCodeSyntax ? { codeSyntax: createCodeSyntax } : {}),
1539
+ };
1540
+ if (existingCollection) {
1541
+ let bucket = existingById.get(existingCollection.id);
1542
+ if (!bucket) {
1543
+ bucket = { collectionId: existingCollection.id, variables: [] };
1544
+ existingById.set(existingCollection.id, bucket);
1545
+ }
1546
+ bucket.variables.push(def);
1547
+ }
1548
+ else {
1549
+ let bucket = newBySet.get(set.name);
1550
+ if (!bucket) {
1551
+ bucket = {
1552
+ setName: set.name,
1553
+ modes: set.modes?.length ? [...set.modes] : ["Default"],
1554
+ variables: [],
1555
+ };
1556
+ newBySet.set(set.name, bucket);
1557
+ }
1558
+ bucket.variables.push(def);
1559
+ }
1560
+ }
1561
+ return {
1562
+ newCollections: [...newBySet.values()],
1563
+ existingCollections: [...existingById.values()],
1564
+ };
1565
+ }
1566
+ /**
1567
+ * Execute a CreatePlan via the plugin bridge in ONE batched script (same
1568
+ * executeCodeViaUI transport the update phase and figma_setup_design_tokens
1569
+ * use — see write-tools.ts for the precedent):
1570
+ *
1571
+ * Pass 0 — create missing collections; rename the auto-created default
1572
+ * mode to the set's first mode, addMode() for the rest.
1573
+ * Pass 1 — create every variable and set its LITERAL values.
1574
+ * Pass 2 — set alias values, after all variables exist, so aliases among
1575
+ * just-created variables resolve via the in-script created-ID map.
1576
+ *
1577
+ * Per-item failures are collected and surfaced without failing the batch.
1578
+ */
1579
+ async function applyCreates(connector, plan) {
1580
+ const payload = JSON.stringify(plan);
1581
+ const totalVars = plan.newCollections.reduce((n, c) => n + c.variables.length, 0) +
1582
+ plan.existingCollections.reduce((n, c) => n + c.variables.length, 0);
1583
+ const timeout = Math.min(60000, Math.max(15000, totalVars * 200 + plan.newCollections.length * 500));
1584
+ const script = `
1585
+ const plan = ${payload};
1586
+ const results = [];
1587
+ const aliasFailures = [];
1588
+ const createdIds = {};
1589
+ const createdCollections = [];
1590
+ const pendingAliases = [];
1591
+
1592
+ function createVariableWithValues(def, collection, modeMap) {
1593
+ try {
1594
+ const variable = figma.variables.createVariable(def.name, collection, def.resolvedType);
1595
+ if (def.description) variable.description = def.description;
1596
+ createdIds[def.key] = variable.id;
1597
+ let appliedModes = 0;
1598
+ const valueErrors = [];
1599
+ // Variable metadata (scopes / per-platform code syntax) — failures
1600
+ // are per-item value errors, never batch failures.
1601
+ if (def.scopes) {
1602
+ try { variable.scopes = def.scopes; }
1603
+ catch (err) { valueErrors.push('scopes: ' + String(err && err.message || err)); }
1604
+ }
1605
+ if (def.codeSyntax) {
1606
+ for (const platform in def.codeSyntax) {
1607
+ try { variable.setVariableCodeSyntax(platform, def.codeSyntax[platform]); }
1608
+ catch (err) { valueErrors.push('codeSyntax ' + platform + ': ' + String(err && err.message || err)); }
1609
+ }
1610
+ }
1611
+ for (const val of def.values) {
1612
+ const modeId = val.modeId || (modeMap ? modeMap[val.modeName] : null);
1613
+ if (!modeId) { valueErrors.push('unknown mode: ' + (val.modeName || val.modeId)); continue; }
1614
+ if (val.kind === 'literal') {
1615
+ try { variable.setValueForMode(modeId, val.value); appliedModes++; }
1616
+ catch (err) { valueErrors.push('mode ' + modeId + ': ' + String(err && err.message || err)); }
1617
+ } else {
1618
+ // Alias values apply in pass 2, after ALL variables exist.
1619
+ pendingAliases.push({ variable: variable, key: def.key, modeId: modeId, targetId: val.targetId || null, targetKey: val.targetKey || null });
1620
+ }
1621
+ }
1622
+ results.push({ key: def.key, name: def.name, id: variable.id, success: true, appliedModes: appliedModes, valueErrors: valueErrors });
1623
+ } catch (err) {
1624
+ results.push({ key: def.key, name: def.name, success: false, error: String(err && err.message || err) });
1625
+ }
1626
+ }
1627
+
1628
+ // Pass 0 + 1a — new collections (full mode list), then their variables.
1629
+ for (const nc of plan.newCollections) {
1630
+ let collection;
1631
+ const modeMap = {};
1632
+ try {
1633
+ collection = figma.variables.createVariableCollection(nc.setName);
1634
+ const defaultModeId = collection.modes[0].modeId;
1635
+ collection.renameMode(defaultModeId, nc.modes[0]);
1636
+ modeMap[nc.modes[0]] = defaultModeId;
1637
+ for (let i = 1; i < nc.modes.length; i++) {
1638
+ modeMap[nc.modes[i]] = collection.addMode(nc.modes[i]);
1639
+ }
1640
+ createdCollections.push({ name: nc.setName, id: collection.id });
1641
+ } catch (err) {
1642
+ // Roll back the orphaned collection if creation succeeded but mode
1643
+ // setup (renameMode/addMode) threw — otherwise a half-configured
1644
+ // empty collection is left behind in the file.
1645
+ let rolledBack = false;
1646
+ if (collection) {
1647
+ try { collection.remove(); rolledBack = true; } catch (removeErr) {}
1648
+ }
1649
+ const suffix = rolledBack ? ' (partially-created collection rolled back)' : '';
1650
+ for (const def of nc.variables) {
1651
+ results.push({ key: def.key, name: def.name, success: false, error: 'collection "' + nc.setName + '" creation failed' + suffix + ': ' + String(err && err.message || err) });
1652
+ }
1653
+ continue;
1654
+ }
1655
+ for (const def of nc.variables) createVariableWithValues(def, collection, modeMap);
1656
+ }
1657
+
1658
+ // Pass 1b — variables missing from EXISTING collections.
1659
+ for (const group of plan.existingCollections) {
1660
+ const collection = await figma.variables.getVariableCollectionByIdAsync(group.collectionId);
1661
+ if (!collection) {
1662
+ for (const def of group.variables) {
1663
+ results.push({ key: def.key, name: def.name, success: false, error: 'collection not found: ' + group.collectionId });
1664
+ }
1665
+ continue;
1666
+ }
1667
+ for (const def of group.variables) createVariableWithValues(def, collection, null);
1668
+ }
1669
+
1670
+ // Pass 2 — alias values. Targets are either pre-resolved IDs or keys of
1671
+ // variables created above (createdIds).
1672
+ for (const pa of pendingAliases) {
1673
+ try {
1674
+ const targetId = pa.targetId || (pa.targetKey ? createdIds[pa.targetKey] : null);
1675
+ if (!targetId) {
1676
+ aliasFailures.push({ key: pa.key, error: 'alias target was not created: ' + (pa.targetKey || 'unknown') });
1677
+ continue;
1678
+ }
1679
+ pa.variable.setValueForMode(pa.modeId, { type: 'VARIABLE_ALIAS', id: targetId });
1680
+ } catch (err) {
1681
+ aliasFailures.push({ key: pa.key, error: 'alias: ' + String(err && err.message || err) });
1682
+ }
1683
+ }
1684
+
1685
+ return {
1686
+ createdCollections: createdCollections,
1687
+ created: results.filter(r => r.success).length,
1688
+ failed: results.filter(r => !r.success).length,
1689
+ results: results,
1690
+ aliasFailures: aliasFailures,
1691
+ createdIds: createdIds,
1692
+ };
1693
+ `;
1694
+ const execResult = await connector.executeCodeViaUI(script, timeout);
1695
+ if (!execResult?.success) {
1696
+ return {
1697
+ created: 0,
1698
+ createdCollections: 0,
1699
+ failed: totalVars,
1700
+ errors: [
1701
+ {
1702
+ variableId: "<create-batch>",
1703
+ error: execResult?.error ??
1704
+ "Plugin executeCodeViaUI returned an error or timed out.",
1705
+ },
1706
+ ],
1707
+ createdIdByKey: new Map(),
1708
+ };
1709
+ }
1710
+ const inner = execResult.result ?? execResult;
1711
+ const errors = [];
1712
+ const results = inner.results ?? [];
1713
+ const aliasFailures = inner.aliasFailures ?? [];
1714
+ for (const r of results) {
1715
+ if (!r.success)
1716
+ errors.push({ variableId: r.key, error: r.error });
1717
+ }
1718
+ for (const f of aliasFailures) {
1719
+ errors.push({ variableId: f.key, error: f.error });
1720
+ }
1721
+ // Count each variable exactly ONCE: a variable that was created but whose
1722
+ // alias pass failed counts as failed, not as created+failed (the previous
1723
+ // arithmetic double-counted it across both buckets). Alias failures can
1724
+ // repeat per mode — dedupe by variable key.
1725
+ const aliasFailedKeys = new Set(aliasFailures.map((f) => f.key));
1726
+ const createFailed = results.filter((r) => !r.success).length;
1727
+ const createdOk = results.filter((r) => r.success && !aliasFailedKeys.has(r.key)).length;
1728
+ return {
1729
+ created: createdOk,
1730
+ createdCollections: (inner.createdCollections ?? []).length,
1731
+ failed: createFailed + aliasFailedKeys.size,
1732
+ errors,
1733
+ // Alias-failed variables DO exist — keep their IDs resolvable for the
1734
+ // later update phase.
1735
+ createdIdByKey: new Map(Object.entries(inner.createdIds ?? {})),
1736
+ };
1737
+ }
1738
+ // ============================================================================
1739
+ // DELETE PHASE — strategy "replace" only
1740
+ // ============================================================================
1741
+ /**
1742
+ * Delete Figma variables that are absent from the token file. STRICTLY
1743
+ * gated by the caller behind strategy "replace" — merge never reaches this.
1744
+ * Uses the connector's DELETE_VARIABLE bridge command (the same one behind
1745
+ * figma_delete_variable), one call per variable, with per-item error
1746
+ * isolation.
1747
+ */
1748
+ async function applyDeletes(connector, toDelete, figmaDoc) {
1749
+ // Diff key → live Figma variable ID.
1750
+ const figmaIdByKey = new Map();
1751
+ for (const set of figmaDoc.sets) {
1752
+ for (const t of set.tokens) {
1753
+ const id = t.extensions?.["figma-console-mcp"]?.variableId;
1754
+ if (typeof id === "string") {
1755
+ figmaIdByKey.set(`${set.name}::${t.path.join(".")}`, id);
1756
+ }
1757
+ }
1758
+ }
1759
+ let deleted = 0;
1760
+ let failed = 0;
1761
+ const errors = [];
1762
+ for (const entry of toDelete) {
1763
+ const variableId = figmaIdByKey.get(entry.path);
1764
+ if (!variableId) {
1765
+ failed++;
1766
+ errors.push({
1767
+ variableId: entry.path,
1768
+ error: "no Figma variable ID recorded for this token — cannot delete",
1769
+ });
1770
+ continue;
1771
+ }
1772
+ try {
1773
+ const result = await connector.deleteVariable(variableId);
1774
+ if (result && result.success === false) {
1775
+ throw new Error(result.error ?? "delete failed");
1776
+ }
1777
+ deleted++;
1778
+ }
1779
+ catch (err) {
1780
+ failed++;
1781
+ errors.push({
1782
+ variableId,
1783
+ error: err instanceof Error ? err.message : String(err),
1784
+ });
1785
+ }
1786
+ }
1787
+ return { deleted, failed, errors };
1788
+ }