@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
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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.
|
|
184
|
-
//
|
|
185
|
-
|
|
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;
|