@mp3wizard/figma-console-mcp 1.32.3 → 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 (137) hide show
  1. package/README.md +25 -17
  2. package/dist/cloudflare/core/cloud-websocket-connector.js +18 -0
  3. package/dist/cloudflare/core/design-system-manifest.js +19 -14
  4. package/dist/cloudflare/core/design-system-tools.js +43 -34
  5. package/dist/cloudflare/core/diagnose-tool.js +4 -0
  6. package/dist/cloudflare/core/enrichment/enrichment-service.js +11 -5
  7. package/dist/cloudflare/core/enrichment/style-resolver.js +38 -18
  8. package/dist/cloudflare/core/figma-api.js +118 -54
  9. package/dist/cloudflare/core/figma-tools.js +179 -63
  10. package/dist/cloudflare/core/port-discovery.js +404 -31
  11. package/dist/cloudflare/core/tokens/alias-resolver.js +75 -5
  12. package/dist/cloudflare/core/tokens/config.js +10 -0
  13. package/dist/cloudflare/core/tokens/dialect.js +232 -0
  14. package/dist/cloudflare/core/tokens/figma-converter.js +144 -16
  15. package/dist/cloudflare/core/tokens/formatters/css-vars.js +21 -12
  16. package/dist/cloudflare/core/tokens/formatters/dtcg.js +106 -30
  17. package/dist/cloudflare/core/tokens/formatters/json.js +28 -10
  18. package/dist/cloudflare/core/tokens/formatters/scss.js +19 -13
  19. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +15 -9
  20. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +14 -9
  21. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +11 -5
  22. package/dist/cloudflare/core/tokens/index.js +2 -1
  23. package/dist/cloudflare/core/tokens/parsers/dtcg.js +32 -5
  24. package/dist/cloudflare/core/tokens/schemas.js +4 -0
  25. package/dist/cloudflare/core/tokens-tools.js +1017 -88
  26. package/dist/cloudflare/core/version-tools.js +44 -3
  27. package/dist/cloudflare/core/websocket-connector.js +42 -0
  28. package/dist/cloudflare/core/websocket-server.js +99 -8
  29. package/dist/cloudflare/core/write-tools.js +355 -86
  30. package/dist/cloudflare/index.js +7 -7
  31. package/dist/core/design-system-manifest.d.ts +1 -0
  32. package/dist/core/design-system-manifest.d.ts.map +1 -1
  33. package/dist/core/design-system-manifest.js +19 -14
  34. package/dist/core/design-system-manifest.js.map +1 -1
  35. package/dist/core/design-system-tools.d.ts.map +1 -1
  36. package/dist/core/design-system-tools.js +43 -34
  37. package/dist/core/design-system-tools.js.map +1 -1
  38. package/dist/core/diagnose-tool.d.ts +8 -0
  39. package/dist/core/diagnose-tool.d.ts.map +1 -1
  40. package/dist/core/diagnose-tool.js +4 -0
  41. package/dist/core/diagnose-tool.js.map +1 -1
  42. package/dist/core/enrichment/enrichment-service.d.ts.map +1 -1
  43. package/dist/core/enrichment/enrichment-service.js +11 -5
  44. package/dist/core/enrichment/enrichment-service.js.map +1 -1
  45. package/dist/core/enrichment/style-resolver.d.ts +7 -2
  46. package/dist/core/enrichment/style-resolver.d.ts.map +1 -1
  47. package/dist/core/enrichment/style-resolver.js +38 -18
  48. package/dist/core/enrichment/style-resolver.js.map +1 -1
  49. package/dist/core/figma-api.d.ts +18 -9
  50. package/dist/core/figma-api.d.ts.map +1 -1
  51. package/dist/core/figma-api.js +118 -54
  52. package/dist/core/figma-api.js.map +1 -1
  53. package/dist/core/figma-connector.d.ts +12 -0
  54. package/dist/core/figma-connector.d.ts.map +1 -1
  55. package/dist/core/figma-tools.d.ts.map +1 -1
  56. package/dist/core/figma-tools.js +179 -63
  57. package/dist/core/figma-tools.js.map +1 -1
  58. package/dist/core/port-discovery.d.ts +40 -0
  59. package/dist/core/port-discovery.d.ts.map +1 -1
  60. package/dist/core/port-discovery.js +404 -31
  61. package/dist/core/port-discovery.js.map +1 -1
  62. package/dist/core/tokens/alias-resolver.d.ts +45 -3
  63. package/dist/core/tokens/alias-resolver.d.ts.map +1 -1
  64. package/dist/core/tokens/alias-resolver.js +75 -5
  65. package/dist/core/tokens/alias-resolver.js.map +1 -1
  66. package/dist/core/tokens/config.d.ts +28 -0
  67. package/dist/core/tokens/config.d.ts.map +1 -1
  68. package/dist/core/tokens/config.js +10 -0
  69. package/dist/core/tokens/config.js.map +1 -1
  70. package/dist/core/tokens/dialect.d.ts +107 -0
  71. package/dist/core/tokens/dialect.d.ts.map +1 -0
  72. package/dist/core/tokens/dialect.js +233 -0
  73. package/dist/core/tokens/dialect.js.map +1 -0
  74. package/dist/core/tokens/figma-converter.d.ts +23 -2
  75. package/dist/core/tokens/figma-converter.d.ts.map +1 -1
  76. package/dist/core/tokens/figma-converter.js +144 -16
  77. package/dist/core/tokens/figma-converter.js.map +1 -1
  78. package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -1
  79. package/dist/core/tokens/formatters/css-vars.js +21 -12
  80. package/dist/core/tokens/formatters/css-vars.js.map +1 -1
  81. package/dist/core/tokens/formatters/dtcg.d.ts +2 -2
  82. package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -1
  83. package/dist/core/tokens/formatters/dtcg.js +106 -30
  84. package/dist/core/tokens/formatters/dtcg.js.map +1 -1
  85. package/dist/core/tokens/formatters/json.d.ts.map +1 -1
  86. package/dist/core/tokens/formatters/json.js +28 -10
  87. package/dist/core/tokens/formatters/json.js.map +1 -1
  88. package/dist/core/tokens/formatters/scss.d.ts.map +1 -1
  89. package/dist/core/tokens/formatters/scss.js +19 -13
  90. package/dist/core/tokens/formatters/scss.js.map +1 -1
  91. package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -1
  92. package/dist/core/tokens/formatters/style-dictionary-v3.js +15 -9
  93. package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -1
  94. package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -1
  95. package/dist/core/tokens/formatters/tailwind-v4.js +14 -9
  96. package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -1
  97. package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -1
  98. package/dist/core/tokens/formatters/tokens-studio.js +11 -5
  99. package/dist/core/tokens/formatters/tokens-studio.js.map +1 -1
  100. package/dist/core/tokens/index.d.ts +2 -1
  101. package/dist/core/tokens/index.d.ts.map +1 -1
  102. package/dist/core/tokens/index.js +2 -1
  103. package/dist/core/tokens/index.js.map +1 -1
  104. package/dist/core/tokens/parsers/dtcg.js +32 -5
  105. package/dist/core/tokens/parsers/dtcg.js.map +1 -1
  106. package/dist/core/tokens/schemas.d.ts +3 -0
  107. package/dist/core/tokens/schemas.d.ts.map +1 -1
  108. package/dist/core/tokens/schemas.js +4 -0
  109. package/dist/core/tokens/schemas.js.map +1 -1
  110. package/dist/core/tokens/types.d.ts +57 -1
  111. package/dist/core/tokens/types.d.ts.map +1 -1
  112. package/dist/core/tokens/types.js.map +1 -1
  113. package/dist/core/tokens-tools.d.ts +250 -7
  114. package/dist/core/tokens-tools.d.ts.map +1 -1
  115. package/dist/core/tokens-tools.js +1017 -88
  116. package/dist/core/tokens-tools.js.map +1 -1
  117. package/dist/core/version-tools.d.ts.map +1 -1
  118. package/dist/core/version-tools.js +44 -3
  119. package/dist/core/version-tools.js.map +1 -1
  120. package/dist/core/websocket-connector.d.ts +38 -0
  121. package/dist/core/websocket-connector.d.ts.map +1 -1
  122. package/dist/core/websocket-connector.js +42 -0
  123. package/dist/core/websocket-connector.js.map +1 -1
  124. package/dist/core/websocket-server.d.ts +23 -0
  125. package/dist/core/websocket-server.d.ts.map +1 -1
  126. package/dist/core/websocket-server.js +99 -8
  127. package/dist/core/websocket-server.js.map +1 -1
  128. package/dist/core/write-tools.d.ts.map +1 -1
  129. package/dist/core/write-tools.js +355 -86
  130. package/dist/core/write-tools.js.map +1 -1
  131. package/dist/local.d.ts +0 -1
  132. package/dist/local.d.ts.map +1 -1
  133. package/dist/local.js +253 -63
  134. package/dist/local.js.map +1 -1
  135. package/figma-desktop-bridge/code.js +382 -28
  136. package/figma-desktop-bridge/ui.html +578 -292
  137. package/package.json +2 -2
@@ -19,11 +19,12 @@
19
19
  * structured $value form. Aliases emit `"$value": "{path.to.target}"`.
20
20
  *
21
21
  * This formatter is the canonical output — the format every other
22
- * formatter (CSS variables today, Tailwind/SCSS/etc. in future minor
23
- * versions) ultimately derives from.
22
+ * formatter (CSS variables, Tailwind v4/v3, SCSS, TS module, JSON
23
+ * flat/nested, Style Dictionary v3, Tokens Studio) ultimately derives from.
24
24
  */
25
25
  import { FIGMA_MCP_EXTENSION_KEY } from "../types.js";
26
26
  import { formatDtcgReference } from "../alias-resolver.js";
27
+ import { colorValueTo2025, dimensionLiteralTo2025, } from "../dialect.js";
27
28
  export function formatDtcg(doc, opts) {
28
29
  const warnings = [];
29
30
  const files = [];
@@ -35,6 +36,9 @@ export function formatDtcg(doc, opts) {
35
36
  // 4. neither → one file with everything
36
37
  const splitByMode = opts.target.splitByMode ?? false;
37
38
  const splitByCollection = opts.target.splitByCollection ?? false;
39
+ // Value-encoding dialect. 'legacy' (default) is byte-identical to the
40
+ // historical output; '2025' opts into DTCG 2025.10 object colors/dimensions.
41
+ const dialect = opts.target.dtcgDialect ?? "legacy";
38
42
  if (splitByMode && splitByCollection) {
39
43
  for (const set of doc.sets) {
40
44
  for (const mode of set.modes) {
@@ -43,7 +47,7 @@ export function formatDtcg(doc, opts) {
43
47
  .filter((t) => t !== null);
44
48
  files.push({
45
49
  path: filenameFor(opts, set, mode),
46
- content: serializeAsDtcg({ sets: [{ ...set, modes: [mode], tokens: fileTokens }], meta: doc.meta }, warnings, mode),
50
+ content: serializeAsDtcg({ sets: [{ ...set, modes: [mode], tokens: fileTokens }], meta: doc.meta }, warnings, dialect, mode),
47
51
  });
48
52
  }
49
53
  }
@@ -65,7 +69,7 @@ export function formatDtcg(doc, opts) {
65
69
  }));
66
70
  files.push({
67
71
  path: filenameFor(opts, undefined, mode),
68
- content: serializeAsDtcg({ sets: fileSets, meta: doc.meta }, warnings, mode),
72
+ content: serializeAsDtcg({ sets: fileSets, meta: doc.meta }, warnings, dialect, mode),
69
73
  });
70
74
  }
71
75
  }
@@ -73,14 +77,14 @@ export function formatDtcg(doc, opts) {
73
77
  for (const set of doc.sets) {
74
78
  files.push({
75
79
  path: filenameFor(opts, set),
76
- content: serializeAsDtcg({ sets: [set], meta: doc.meta }, warnings),
80
+ content: serializeAsDtcg({ sets: [set], meta: doc.meta }, warnings, dialect),
77
81
  });
78
82
  }
79
83
  }
80
84
  else {
81
85
  files.push({
82
86
  path: filenameFor(opts),
83
- content: serializeAsDtcg(doc, warnings),
87
+ content: serializeAsDtcg(doc, warnings, dialect),
84
88
  });
85
89
  }
86
90
  return { files, warnings };
@@ -131,7 +135,7 @@ function slugify(s) {
131
135
  * file represents — otherwise the parser sees only `$value` literals and
132
136
  * labels them "Default", which breaks round-trip diffs on multi-mode files.
133
137
  */
134
- function serializeAsDtcg(doc, warnings, fileMode) {
138
+ function serializeAsDtcg(doc, warnings, dialect, fileMode) {
135
139
  // Build the nested DTCG group tree by walking every token's path and
136
140
  // building groups along the way.
137
141
  const tree = {};
@@ -176,7 +180,7 @@ function serializeAsDtcg(doc, warnings, fileMode) {
176
180
  tree[setKey] = setGroup;
177
181
  }
178
182
  for (const token of set.tokens) {
179
- writeTokenIntoTree(setGroup, token, set.modes, warnings);
183
+ writeTokenIntoTree(setGroup, token, set.modes, warnings, dialect, fileMode);
180
184
  }
181
185
  }
182
186
  return JSON.stringify(sortKeys(tree), null, 2) + "\n";
@@ -193,20 +197,58 @@ function setKeyFor(set) {
193
197
  /**
194
198
  * Insert a token into the DTCG group tree at the right nested path.
195
199
  * Creates intermediate groups as needed.
200
+ *
201
+ * Leaf/group name conflicts (a variable named "color" alongside
202
+ * "color/primary") are kept — the leaf is emitted under the reserved "@"
203
+ * key inside the group, flagged with `leafRemap: true` in its
204
+ * figma-console-mcp $extensions so the parser can restore the original
205
+ * name on round-trip. A warning names both sides of the conflict.
196
206
  */
197
- function writeTokenIntoTree(root, token, setModes, warnings) {
207
+ function writeTokenIntoTree(root, token, setModes, warnings, dialect, fileMode) {
198
208
  let cursor = root;
199
209
  for (let i = 0; i < token.path.length - 1; i++) {
200
210
  const segment = token.path[i];
201
211
  let next = cursor[segment];
202
- if (!next || isToken(next)) {
212
+ if (next && isToken(next)) {
213
+ // A leaf token already occupies this segment and we need a group
214
+ // here. Keep both: demote the existing leaf to the reserved "@" key
215
+ // inside the new group.
216
+ const conflictPath = token.path.slice(0, i + 1).join("/");
217
+ warnings.push(`Name conflict: "${conflictPath}" is both a token and a group (needed by "${token.path.join("/")}"). Kept both — the leaf token "${conflictPath}" was emitted under "${conflictPath}/@" and round-trips back to its original name.`);
218
+ const group = { "@": markLeafRemap(next) };
219
+ cursor[segment] = group;
220
+ next = group;
221
+ }
222
+ else if (!next) {
203
223
  next = {};
204
224
  cursor[segment] = next;
205
225
  }
206
226
  cursor = next;
207
227
  }
208
228
  const leafKey = token.path[token.path.length - 1];
209
- cursor[leafKey] = renderToken(token, setModes, warnings);
229
+ const rendered = renderToken(token, setModes, warnings, dialect, fileMode);
230
+ const existing = cursor[leafKey];
231
+ if (existing &&
232
+ typeof existing === "object" &&
233
+ !isToken(existing)) {
234
+ // A group already exists at this name (some other token nests under
235
+ // it). Keep both: emit this leaf under the reserved "@" key.
236
+ const conflictPath = token.path.join("/");
237
+ warnings.push(`Name conflict: token "${conflictPath}" collides with the group "${conflictPath}" (created by tokens nested under it). Kept both — the leaf was emitted under "${conflictPath}/@" and round-trips back to its original name.`);
238
+ existing["@"] = markLeafRemap(rendered);
239
+ return;
240
+ }
241
+ cursor[leafKey] = rendered;
242
+ }
243
+ /**
244
+ * Flag a rendered leaf token as remapped under the reserved "@" key so the
245
+ * parser can strip that synthetic path segment on round-trip.
246
+ */
247
+ function markLeafRemap(tok) {
248
+ const existing = tok.$extensions?.[FIGMA_MCP_EXTENSION_KEY] ?? {};
249
+ tok.$extensions ??= {};
250
+ tok.$extensions[FIGMA_MCP_EXTENSION_KEY] = { ...existing, leafRemap: true };
251
+ return tok;
210
252
  }
211
253
  function isToken(node) {
212
254
  return "$value" in node;
@@ -215,11 +257,15 @@ function isToken(node) {
215
257
  * Convert an internal Token to its DTCG-encoded leaf form.
216
258
  *
217
259
  * Single-mode token: emits `{ $value, $type, ... }`.
218
- * Multi-mode token: emits one extension stash with all mode values, because
219
- * vanilla DTCG doesn't have a native multi-mode encoding. Callers who want
220
- * one-file-per-mode should set splitByMode at the formatter level.
260
+ * Multi-mode token: emits the primary mode as `$value` and stashes BOTH the
261
+ * primary mode's name (`primaryMode`) and the remaining mode values
262
+ * (`modes`) under `$extensions["figma-console-mcp"]`, because vanilla DTCG
263
+ * doesn't have a native multi-mode encoding. The parser reads the same keys
264
+ * back, so a Light/Dark collection round-trips losslessly even without
265
+ * splitByMode. Callers who want one-file-per-mode should set splitByMode at
266
+ * the formatter level.
221
267
  */
222
- function renderToken(token, setModes, warnings) {
268
+ function renderToken(token, setModes, warnings, dialect, fileMode) {
223
269
  const result = {
224
270
  $value: "",
225
271
  $type: token.type,
@@ -228,23 +274,16 @@ function renderToken(token, setModes, warnings) {
228
274
  result.$description = token.description;
229
275
  const modeKeys = Object.keys(token.values);
230
276
  const isSingleMode = modeKeys.length === 1;
231
- if (isSingleMode) {
232
- const onlyValue = token.values[modeKeys[0]];
233
- result.$value = encodeValue(onlyValue, token, warnings);
234
- }
235
- else {
236
- // Multi-mode in a single file: pick the first mode as the canonical
237
- // $value, stash the rest in $extensions for round-trip.
238
- const primaryMode = setModes[0] in token.values ? setModes[0] : modeKeys[0];
239
- result.$value = encodeValue(token.values[primaryMode], token, warnings);
240
- const otherModes = {};
277
+ // Pick the primary mode: the set's first mode when the token has a value
278
+ // for it, otherwise the token's first mode.
279
+ const primaryMode = setModes[0] in token.values ? setModes[0] : modeKeys[0];
280
+ result.$value = encodeValue(token.values[primaryMode], token, warnings, dialect);
281
+ const otherModes = {};
282
+ if (!isSingleMode) {
241
283
  for (const m of modeKeys) {
242
284
  if (m === primaryMode)
243
285
  continue;
244
- otherModes[m] = encodeValue(token.values[m], token, warnings);
245
- }
246
- if (Object.keys(otherModes).length > 0) {
247
- mergeExtension(result, "modes", otherModes);
286
+ otherModes[m] = encodeValue(token.values[m], token, warnings, dialect);
248
287
  }
249
288
  }
250
289
  // Preserve any pre-existing extensions (e.g. studio.tokens, our own metadata).
@@ -258,9 +297,29 @@ function renderToken(token, setModes, warnings) {
258
297
  }
259
298
  }
260
299
  }
300
+ // Mode round-trip metadata goes under OUR extension key (merged on top of
301
+ // any preserved figma-console-mcp payload so variableId etc. survive):
302
+ // - `primaryMode` whenever the parser couldn't otherwise recover the
303
+ // primary mode's name (i.e. it isn't covered by the file-level
304
+ // fileMode stamp and isn't the "Default" fallback).
305
+ // - `modes` with every non-primary mode's value for multi-mode tokens.
306
+ const needsPrimaryStash = primaryMode !== (fileMode ?? "Default");
307
+ const hasOtherModes = Object.keys(otherModes).length > 0;
308
+ if (needsPrimaryStash || hasOtherModes) {
309
+ const existing = result.$extensions?.[FIGMA_MCP_EXTENSION_KEY] ?? {};
310
+ mergeExtension(result, FIGMA_MCP_EXTENSION_KEY, {
311
+ ...existing,
312
+ primaryMode,
313
+ ...(hasOtherModes ? { modes: otherModes } : {}),
314
+ });
315
+ }
261
316
  return result;
262
317
  }
263
- function encodeValue(value, token, warnings) {
318
+ function encodeValue(value, token, warnings, dialect) {
319
+ if (!value) {
320
+ warnings.push(`Token ${token.path.join(".")} has no mode values — emitting empty string.`);
321
+ return "";
322
+ }
264
323
  if (value.reference) {
265
324
  return formatDtcgReference(value.reference.replace(/^\{|\}$/g, "").split("."));
266
325
  }
@@ -268,6 +327,23 @@ function encodeValue(value, token, warnings) {
268
327
  warnings.push(`Token ${token.path.join(".")} has neither literal nor reference — emitting empty string.`);
269
328
  return "";
270
329
  }
330
+ // DTCG 2025.10 dialect: colors emit the object form (components from the
331
+ // converter's full-precision rawColor floats, hex kept as the interop
332
+ // courtesy field); dimension-typed bare numbers emit { value, unit: "px" }.
333
+ // duration already emits { value, unit: "ms" } in both dialects. Anything
334
+ // the encoders don't recognize keeps the legacy rendering.
335
+ if (dialect === "2025") {
336
+ if (token.type === "color") {
337
+ const encoded = colorValueTo2025(value);
338
+ if (encoded)
339
+ return encoded;
340
+ }
341
+ else if (token.type === "dimension") {
342
+ const encoded = dimensionLiteralTo2025(value.literal);
343
+ if (encoded)
344
+ return encoded;
345
+ }
346
+ }
271
347
  return value.literal;
272
348
  }
273
349
  function mergeExtension(token, key, payload) {
@@ -31,11 +31,13 @@
31
31
  * aliases get a `null` (caller can decide how to fill those in).
32
32
  */
33
33
  import { buildTokenIndex, resolveAliasChain } from "../alias-resolver.js";
34
+ import { colorValueTo2025, dimensionLiteralTo2025, } from "../dialect.js";
34
35
  export function formatJsonFlat(doc, opts) {
35
36
  const warnings = [];
36
37
  const files = [];
37
38
  const splitByCollection = opts.target.splitByCollection ?? false;
38
39
  const prefix = opts.target.prefix ?? "";
40
+ const dialect = opts.target.dtcgDialect ?? "legacy";
39
41
  // Plain JSON has no native alias mechanism — resolve aliases to their
40
42
  // literal target so consumers get usable values, not opaque `{ref}` strings.
41
43
  const tokenIndex = buildTokenIndex(doc);
@@ -43,14 +45,14 @@ export function formatJsonFlat(doc, opts) {
43
45
  for (const set of doc.sets) {
44
46
  files.push({
45
47
  path: filenameFor(opts, set, "flat"),
46
- content: renderFlat([set], prefix, tokenIndex, warnings),
48
+ content: renderFlat([set], prefix, tokenIndex, warnings, dialect),
47
49
  });
48
50
  }
49
51
  }
50
52
  else {
51
53
  files.push({
52
54
  path: filenameFor(opts, undefined, "flat"),
53
- content: renderFlat(doc.sets, prefix, tokenIndex, warnings),
55
+ content: renderFlat(doc.sets, prefix, tokenIndex, warnings, dialect),
54
56
  });
55
57
  }
56
58
  return { files, warnings };
@@ -59,19 +61,20 @@ export function formatJsonNested(doc, opts) {
59
61
  const warnings = [];
60
62
  const files = [];
61
63
  const splitByCollection = opts.target.splitByCollection ?? false;
64
+ const dialect = opts.target.dtcgDialect ?? "legacy";
62
65
  const tokenIndex = buildTokenIndex(doc);
63
66
  if (splitByCollection) {
64
67
  for (const set of doc.sets) {
65
68
  files.push({
66
69
  path: filenameFor(opts, set, "nested"),
67
- content: renderNested([set], tokenIndex, warnings),
70
+ content: renderNested([set], tokenIndex, warnings, dialect),
68
71
  });
69
72
  }
70
73
  }
71
74
  else {
72
75
  files.push({
73
76
  path: filenameFor(opts, undefined, "nested"),
74
- content: renderNested(doc.sets, tokenIndex, warnings),
77
+ content: renderNested(doc.sets, tokenIndex, warnings, dialect),
75
78
  });
76
79
  }
77
80
  return { files, warnings };
@@ -92,7 +95,7 @@ function slugify(s) {
92
95
  .replace(/[^a-z0-9]+/g, "-")
93
96
  .replace(/^-+|-+$/g, "");
94
97
  }
95
- function renderFlat(sets, prefix, tokenIndex, warnings) {
98
+ function renderFlat(sets, prefix, tokenIndex, warnings, dialect) {
96
99
  const out = {};
97
100
  for (const set of sets) {
98
101
  const primaryMode = pickPrimaryMode(set.modes);
@@ -102,7 +105,7 @@ function renderFlat(sets, prefix, tokenIndex, warnings) {
102
105
  const key = modeName === primaryMode
103
106
  ? baseName
104
107
  : `${baseName}--${slugify(modeName)}`;
105
- const resolved = resolveValue(value, token, modeName, tokenIndex, warnings);
108
+ const resolved = resolveValue(value, token, modeName, tokenIndex, warnings, dialect);
106
109
  if (resolved !== undefined)
107
110
  out[key] = resolved;
108
111
  }
@@ -112,7 +115,7 @@ function renderFlat(sets, prefix, tokenIndex, warnings) {
112
115
  const sorted = Object.fromEntries(Object.entries(out).sort(([a], [b]) => a.localeCompare(b)));
113
116
  return JSON.stringify(sorted, null, 2) + "\n";
114
117
  }
115
- function renderNested(sets, tokenIndex, warnings) {
118
+ function renderNested(sets, tokenIndex, warnings, dialect) {
116
119
  const out = {};
117
120
  for (const set of sets) {
118
121
  const isMultiMode = set.modes.length > 1;
@@ -131,7 +134,7 @@ function renderNested(sets, tokenIndex, warnings) {
131
134
  if (isMultiMode) {
132
135
  const modeValues = {};
133
136
  for (const [modeName, value] of Object.entries(token.values)) {
134
- const resolved = resolveValue(value, token, modeName, tokenIndex, warnings);
137
+ const resolved = resolveValue(value, token, modeName, tokenIndex, warnings, dialect);
135
138
  if (resolved !== undefined)
136
139
  modeValues[modeName] = resolved;
137
140
  }
@@ -140,7 +143,7 @@ function renderNested(sets, tokenIndex, warnings) {
140
143
  else {
141
144
  const [onlyModeName, onlyValue] = Object.entries(token.values)[0] ?? [];
142
145
  if (onlyValue) {
143
- const resolved = resolveValue(onlyValue, token, onlyModeName, tokenIndex, warnings);
146
+ const resolved = resolveValue(onlyValue, token, onlyModeName, tokenIndex, warnings, dialect);
144
147
  if (resolved !== undefined)
145
148
  cursor[leafKey] = resolved;
146
149
  }
@@ -152,7 +155,7 @@ function renderNested(sets, tokenIndex, warnings) {
152
155
  function pickPrimaryMode(modes) {
153
156
  return modes.find((m) => /^(default|light|value)$/i.test(m)) ?? modes[0];
154
157
  }
155
- function resolveValue(value, token, mode, tokenIndex, warnings) {
158
+ function resolveValue(value, token, mode, tokenIndex, warnings, dialect) {
156
159
  let effective = value;
157
160
  if (value.reference) {
158
161
  effective = resolveAliasChain(value, mode, tokenIndex);
@@ -168,6 +171,21 @@ function resolveValue(value, token, mode, tokenIndex, warnings) {
168
171
  if (!effective || effective.literal === undefined || effective.literal === null) {
169
172
  return undefined;
170
173
  }
174
+ // DTCG 2025.10 dialect (opt-in): colors emit the object form, dimensions
175
+ // emit { value, unit: "px" }. Legacy (default) keeps hex strings and
176
+ // "16px" strings — byte-identical to historical output.
177
+ if (dialect === "2025") {
178
+ if (token.type === "color") {
179
+ const encoded = colorValueTo2025(effective);
180
+ if (encoded)
181
+ return encoded;
182
+ }
183
+ else if (token.type === "dimension") {
184
+ const encoded = dimensionLiteralTo2025(effective.literal);
185
+ if (encoded)
186
+ return encoded;
187
+ }
188
+ }
171
189
  if (typeof effective.literal === "number") {
172
190
  if (token.type === "dimension")
173
191
  return `${effective.literal}px`;
@@ -20,9 +20,13 @@
20
20
  * Composite tokens (typography, shadow) expand into multiple primitive
21
21
  * variables since SCSS variables hold a single value.
22
22
  */
23
+ import { buildTokenIndex, referenceTargetPath } from "../alias-resolver.js";
23
24
  export function formatScss(doc, opts) {
24
25
  const warnings = [];
25
26
  const files = [];
27
+ // Whole-document index so set-qualified alias references resolve to the
28
+ // target token's own path when generating $variable names.
29
+ const tokenIndex = buildTokenIndex(doc, warnings);
26
30
  const splitByMode = opts.target.splitByMode ?? false;
27
31
  const splitByCollection = opts.target.splitByCollection ?? false;
28
32
  const prefix = opts.target.prefix ?? "";
@@ -31,7 +35,7 @@ export function formatScss(doc, opts) {
31
35
  for (const mode of set.modes) {
32
36
  files.push({
33
37
  path: filenameFor(opts, set, mode),
34
- content: renderSingleMode([set], mode, prefix, warnings),
38
+ content: renderSingleMode([set], mode, prefix, tokenIndex, warnings),
35
39
  });
36
40
  }
37
41
  }
@@ -45,7 +49,7 @@ export function formatScss(doc, opts) {
45
49
  const sets = doc.sets.filter((s) => s.modes.includes(mode));
46
50
  files.push({
47
51
  path: filenameFor(opts, undefined, mode),
48
- content: renderSingleMode(sets, mode, prefix, warnings),
52
+ content: renderSingleMode(sets, mode, prefix, tokenIndex, warnings),
49
53
  });
50
54
  }
51
55
  }
@@ -53,14 +57,14 @@ export function formatScss(doc, opts) {
53
57
  for (const set of doc.sets) {
54
58
  files.push({
55
59
  path: filenameFor(opts, set),
56
- content: renderAllModes([set], prefix, warnings),
60
+ content: renderAllModes([set], prefix, tokenIndex, warnings),
57
61
  });
58
62
  }
59
63
  }
60
64
  else {
61
65
  files.push({
62
66
  path: filenameFor(opts),
63
- content: renderAllModes(doc.sets, prefix, warnings),
67
+ content: renderAllModes(doc.sets, prefix, tokenIndex, warnings),
64
68
  });
65
69
  }
66
70
  return { files, warnings };
@@ -87,7 +91,7 @@ function slugify(s) {
87
91
  function varName(path, prefix) {
88
92
  return `$${prefix}${path.map(slugify).join("-")}`;
89
93
  }
90
- function renderSingleMode(sets, mode, prefix, warnings) {
94
+ function renderSingleMode(sets, mode, prefix, tokenIndex, warnings) {
91
95
  const lines = [];
92
96
  lines.push("// Generated by figma-console-mcp — do not edit by hand");
93
97
  lines.push(`// Mode: ${mode}`);
@@ -100,13 +104,13 @@ function renderSingleMode(sets, mode, prefix, warnings) {
100
104
  const value = token.values[mode];
101
105
  if (!value)
102
106
  continue;
103
- emitSassLines(token, value, prefix, lines, warnings);
107
+ emitSassLines(token, value, prefix, tokenIndex, lines, warnings);
104
108
  }
105
109
  lines.push("");
106
110
  }
107
111
  return lines.join("\n");
108
112
  }
109
- function renderAllModes(sets, prefix, warnings) {
113
+ function renderAllModes(sets, prefix, tokenIndex, warnings) {
110
114
  const lines = [];
111
115
  lines.push("// Generated by figma-console-mcp — do not edit by hand");
112
116
  lines.push("");
@@ -120,7 +124,7 @@ function renderAllModes(sets, prefix, warnings) {
120
124
  // Primary value as the bare variable.
121
125
  const primary = token.values[primaryMode];
122
126
  if (primary)
123
- emitSassLines(token, primary, prefix, lines, warnings);
127
+ emitSassLines(token, primary, prefix, tokenIndex, lines, warnings);
124
128
  // Other modes as a map: $ds-color-primary--modes: ("Dark": #..., "Vibrant": #...)
125
129
  if (isMultiMode) {
126
130
  const otherModes = set.modes.filter((m) => m !== primaryMode);
@@ -129,7 +133,7 @@ function renderAllModes(sets, prefix, warnings) {
129
133
  const v = token.values[mode];
130
134
  if (!v)
131
135
  continue;
132
- const formatted = scssValueFor(v, token, prefix, warnings);
136
+ const formatted = scssValueFor(v, token, prefix, tokenIndex, warnings);
133
137
  if (formatted !== null) {
134
138
  entries.push(` "${mode}": ${formatted}`);
135
139
  }
@@ -149,7 +153,7 @@ function renderAllModes(sets, prefix, warnings) {
149
153
  function pickPrimaryMode(modes) {
150
154
  return modes.find((m) => /^(default|light|value)$/i.test(m)) ?? modes[0];
151
155
  }
152
- function emitSassLines(token, value, prefix, out, warnings) {
156
+ function emitSassLines(token, value, prefix, tokenIndex, out, warnings) {
153
157
  const sassName = varName(token.path, prefix);
154
158
  if (value.reference) {
155
159
  const bareRef = value.reference.replace(/^\{|\}$/g, "");
@@ -160,7 +164,8 @@ function emitSassLines(token, value, prefix, out, warnings) {
160
164
  out.push(`// ${sassName}: skipped — cross-library alias to ${originalId}`);
161
165
  return;
162
166
  }
163
- const refPath = bareRef.split(".");
167
+ // Resolve set-qualified references to the target token's own path.
168
+ const refPath = referenceTargetPath(value.reference, tokenIndex);
164
169
  out.push(`${sassName}: ${varName(refPath, prefix)};`);
165
170
  return;
166
171
  }
@@ -185,12 +190,13 @@ function emitSassLines(token, value, prefix, out, warnings) {
185
190
  }
186
191
  out.push(`${sassName}: ${formatScssLiteral(value.literal, token.type)};`);
187
192
  }
188
- function scssValueFor(value, token, prefix, _warnings) {
193
+ function scssValueFor(value, token, prefix, tokenIndex, _warnings) {
189
194
  if (value.reference) {
190
195
  const bareRef = value.reference.replace(/^\{|\}$/g, "");
191
196
  if (bareRef.startsWith("__library:") || bareRef === "unknown")
192
197
  return null;
193
- return varName(bareRef.split("."), prefix);
198
+ // Resolve set-qualified references to the target token's own path.
199
+ return varName(referenceTargetPath(value.reference, tokenIndex), prefix);
194
200
  }
195
201
  if (value.literal === undefined || value.literal === null)
196
202
  return null;
@@ -39,9 +39,13 @@
39
39
  * For back-compat with cbds-components / blocks / czi-edu / eddie-design-system
40
40
  * which still use SD v3's bare-key source format.
41
41
  */
42
+ import { buildTokenIndex, referenceTargetPath } from "../alias-resolver.js";
42
43
  export function formatStyleDictionaryV3(doc, opts) {
43
44
  const warnings = [];
44
45
  const files = [];
46
+ // Whole-document index so set-qualified alias references resolve to the
47
+ // target token's own path (SD v3 trees are path-based, no set groups).
48
+ const tokenIndex = buildTokenIndex(doc, warnings);
45
49
  const splitByMode = opts.target.splitByMode ?? false;
46
50
  const splitByCollection = opts.target.splitByCollection ?? false;
47
51
  if (splitByMode && splitByCollection) {
@@ -49,7 +53,7 @@ export function formatStyleDictionaryV3(doc, opts) {
49
53
  for (const mode of set.modes) {
50
54
  files.push({
51
55
  path: filenameFor(opts, set, mode),
52
- content: renderSdJson([set], [mode], warnings),
56
+ content: renderSdJson([set], [mode], tokenIndex, warnings),
53
57
  });
54
58
  }
55
59
  }
@@ -63,7 +67,7 @@ export function formatStyleDictionaryV3(doc, opts) {
63
67
  const sets = doc.sets.filter((s) => s.modes.includes(mode));
64
68
  files.push({
65
69
  path: filenameFor(opts, undefined, mode),
66
- content: renderSdJson(sets, [mode], warnings),
70
+ content: renderSdJson(sets, [mode], tokenIndex, warnings),
67
71
  });
68
72
  }
69
73
  }
@@ -71,7 +75,7 @@ export function formatStyleDictionaryV3(doc, opts) {
71
75
  for (const set of doc.sets) {
72
76
  files.push({
73
77
  path: filenameFor(opts, set),
74
- content: renderSdJson([set], set.modes, warnings),
78
+ content: renderSdJson([set], set.modes, tokenIndex, warnings),
75
79
  });
76
80
  }
77
81
  }
@@ -82,7 +86,7 @@ export function formatStyleDictionaryV3(doc, opts) {
82
86
  allModes.add(m);
83
87
  files.push({
84
88
  path: filenameFor(opts),
85
- content: renderSdJson(doc.sets, [...allModes], warnings),
89
+ content: renderSdJson(doc.sets, [...allModes], tokenIndex, warnings),
86
90
  });
87
91
  }
88
92
  return { files, warnings };
@@ -133,7 +137,7 @@ function sdTypeFor(token) {
133
137
  // Composites / less-common types: pass through.
134
138
  return token.type;
135
139
  }
136
- function renderSdJson(sets, modes, warnings) {
140
+ function renderSdJson(sets, modes, tokenIndex, warnings) {
137
141
  const tree = {};
138
142
  const primaryMode = pickPrimaryMode(modes);
139
143
  for (const set of sets) {
@@ -150,7 +154,7 @@ function renderSdJson(sets, modes, warnings) {
150
154
  ? primaryMode
151
155
  : usableModes[0];
152
156
  const tokenValue = token.values[valueMode];
153
- const sdValue = sdValueFor(tokenValue, token, warnings);
157
+ const sdValue = sdValueFor(tokenValue, token, tokenIndex, warnings);
154
158
  if (sdValue === undefined)
155
159
  continue;
156
160
  // Walk the path, creating nested groups.
@@ -177,15 +181,17 @@ function renderSdJson(sets, modes, warnings) {
177
181
  function pickPrimaryMode(modes) {
178
182
  return modes.find((m) => /^(default|light|value)$/i.test(m)) ?? modes[0];
179
183
  }
180
- function sdValueFor(value, token, warnings) {
184
+ function sdValueFor(value, token, tokenIndex, warnings) {
181
185
  if (value.reference) {
182
186
  const bare = value.reference.replace(/^\{|\}$/g, "");
183
187
  if (bare.startsWith("__library:") || bare === "unknown") {
184
188
  warnings.push(`Skipped ${token.path.join(".")} in Style Dictionary v3 — cross-library alias unresolved.`);
185
189
  return undefined;
186
190
  }
187
- // SD v3 uses the same `{path.to.token}` alias syntax as DTCG.
188
- return `{${bare}}`;
191
+ // SD v3 uses the same `{path.to.token}` alias syntax as DTCG, but its
192
+ // tree has no set groups — resolve set-qualified references to the
193
+ // target token's own path.
194
+ return `{${referenceTargetPath(value.reference, tokenIndex).join(".")}}`;
189
195
  }
190
196
  if (value.literal === undefined || value.literal === null)
191
197
  return undefined;