@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.
- package/README.md +26 -17
- package/dist/cloudflare/core/cloud-websocket-connector.js +18 -0
- package/dist/cloudflare/core/design-code-tools.js +60 -17
- package/dist/cloudflare/core/design-system-manifest.js +19 -14
- package/dist/cloudflare/core/design-system-tools.js +43 -34
- package/dist/cloudflare/core/diagnose-tool.js +4 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +11 -5
- package/dist/cloudflare/core/enrichment/style-resolver.js +38 -18
- package/dist/cloudflare/core/figma-api.js +118 -54
- package/dist/cloudflare/core/figma-tools.js +179 -63
- package/dist/cloudflare/core/port-discovery.js +404 -31
- package/dist/cloudflare/core/tokens/alias-resolver.js +75 -5
- package/dist/cloudflare/core/tokens/config.js +10 -0
- package/dist/cloudflare/core/tokens/dialect.js +232 -0
- package/dist/cloudflare/core/tokens/figma-converter.js +144 -16
- package/dist/cloudflare/core/tokens/formatters/css-vars.js +21 -12
- package/dist/cloudflare/core/tokens/formatters/dtcg.js +106 -30
- package/dist/cloudflare/core/tokens/formatters/json.js +28 -10
- package/dist/cloudflare/core/tokens/formatters/scss.js +19 -13
- package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +15 -9
- package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +14 -9
- package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +11 -5
- package/dist/cloudflare/core/tokens/index.js +2 -1
- package/dist/cloudflare/core/tokens/parsers/dtcg.js +32 -5
- package/dist/cloudflare/core/tokens/schemas.js +4 -0
- package/dist/cloudflare/core/tokens-tools.js +1017 -88
- package/dist/cloudflare/core/version-tools.js +44 -3
- package/dist/cloudflare/core/websocket-connector.js +42 -0
- package/dist/cloudflare/core/websocket-server.js +99 -8
- package/dist/cloudflare/core/write-tools.js +355 -86
- package/dist/cloudflare/index.js +7 -7
- package/dist/core/design-code-tools.d.ts.map +1 -1
- package/dist/core/design-code-tools.js +60 -17
- package/dist/core/design-code-tools.js.map +1 -1
- package/dist/core/design-system-manifest.d.ts +1 -0
- package/dist/core/design-system-manifest.d.ts.map +1 -1
- package/dist/core/design-system-manifest.js +19 -14
- package/dist/core/design-system-manifest.js.map +1 -1
- package/dist/core/design-system-tools.d.ts.map +1 -1
- package/dist/core/design-system-tools.js +43 -34
- package/dist/core/design-system-tools.js.map +1 -1
- package/dist/core/diagnose-tool.d.ts +8 -0
- package/dist/core/diagnose-tool.d.ts.map +1 -1
- package/dist/core/diagnose-tool.js +4 -0
- package/dist/core/diagnose-tool.js.map +1 -1
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -1
- package/dist/core/enrichment/enrichment-service.js +11 -5
- package/dist/core/enrichment/enrichment-service.js.map +1 -1
- package/dist/core/enrichment/style-resolver.d.ts +7 -2
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -1
- package/dist/core/enrichment/style-resolver.js +38 -18
- package/dist/core/enrichment/style-resolver.js.map +1 -1
- package/dist/core/figma-api.d.ts +18 -9
- package/dist/core/figma-api.d.ts.map +1 -1
- package/dist/core/figma-api.js +118 -54
- package/dist/core/figma-api.js.map +1 -1
- package/dist/core/figma-connector.d.ts +12 -0
- package/dist/core/figma-connector.d.ts.map +1 -1
- package/dist/core/figma-tools.d.ts.map +1 -1
- package/dist/core/figma-tools.js +179 -63
- package/dist/core/figma-tools.js.map +1 -1
- package/dist/core/port-discovery.d.ts +40 -0
- package/dist/core/port-discovery.d.ts.map +1 -1
- package/dist/core/port-discovery.js +404 -31
- package/dist/core/port-discovery.js.map +1 -1
- package/dist/core/tokens/alias-resolver.d.ts +45 -3
- package/dist/core/tokens/alias-resolver.d.ts.map +1 -1
- package/dist/core/tokens/alias-resolver.js +75 -5
- package/dist/core/tokens/alias-resolver.js.map +1 -1
- package/dist/core/tokens/config.d.ts +28 -0
- package/dist/core/tokens/config.d.ts.map +1 -1
- package/dist/core/tokens/config.js +10 -0
- package/dist/core/tokens/config.js.map +1 -1
- package/dist/core/tokens/dialect.d.ts +107 -0
- package/dist/core/tokens/dialect.d.ts.map +1 -0
- package/dist/core/tokens/dialect.js +233 -0
- package/dist/core/tokens/dialect.js.map +1 -0
- package/dist/core/tokens/figma-converter.d.ts +23 -2
- package/dist/core/tokens/figma-converter.d.ts.map +1 -1
- package/dist/core/tokens/figma-converter.js +144 -16
- package/dist/core/tokens/figma-converter.js.map +1 -1
- package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -1
- package/dist/core/tokens/formatters/css-vars.js +21 -12
- package/dist/core/tokens/formatters/css-vars.js.map +1 -1
- package/dist/core/tokens/formatters/dtcg.d.ts +2 -2
- package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -1
- package/dist/core/tokens/formatters/dtcg.js +106 -30
- package/dist/core/tokens/formatters/dtcg.js.map +1 -1
- package/dist/core/tokens/formatters/json.d.ts.map +1 -1
- package/dist/core/tokens/formatters/json.js +28 -10
- package/dist/core/tokens/formatters/json.js.map +1 -1
- package/dist/core/tokens/formatters/scss.d.ts.map +1 -1
- package/dist/core/tokens/formatters/scss.js +19 -13
- package/dist/core/tokens/formatters/scss.js.map +1 -1
- package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -1
- package/dist/core/tokens/formatters/style-dictionary-v3.js +15 -9
- package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -1
- package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -1
- package/dist/core/tokens/formatters/tailwind-v4.js +14 -9
- package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -1
- package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -1
- package/dist/core/tokens/formatters/tokens-studio.js +11 -5
- package/dist/core/tokens/formatters/tokens-studio.js.map +1 -1
- package/dist/core/tokens/index.d.ts +2 -1
- package/dist/core/tokens/index.d.ts.map +1 -1
- package/dist/core/tokens/index.js +2 -1
- package/dist/core/tokens/index.js.map +1 -1
- package/dist/core/tokens/parsers/dtcg.js +32 -5
- package/dist/core/tokens/parsers/dtcg.js.map +1 -1
- package/dist/core/tokens/schemas.d.ts +3 -0
- package/dist/core/tokens/schemas.d.ts.map +1 -1
- package/dist/core/tokens/schemas.js +4 -0
- package/dist/core/tokens/schemas.js.map +1 -1
- package/dist/core/tokens/types.d.ts +57 -1
- package/dist/core/tokens/types.d.ts.map +1 -1
- package/dist/core/tokens/types.js.map +1 -1
- package/dist/core/tokens-tools.d.ts +250 -7
- package/dist/core/tokens-tools.d.ts.map +1 -1
- package/dist/core/tokens-tools.js +1017 -88
- package/dist/core/tokens-tools.js.map +1 -1
- package/dist/core/version-tools.d.ts.map +1 -1
- package/dist/core/version-tools.js +44 -3
- package/dist/core/version-tools.js.map +1 -1
- package/dist/core/websocket-connector.d.ts +38 -0
- package/dist/core/websocket-connector.d.ts.map +1 -1
- package/dist/core/websocket-connector.js +42 -0
- package/dist/core/websocket-connector.js.map +1 -1
- package/dist/core/websocket-server.d.ts +23 -0
- package/dist/core/websocket-server.d.ts.map +1 -1
- package/dist/core/websocket-server.js +99 -8
- package/dist/core/websocket-server.js.map +1 -1
- package/dist/core/write-tools.d.ts.map +1 -1
- package/dist/core/write-tools.js +355 -86
- package/dist/core/write-tools.js.map +1 -1
- package/dist/local.d.ts +0 -1
- package/dist/local.d.ts.map +1 -1
- package/dist/local.js +253 -63
- package/dist/local.js.map +1 -1
- package/figma-desktop-bridge/code.js +382 -28
- package/figma-desktop-bridge/ui.html +578 -292
- 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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
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
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
?
|
|
365
|
-
|
|
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
|
-
: "
|
|
368
|
-
|
|
369
|
-
?
|
|
370
|
-
:
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
*
|
|
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
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
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
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
b =
|
|
714
|
-
a =
|
|
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
|
-
|
|
717
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
759
|
-
// Figma
|
|
760
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
790
|
-
*
|
|
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
|
-
|
|
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,4 +1406,384 @@ 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
|
+
}
|
|
860
1789
|
//# sourceMappingURL=tokens-tools.js.map
|