@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
@@ -0,0 +1,232 @@
1
+ /**
2
+ * DTCG dialect helpers — legacy vs 2025.10 value encodings.
3
+ *
4
+ * The token pipeline historically speaks the "legacy" DTCG dialect: colors
5
+ * as hex strings ("#4085F2"), FLOAT dimensions as bare numbers. The DTCG
6
+ * 2025.10 spec (https://tr.designtokens.org/format/) switched to object
7
+ * forms:
8
+ *
9
+ * color = { colorSpace: "srgb", components: [r, g, b], alpha?, hex? }
10
+ * dimension = { value: number, unit: "px" | "rem" }
11
+ * duration = { value: number, unit: "ms" | "s" } (we already emit this)
12
+ *
13
+ * Export stays legacy by default (downstream consumers depend on it) and
14
+ * opts into 2025 via the `dtcgDialect` option. Import accepts BOTH dialects
15
+ * unconditionally. This module centralizes:
16
+ *
17
+ * - 2025 encoding helpers used by the dtcg/json formatters at render time
18
+ * - dialect-agnostic canonicalization used by the import diff so a 2025
19
+ * color object compares equal to the same color's legacy hex string
20
+ * (both are quantized to 1/255 per channel, tolerating the 8-bit
21
+ * precision loss inherent to hex)
22
+ * - stripping of the transient `rawColor` field the converter carries on
23
+ * COLOR TokenValues (full-precision floats for 2025 components) so it
24
+ * never leaks into serialized output
25
+ */
26
+ export function clamp01(n) {
27
+ if (Number.isNaN(n))
28
+ return 0;
29
+ return Math.max(0, Math.min(1, n));
30
+ }
31
+ function byteHex(f) {
32
+ return Math.round(clamp01(f) * 255)
33
+ .toString(16)
34
+ .padStart(2, "0");
35
+ }
36
+ /**
37
+ * Canonicalize any color-like literal to a lowercase 8-digit hex string
38
+ * (`#rrggbbaa`), or return null when the literal isn't recognizably a color.
39
+ *
40
+ * Accepts:
41
+ * - hex strings: #rgb, #rrggbb, #rrggbbaa
42
+ * - 2025.10 color objects with srgb (or unspecified) colorSpace +
43
+ * 3 numeric components (+ optional alpha)
44
+ * - color objects with a hex fallback field (any colorSpace) + optional alpha
45
+ *
46
+ * Both the components form and the hex form quantize to 1 / 255 per channel,
47
+ * so a full-precision components array compares equal to the hex string the
48
+ * legacy pipeline derived from the same Figma floats.
49
+ */
50
+ export function colorLiteralToCanonicalHex(literal) {
51
+ if (typeof literal === "string") {
52
+ const m = literal
53
+ .trim()
54
+ .match(/^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
55
+ if (!m)
56
+ return null;
57
+ let digits = m[1].toLowerCase();
58
+ if (digits.length === 3) {
59
+ digits = digits
60
+ .split("")
61
+ .map((c) => c + c)
62
+ .join("");
63
+ }
64
+ if (digits.length === 6)
65
+ digits += "ff";
66
+ return `#${digits}`;
67
+ }
68
+ if (literal && typeof literal === "object" && !Array.isArray(literal)) {
69
+ const o = literal;
70
+ const colorSpace = typeof o.colorSpace === "string" ? o.colorSpace : undefined;
71
+ const comps = o.components;
72
+ if ((colorSpace === undefined || colorSpace === "srgb") &&
73
+ Array.isArray(comps) &&
74
+ comps.length === 3 &&
75
+ comps.every((c) => typeof c === "number")) {
76
+ const [r, g, b] = comps;
77
+ const a = typeof o.alpha === "number" ? o.alpha : 1;
78
+ return `#${byteHex(r)}${byteHex(g)}${byteHex(b)}${byteHex(a)}`;
79
+ }
80
+ if (typeof o.hex === "string") {
81
+ const base = colorLiteralToCanonicalHex(o.hex);
82
+ if (!base)
83
+ return null;
84
+ if (typeof o.alpha === "number") {
85
+ return base.slice(0, 7) + byteHex(o.alpha);
86
+ }
87
+ return base;
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ /**
93
+ * Parse a hex color string to raw rgba floats (0–1). Returns null on
94
+ * anything that isn't a valid 3/6/8-digit hex string.
95
+ */
96
+ export function hexToRawRgba(hex) {
97
+ const canonical = colorLiteralToCanonicalHex(hex);
98
+ if (!canonical)
99
+ return null;
100
+ const d = canonical.slice(1);
101
+ return {
102
+ r: parseInt(d.slice(0, 2), 16) / 255,
103
+ g: parseInt(d.slice(2, 4), 16) / 255,
104
+ b: parseInt(d.slice(4, 6), 16) / 255,
105
+ a: parseInt(d.slice(6, 8), 16) / 255,
106
+ };
107
+ }
108
+ /**
109
+ * Encode a color TokenValue in the DTCG 2025.10 object form.
110
+ *
111
+ * Prefers the converter's transient `rawColor` floats (full precision — NOT
112
+ * round-tripped through 8-bit hex); falls back to parsing a hex-string
113
+ * literal. Literals already in object form pass through verbatim. Returns
114
+ * null when the value can't be encoded (caller keeps the legacy rendering).
115
+ *
116
+ * `alpha` is emitted only when < 1; `hex` is always included as the interop
117
+ * courtesy field (#RRGGBB, alpha carried separately).
118
+ */
119
+ export function colorValueTo2025(value) {
120
+ const lit = value.literal;
121
+ if (lit && typeof lit === "object" && !Array.isArray(lit)) {
122
+ // Already object-form (e.g. a re-exported 2025 document) — pass through.
123
+ const o = lit;
124
+ if ("colorSpace" in o || "components" in o || "hex" in o)
125
+ return o;
126
+ return null;
127
+ }
128
+ let floats = value.rawColor ?? null;
129
+ if (!floats && typeof lit === "string") {
130
+ floats = hexToRawRgba(lit);
131
+ }
132
+ if (!floats)
133
+ return null;
134
+ const { r, g, b, a } = floats;
135
+ return {
136
+ colorSpace: "srgb",
137
+ components: [r, g, b],
138
+ ...(a < 1 ? { alpha: a } : {}),
139
+ hex: `#${byteHex(r)}${byteHex(g)}${byteHex(b)}`.toUpperCase(),
140
+ };
141
+ }
142
+ /**
143
+ * Encode a dimension literal in the DTCG 2025.10 object form. The converter
144
+ * emits Figma FLOAT dimensions as bare unitless numbers conventionally
145
+ * interpreted as px (the same convention the css/json formatters use), so
146
+ * only bare finite numbers are converted; everything else (unit strings,
147
+ * pre-encoded objects) keeps its current rendering.
148
+ */
149
+ export function dimensionLiteralTo2025(literal) {
150
+ if (typeof literal === "number" && Number.isFinite(literal)) {
151
+ return { value: literal, unit: "px" };
152
+ }
153
+ return null;
154
+ }
155
+ /**
156
+ * Normalize a TokenValue to a dialect-agnostic comparable form for diffing:
157
+ *
158
+ * - the transient `rawColor` field is dropped (Figma-side values carry it;
159
+ * parsed code-side values never do)
160
+ * - color-like literals (hex strings OR 2025 color objects) normalize to a
161
+ * lowercase 8-digit hex string, quantized to 1/255 per channel
162
+ * - `{ value, unit }` objects with px/ms normalize to the bare number
163
+ * ("s" converts to ms first), so `{value: 16, unit: "px"}` equals 16 and
164
+ * `{value: 0.3, unit: "s"}` equals `{value: 300, unit: "ms"}`
165
+ * - "16px"-style strings normalize to the bare number
166
+ *
167
+ * Conservative by design: anything not confidently recognized is returned
168
+ * unchanged (minus rawColor), falling back to the existing deep comparison.
169
+ */
170
+ export function canonicalizeTokenValueForComparison(v) {
171
+ if (v === null || typeof v !== "object" || Array.isArray(v))
172
+ return v;
173
+ const obj = v;
174
+ if (!("literal" in obj) && !("reference" in obj))
175
+ return v;
176
+ const { rawColor: _rawColor, ...rest } = obj;
177
+ if (rest.literal !== undefined) {
178
+ const hex = colorLiteralToCanonicalHex(rest.literal);
179
+ if (hex !== null)
180
+ return { ...rest, literal: hex };
181
+ const num = numericLiteralToCanonical(rest.literal);
182
+ if (num !== null)
183
+ return { ...rest, literal: num };
184
+ }
185
+ return rest;
186
+ }
187
+ /**
188
+ * Normalize dimension/duration-shaped literals to a bare comparable number:
189
+ * - `{ value: n, unit: "px" | "ms" }` → n
190
+ * - `{ value: n, unit: "s" }` → n * 1000 (canonical ms)
191
+ * - `"16px"` strings → 16
192
+ * Returns null for everything else (including rem/em/% and objects with
193
+ * extra fields — those keep structural comparison).
194
+ */
195
+ function numericLiteralToCanonical(literal) {
196
+ if (typeof literal === "string") {
197
+ const m = literal.trim().match(/^(-?(?:\d+\.?\d*|\.\d+))px$/i);
198
+ return m ? Number(m[1]) : null;
199
+ }
200
+ if (literal &&
201
+ typeof literal === "object" &&
202
+ !Array.isArray(literal) &&
203
+ Object.keys(literal).length === 2) {
204
+ const o = literal;
205
+ if (typeof o.value === "number" && typeof o.unit === "string") {
206
+ if (o.unit === "px" || o.unit === "ms")
207
+ return o.value;
208
+ if (o.unit === "s")
209
+ return o.value * 1000;
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+ /**
215
+ * Return a copy of a mode-keyed TokenValue map with the transient `rawColor`
216
+ * field removed from every entry. Used wherever values get serialized
217
+ * (lastSyncedValue snapshots, diff-plan samples) so the transient field never
218
+ * appears in output — keeping legacy output byte-identical.
219
+ */
220
+ export function stripRawColorFromValues(values) {
221
+ const out = {};
222
+ for (const [k, v] of Object.entries(values)) {
223
+ if (v && typeof v === "object" && "rawColor" in v) {
224
+ const { rawColor: _rawColor, ...rest } = v;
225
+ out[k] = rest;
226
+ }
227
+ else {
228
+ out[k] = v;
229
+ }
230
+ }
231
+ return out;
232
+ }
@@ -11,6 +11,8 @@
11
11
  * normalized to our TokenValue shape, and Figma IDs preserved in
12
12
  * $extensions["figma-console-mcp"] for round-trip non-destructiveness.
13
13
  */
14
+ import { slugifySetName } from "./alias-resolver.js";
15
+ import { stripRawColorFromValues } from "./dialect.js";
14
16
  /**
15
17
  * Convert a Figma variables payload to our canonical TokenDocument.
16
18
  */
@@ -20,11 +22,19 @@ export function convertFigmaVariablesToDocument(payload, opts = {}) {
20
22
  const variableById = new Map();
21
23
  for (const v of payload.variables)
22
24
  variableById.set(v.id, v);
25
+ // Collection name lookup — alias references must carry the owning set so
26
+ // same-path tokens across collections don't misresolve (and so emitted
27
+ // DTCG references point at the actual set group in the output tree).
28
+ // Built over ALL collections (not just the filtered ones) because an
29
+ // alias can target a variable in a collection outside the export scope.
30
+ const collectionNameById = new Map();
31
+ for (const c of payload.collections)
32
+ collectionNameById.set(c.id, c.name);
23
33
  // Filter collections per scope.
24
34
  const wantedCollections = opts.collectionIds?.length
25
35
  ? payload.collections.filter((c) => opts.collectionIds.includes(c.id))
26
36
  : payload.collections;
27
- const sets = wantedCollections.map((collection) => convertCollection(collection, payload.variables, variableById, opts, warnings));
37
+ const sets = wantedCollections.map((collection) => convertCollection(collection, payload.variables, variableById, collectionNameById, opts, warnings));
28
38
  return {
29
39
  document: {
30
40
  $schema: "https://figma-console-mcp.southleft.com/schemas/dtcg-extended-v1.json",
@@ -38,7 +48,7 @@ export function convertFigmaVariablesToDocument(payload, opts = {}) {
38
48
  warnings,
39
49
  };
40
50
  }
41
- function convertCollection(collection, allVariables, variableById, opts, warnings) {
51
+ function convertCollection(collection, allVariables, variableById, collectionNameById, opts, warnings) {
42
52
  // Mode filter: keep only modes the caller wants, intersected with what
43
53
  // the collection actually has.
44
54
  const wantedModes = !opts.modes || opts.modes === "all"
@@ -46,7 +56,7 @@ function convertCollection(collection, allVariables, variableById, opts, warning
46
56
  : collection.modes.filter((m) => opts.modes.includes(m.name));
47
57
  // Variables in this collection.
48
58
  const collectionVars = allVariables.filter((v) => v.variableCollectionId === collection.id);
49
- const tokens = collectionVars.map((variable) => convertVariable(variable, wantedModes, variableById, opts, warnings));
59
+ const tokens = collectionVars.map((variable) => convertVariable(variable, wantedModes, variableById, collectionNameById, opts, warnings));
50
60
  return {
51
61
  name: collection.name,
52
62
  modes: wantedModes.map((m) => m.name),
@@ -56,26 +66,47 @@ function convertCollection(collection, allVariables, variableById, opts, warning
56
66
  },
57
67
  };
58
68
  }
59
- function convertVariable(variable, wantedModes, variableById, opts, warnings) {
60
- // Derive the hierarchical path from the Figma variable name. Figma uses
61
- // slashes to indicate grouping: "color/brand/primary" ["color", "brand", "primary"].
69
+ /**
70
+ * Derive a variable's token path from its Figma name. Strips the configured
71
+ * prefix, splits on "/", and for TIMING/EASING variables drops the trailing
72
+ * type segment Figma appends to Config-2026 motion variables (e.g.
73
+ * "motion/duration/quick/Timing" → ["motion", "duration", "quick"]).
74
+ */
75
+ function variableTokenPath(variable, opts) {
62
76
  let name = variable.name;
63
77
  if (opts.stripPrefix && name.startsWith(opts.stripPrefix)) {
64
78
  name = name.slice(opts.stripPrefix.length);
65
79
  }
66
- const path = name.split("/").filter(Boolean);
80
+ const segments = name.split("/").filter(Boolean);
81
+ let strippedTypeSuffix = false;
82
+ if ((variable.resolvedType === "TIMING" || variable.resolvedType === "EASING") &&
83
+ segments.length > 1 &&
84
+ /^(timing|easing)$/i.test(segments[segments.length - 1])) {
85
+ segments.pop();
86
+ strippedTypeSuffix = true;
87
+ }
88
+ return { path: segments, strippedTypeSuffix };
89
+ }
90
+ function convertVariable(variable, wantedModes, variableById, collectionNameById, opts, warnings) {
91
+ // Derive the hierarchical path from the Figma variable name. Figma uses
92
+ // slashes to indicate grouping: "color/brand/primary" → ["color", "brand", "primary"].
93
+ const { path, strippedTypeSuffix } = variableTokenPath(variable, opts);
67
94
  // Map resolvedType to TokenType.
68
95
  const type = mapResolvedType(variable.resolvedType, variable.name, warnings);
69
96
  // Convert each (mode → value) pair to our TokenValue shape, filtered by
70
- // the wanted modes.
97
+ // the wanted modes. Spring easings can't be expressed in DTCG — their
98
+ // parameters get stashed per-mode in the token's extensions.
71
99
  const values = {};
100
+ const springByMode = {};
72
101
  for (const mode of wantedModes) {
73
102
  const rawValue = variable.valuesByMode[mode.modeId];
74
103
  if (rawValue === undefined) {
75
104
  warnings.push(`Variable "${variable.name}" has no value for mode "${mode.name}" (${mode.modeId}); skipping that mode.`);
76
105
  continue;
77
106
  }
78
- values[mode.name] = convertValue(rawValue, variable.resolvedType, variableById, warnings);
107
+ values[mode.name] = convertValue(rawValue, variable, mode.name, variableById, collectionNameById, opts, warnings, (spring) => {
108
+ springByMode[mode.name] = spring;
109
+ });
79
110
  }
80
111
  return {
81
112
  path,
@@ -86,14 +117,45 @@ function convertVariable(variable, wantedModes, variableById, opts, warnings) {
86
117
  "figma-console-mcp": {
87
118
  variableId: variable.id,
88
119
  collectionId: variable.variableCollectionId,
120
+ // The Figma-native type is what import needs to decide writability
121
+ // — TIMING/EASING variables cannot be written via the Plugin API,
122
+ // and FLOAT variables whose token type is "duration" (name-inferred)
123
+ // must NOT be mistaken for TIMING.
124
+ figmaResolvedType: variable.resolvedType,
125
+ // Preserve the original variable name when the token path dropped
126
+ // the trailing "Timing"/"Easing" segment, so round-trip can
127
+ // reconstruct it.
128
+ ...(strippedTypeSuffix ? { figmaName: variable.name } : {}),
129
+ ...(Object.keys(springByMode).length > 0 ? { spring: springByMode } : {}),
130
+ // Scopes: stash only when NON-default. ["ALL_SCOPES"] (Figma's
131
+ // default) and empty/absent arrays are omitted so pre-existing
132
+ // exports stay byte-identical. codeSyntax: stash only when
133
+ // non-empty, same reasoning.
134
+ ...(hasNonDefaultScopes(variable.scopes)
135
+ ? { scopes: [...variable.scopes] }
136
+ : {}),
137
+ ...(variable.codeSyntax && Object.keys(variable.codeSyntax).length > 0
138
+ ? { codeSyntax: { ...variable.codeSyntax } }
139
+ : {}),
89
140
  lastSyncedAt: new Date().toISOString(),
90
141
  // We snapshot the synced value so future merge calls can detect
91
- // two-sided conflicts.
92
- lastSyncedValue: { ...values },
142
+ // two-sided conflicts. rawColor is transient render-time data and
143
+ // must not leak into serialized extensions (legacy output stays
144
+ // byte-identical).
145
+ lastSyncedValue: stripRawColorFromValues({ ...values }),
93
146
  },
94
147
  },
95
148
  };
96
149
  }
150
+ /**
151
+ * True when a variable's scopes array is meaningfully restrictive — i.e.
152
+ * present, non-empty, and not just the default ["ALL_SCOPES"].
153
+ */
154
+ function hasNonDefaultScopes(scopes) {
155
+ if (!Array.isArray(scopes) || scopes.length === 0)
156
+ return false;
157
+ return !(scopes.length === 1 && scopes[0] === "ALL_SCOPES");
158
+ }
97
159
  function mapResolvedType(resolvedType, variableName, warnings) {
98
160
  switch (resolvedType) {
99
161
  case "COLOR":
@@ -109,6 +171,12 @@ function mapResolvedType(resolvedType, variableName, warnings) {
109
171
  return inferStringType(variableName);
110
172
  case "BOOLEAN":
111
173
  return "boolean";
174
+ case "TIMING":
175
+ // Config-2026 motion duration variables — plain numbers in SECONDS.
176
+ return "duration";
177
+ case "EASING":
178
+ // Config-2026 easing variables — bezier (or spring) curve objects.
179
+ return "cubicBezier";
112
180
  default: {
113
181
  const _exhaustive = resolvedType;
114
182
  warnings.push(`Unknown resolvedType "${_exhaustive}" for variable "${variableName}"; treating as string.`);
@@ -133,7 +201,8 @@ function inferStringType(variableName) {
133
201
  return "fontFamily";
134
202
  return "string";
135
203
  }
136
- function convertValue(rawValue, resolvedType, variableById, warnings) {
204
+ function convertValue(rawValue, variable, modeName, variableById, collectionNameById, opts, warnings, onSpring) {
205
+ const resolvedType = variable.resolvedType;
137
206
  // Alias references: convert variable ID → path-based reference for DTCG.
138
207
  if (isVariableAlias(rawValue)) {
139
208
  const target = variableById.get(rawValue.id);
@@ -146,14 +215,38 @@ function convertValue(rawValue, resolvedType, variableById, warnings) {
146
215
  warnings.push(`Alias to unknown variable ID ${rawValue.id} (likely a cross-library reference). Original ID preserved in reference for round-trip.`);
147
216
  return { reference: `{__library:${rawValue.id}}` };
148
217
  }
149
- // The DTCG alias path uses dots: "color.brand.primary".
150
- const dotPath = target.name.replace(/\//g, ".");
151
- return { reference: `{${dotPath}}` };
218
+ // The DTCG alias path uses dots, QUALIFIED by the target's set group
219
+ // (`{<set-slug>.color.brand.primary}`). The set qualifier does two
220
+ // jobs: (1) same-path tokens in different collections resolve to the
221
+ // right target instead of "whichever set was indexed last", and
222
+ // (2) the emitted DTCG reference points at the actual location in the
223
+ // output tree (tokens nest under the slugified set group), so
224
+ // external DTCG tools like Style Dictionary v4 can resolve it.
225
+ const targetPath = variableTokenPath(target, opts).path;
226
+ const dotPath = targetPath.join(".");
227
+ const targetCollectionName = collectionNameById.get(target.variableCollectionId);
228
+ if (!targetCollectionName) {
229
+ warnings.push(`Alias target "${target.name}" belongs to unknown collection ${target.variableCollectionId} — emitting an unqualified reference.`);
230
+ return { reference: `{${dotPath}}` };
231
+ }
232
+ return { reference: `{${slugifySetName(targetCollectionName)}.${dotPath}}` };
152
233
  }
153
234
  // Literal values per type.
154
235
  if (resolvedType === "COLOR") {
155
236
  if (typeof rawValue === "object" && rawValue !== null && "r" in rawValue) {
156
- return { literal: rgbaToHex(rawValue) };
237
+ // The hex string stays the literal (legacy dialect + back-compat), but
238
+ // we also carry the raw full-precision floats so the 2025.10 dialect
239
+ // can emit `components` without round-tripping through 8-bit hex.
240
+ // `rawColor` is transient — see TokenValue.rawColor in types.ts.
241
+ return {
242
+ literal: rgbaToHex(rawValue),
243
+ rawColor: {
244
+ r: rawValue.r,
245
+ g: rawValue.g,
246
+ b: rawValue.b,
247
+ a: rawValue.a ?? 1,
248
+ },
249
+ };
157
250
  }
158
251
  warnings.push(`COLOR value isn't an RGB object: ${JSON.stringify(rawValue)}`);
159
252
  return { literal: String(rawValue) };
@@ -164,6 +257,41 @@ function convertValue(rawValue, resolvedType, variableById, warnings) {
164
257
  if (resolvedType === "BOOLEAN") {
165
258
  return { literal: Boolean(rawValue) };
166
259
  }
260
+ if (resolvedType === "TIMING") {
261
+ // Figma TIMING values are plain numbers in SECONDS. DTCG duration uses
262
+ // the structured `{ value, unit }` form — emit milliseconds.
263
+ if (typeof rawValue === "number") {
264
+ return { literal: { value: rawValue * 1000, unit: "ms" } };
265
+ }
266
+ warnings.push(`TIMING value for "${variable.name}" (mode "${modeName}") isn't a number: ${JSON.stringify(rawValue)} — emitting as string.`);
267
+ return { literal: String(rawValue) };
268
+ }
269
+ if (resolvedType === "EASING") {
270
+ const easing = rawValue;
271
+ const b = easing?.bezierValues;
272
+ if (b &&
273
+ typeof b.p1x === "number" &&
274
+ typeof b.p1y === "number" &&
275
+ typeof b.p2x === "number" &&
276
+ typeof b.p2y === "number") {
277
+ // DTCG cubicBezier: [p1x, p1y, p2x, p2y].
278
+ return { literal: [b.p1x, b.p1y, b.p2x, b.p2y] };
279
+ }
280
+ if (easing && typeof easing === "object" && easing.springValues) {
281
+ // Spring easings have no bezier representation — DTCG cannot express
282
+ // springs. Emit a standard "ease" bezier as a usable approximation
283
+ // and preserve the spring parameters in the token's
284
+ // figma-console-mcp extensions for round-trip.
285
+ warnings.push(`EASING variable "${variable.name}" (mode "${modeName}") is a spring (${easing.easingType ?? "unknown type"}) — DTCG cannot represent springs. Emitted a fallback cubicBezier; spring parameters preserved in $extensions["figma-console-mcp"].spring.`);
286
+ onSpring({
287
+ easingType: easing.easingType,
288
+ springValues: easing.springValues,
289
+ });
290
+ return { literal: [0.25, 0.1, 0.25, 1.0] };
291
+ }
292
+ warnings.push(`EASING value for "${variable.name}" (mode "${modeName}") has no usable bezierValues or springValues: ${JSON.stringify(rawValue)} — emitting as string.`);
293
+ return { literal: String(rawValue) };
294
+ }
167
295
  // STRING and fallthrough.
168
296
  return { literal: typeof rawValue === "string" ? rawValue : String(rawValue) };
169
297
  }
@@ -18,9 +18,14 @@
18
18
  * - splitByMode emits one file per mode with just that mode's values.
19
19
  * - splitByCollection emits one file per set.
20
20
  */
21
+ import { buildTokenIndex, referenceTargetPath } from "../alias-resolver.js";
21
22
  export function formatCssVars(doc, opts) {
22
23
  const warnings = [];
23
24
  const files = [];
25
+ // Index over the WHOLE document (not per-file subsets) so set-qualified
26
+ // alias references ({set-slug.path}) resolve to their target token's own
27
+ // path when generating var(--...) names.
28
+ const tokenIndex = buildTokenIndex(doc, warnings);
24
29
  const splitByMode = opts.target.splitByMode ?? false;
25
30
  const splitByCollection = opts.target.splitByCollection ?? false;
26
31
  const prefix = opts.target.prefix ?? "";
@@ -30,7 +35,7 @@ export function formatCssVars(doc, opts) {
30
35
  for (const mode of set.modes) {
31
36
  files.push({
32
37
  path: filenameFor(opts, set, mode),
33
- content: renderSingleSelector(doc.sets.filter((s) => s.name === set.name), mode, selectorFor(mode), prefix, warnings),
38
+ content: renderSingleSelector(doc.sets.filter((s) => s.name === set.name), mode, selectorFor(mode), prefix, tokenIndex, warnings),
34
39
  });
35
40
  }
36
41
  }
@@ -45,7 +50,7 @@ export function formatCssVars(doc, opts) {
45
50
  const setsWithMode = doc.sets.filter((s) => s.modes.includes(mode));
46
51
  files.push({
47
52
  path: filenameFor(opts, undefined, mode),
48
- content: renderSingleSelector(setsWithMode, mode, selectorFor(mode), prefix, warnings),
53
+ content: renderSingleSelector(setsWithMode, mode, selectorFor(mode), prefix, tokenIndex, warnings),
49
54
  });
50
55
  }
51
56
  }
@@ -54,7 +59,7 @@ export function formatCssVars(doc, opts) {
54
59
  for (const set of doc.sets) {
55
60
  files.push({
56
61
  path: filenameFor(opts, set),
57
- content: renderMultiSelector([set], prefix, warnings),
62
+ content: renderMultiSelector([set], prefix, tokenIndex, warnings),
58
63
  });
59
64
  }
60
65
  }
@@ -62,7 +67,7 @@ export function formatCssVars(doc, opts) {
62
67
  // Single file with everything.
63
68
  files.push({
64
69
  path: filenameFor(opts),
65
- content: renderMultiSelector(doc.sets, prefix, warnings),
70
+ content: renderMultiSelector(doc.sets, prefix, tokenIndex, warnings),
66
71
  });
67
72
  }
68
73
  return { files, warnings };
@@ -107,7 +112,7 @@ function selectorFor(mode) {
107
112
  * Render all tokens across the given sets under a single selector. Used when
108
113
  * one file holds one mode's worth of vars (splitByMode output).
109
114
  */
110
- function renderSingleSelector(sets, mode, selector, prefix, warnings) {
115
+ function renderSingleSelector(sets, mode, selector, prefix, tokenIndex, warnings) {
111
116
  const lines = [];
112
117
  lines.push(`/* Generated by figma-console-mcp — do not edit by hand */`);
113
118
  lines.push(`${selector} {`);
@@ -116,7 +121,7 @@ function renderSingleSelector(sets, mode, selector, prefix, warnings) {
116
121
  const value = token.values[mode];
117
122
  if (!value)
118
123
  continue;
119
- emitTokenLines(token, value, prefix, lines, warnings);
124
+ emitTokenLines(token, value, prefix, tokenIndex, lines, warnings);
120
125
  }
121
126
  }
122
127
  lines.push(`}`);
@@ -127,7 +132,7 @@ function renderSingleSelector(sets, mode, selector, prefix, warnings) {
127
132
  * Render multiple sets across multiple modes in one file. Each mode gets its
128
133
  * own selector block.
129
134
  */
130
- function renderMultiSelector(sets, prefix, warnings) {
135
+ function renderMultiSelector(sets, prefix, tokenIndex, warnings) {
131
136
  const lines = [];
132
137
  lines.push(`/* Generated by figma-console-mcp — do not edit by hand */`);
133
138
  // Collect all (mode, selector) pairs across all sets.
@@ -148,7 +153,7 @@ function renderMultiSelector(sets, prefix, warnings) {
148
153
  const value = token.values[mode];
149
154
  if (!value)
150
155
  continue;
151
- emitTokenLines(token, value, prefix, lines, warnings);
156
+ emitTokenLines(token, value, prefix, tokenIndex, lines, warnings);
152
157
  }
153
158
  }
154
159
  lines.push(`}`);
@@ -161,7 +166,7 @@ function renderMultiSelector(sets, prefix, warnings) {
161
166
  * Primitives emit one line; composite tokens (typography, shadow) expand
162
167
  * into multiple lines.
163
168
  */
164
- function emitTokenLines(token, value, prefix, out, warnings) {
169
+ function emitTokenLines(token, value, prefix, tokenIndex, out, warnings) {
165
170
  // Every path segment must be a valid CSS identifier — slugify each to
166
171
  // normalize spaces, dots, and other special characters that show up in
167
172
  // real Figma variable names (e.g. "tailwind colors/purple/50").
@@ -180,9 +185,13 @@ function emitTokenLines(token, value, prefix, out, warnings) {
180
185
  out.push(` /* ${cssName}: skipped — cross-library alias to ${originalId} */`);
181
186
  return;
182
187
  }
183
- // Alias → var(--other) so CSS cascading semantics are preserved. The
184
- // target path goes through the same slugify treatment as the source.
185
- const refPath = bareRef.split(".");
188
+ // Alias → var(--other) so CSS cascading semantics are preserved.
189
+ // Resolve set-qualified references ({set-slug.path}) to the target
190
+ // token's own path via the index (the target's declaration is named
191
+ // from its path, without the set qualifier); bare/unresolvable refs
192
+ // fall back to the raw reference path. Either way the segments go
193
+ // through the same slugify treatment as the source.
194
+ const refPath = referenceTargetPath(value.reference, tokenIndex);
186
195
  const targetCssName = pathToCssName(refPath);
187
196
  out.push(` ${cssName}: var(--${prefix}${targetCssName});`);
188
197
  return;