@servicetitan/hammer-token 2.5.0 → 3.0.0
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/CHANGELOG.md +56 -0
- package/README.md +332 -0
- package/build/web/core/component-variables.scss +1088 -131
- package/build/web/core/component.d.ts +558 -0
- package/build/web/core/component.js +6685 -249
- package/build/web/core/component.scss +557 -69
- package/build/web/core/css-utils/a2-border.css +47 -45
- package/build/web/core/css-utils/a2-color.css +443 -227
- package/build/web/core/css-utils/a2-font.css +0 -2
- package/build/web/core/css-utils/a2-spacing.css +476 -478
- package/build/web/core/css-utils/a2-utils.css +992 -772
- package/build/web/core/css-utils/border.css +47 -45
- package/build/web/core/css-utils/color.css +443 -227
- package/build/web/core/css-utils/font.css +0 -2
- package/build/web/core/css-utils/spacing.css +476 -478
- package/build/web/core/css-utils/utils.css +992 -772
- package/build/web/core/index.d.ts +6 -0
- package/build/web/core/index.js +1 -1
- package/build/web/core/primitive-variables.scss +148 -65
- package/build/web/core/primitive.d.ts +209 -0
- package/build/web/core/primitive.js +779 -61
- package/build/web/core/primitive.scss +207 -124
- package/build/web/core/semantic-variables.scss +363 -239
- package/build/web/core/semantic.d.ts +221 -0
- package/build/web/core/semantic.js +1613 -347
- package/build/web/core/semantic.scss +219 -137
- package/build/web/index.d.ts +3 -4
- package/build/web/index.js +0 -1
- package/build/web/types.d.ts +17 -0
- package/config.js +121 -497
- package/eslint.config.mjs +11 -1
- package/package.json +15 -5
- package/src/global/primitive/breakpoint.tokens.json +54 -0
- package/src/global/primitive/color.tokens.json +1092 -0
- package/src/global/primitive/duration.tokens.json +44 -0
- package/src/global/primitive/font.tokens.json +151 -0
- package/src/global/primitive/radius.tokens.json +94 -0
- package/src/global/primitive/size.tokens.json +174 -0
- package/src/global/primitive/transition.tokens.json +32 -0
- package/src/theme/core/background.tokens.json +1312 -0
- package/src/theme/core/border.tokens.json +192 -0
- package/src/theme/core/chart.tokens.json +982 -0
- package/src/theme/core/component/ai-mark.tokens.json +20 -0
- package/src/theme/core/component/alert.tokens.json +261 -0
- package/src/theme/core/component/announcement.tokens.json +460 -0
- package/src/theme/core/component/avatar.tokens.json +137 -0
- package/src/theme/core/component/badge.tokens.json +42 -0
- package/src/theme/core/component/breadcrumb.tokens.json +42 -0
- package/src/theme/core/component/button-toggle.tokens.json +428 -0
- package/src/theme/core/component/button.tokens.json +941 -0
- package/src/theme/core/component/calendar.tokens.json +391 -0
- package/src/theme/core/component/card.tokens.json +107 -0
- package/src/theme/core/component/checkbox.tokens.json +631 -0
- package/src/theme/core/component/chip.tokens.json +169 -0
- package/src/theme/core/component/combobox.tokens.json +269 -0
- package/src/theme/core/component/details.tokens.json +152 -0
- package/src/theme/core/component/dialog.tokens.json +87 -0
- package/src/theme/core/component/divider.tokens.json +23 -0
- package/src/theme/core/component/dnd.tokens.json +208 -0
- package/src/theme/core/component/drawer.tokens.json +61 -0
- package/src/theme/core/component/drilldown.tokens.json +61 -0
- package/src/theme/core/component/edit-card.tokens.json +381 -0
- package/src/theme/core/component/field-label.tokens.json +42 -0
- package/src/theme/core/component/field-message.tokens.json +74 -0
- package/src/theme/core/component/icon.tokens.json +42 -0
- package/src/theme/core/component/link.tokens.json +108 -0
- package/src/theme/core/component/list-view.tokens.json +82 -0
- package/src/theme/core/component/listbox.tokens.json +283 -0
- package/src/theme/core/component/menu.tokens.json +230 -0
- package/src/theme/core/component/overflow.tokens.json +84 -0
- package/src/theme/core/component/page.tokens.json +377 -0
- package/src/theme/core/component/pagination.tokens.json +63 -0
- package/src/theme/core/component/popover.tokens.json +122 -0
- package/src/theme/core/component/progress-bar.tokens.json +133 -0
- package/src/theme/core/component/radio.tokens.json +631 -0
- package/src/theme/core/component/segmented-control.tokens.json +175 -0
- package/src/theme/core/component/select-card.tokens.json +943 -0
- package/src/theme/core/component/side-nav.tokens.json +349 -0
- package/src/theme/core/component/skeleton.tokens.json +42 -0
- package/src/theme/core/component/spinner.tokens.json +96 -0
- package/src/theme/core/component/status-icon.tokens.json +164 -0
- package/src/theme/core/component/stepper.tokens.json +484 -0
- package/src/theme/core/component/switch.tokens.json +285 -0
- package/src/theme/core/component/tab.tokens.json +192 -0
- package/src/theme/core/component/text-field.tokens.json +160 -0
- package/src/theme/core/component/text.tokens.json +59 -0
- package/src/theme/core/component/toast.tokens.json +343 -0
- package/src/theme/core/component/toolbar.tokens.json +114 -0
- package/src/theme/core/component/tooltip.tokens.json +61 -0
- package/src/theme/core/focus.tokens.json +56 -0
- package/src/theme/core/foreground.tokens.json +416 -0
- package/src/theme/core/gradient.tokens.json +41 -0
- package/src/theme/core/opacity.tokens.json +25 -0
- package/src/theme/core/shadow.tokens.json +81 -0
- package/src/theme/core/status.tokens.json +74 -0
- package/src/theme/core/typography.tokens.json +163 -0
- package/src/utils/__tests__/css-utils-format-utils.test.js +312 -0
- package/src/utils/__tests__/sd-build-configs.test.js +306 -0
- package/src/utils/__tests__/sd-formats.test.js +950 -0
- package/src/utils/__tests__/sd-transforms.test.js +336 -0
- package/src/utils/__tests__/token-helpers.test.js +1160 -0
- package/src/utils/copy-css-utils-cli.js +13 -1
- package/src/utils/css-utils-format-utils.js +105 -176
- package/src/utils/figma/__tests__/sync-gradient.test.js +561 -0
- package/src/utils/figma/__tests__/token-conversion.test.js +117 -0
- package/src/utils/figma/__tests__/token-resolution.test.js +231 -0
- package/src/utils/figma/auth.js +355 -0
- package/src/utils/figma/constants.js +22 -0
- package/src/utils/figma/errors.js +80 -0
- package/src/utils/figma/figma-api.js +1069 -0
- package/src/utils/figma/get-token.js +348 -0
- package/src/utils/figma/sync-components.js +909 -0
- package/src/utils/figma/sync-main.js +692 -0
- package/src/utils/figma/sync-orchestration.js +683 -0
- package/src/utils/figma/sync-primitives.js +230 -0
- package/src/utils/figma/sync-semantic.js +1056 -0
- package/src/utils/figma/token-conversion.js +340 -0
- package/src/utils/figma/token-parsing.js +186 -0
- package/src/utils/figma/token-resolution.js +569 -0
- package/src/utils/figma/utils.js +199 -0
- package/src/utils/sd-build-configs.js +305 -0
- package/src/utils/sd-formats.js +965 -0
- package/src/utils/sd-transforms.js +165 -0
- package/src/utils/token-helpers.js +848 -0
- package/tsconfig.json +18 -0
- package/vitest.config.js +17 -0
- package/.turbo/turbo-build.log +0 -37
- package/build/web/core/raw.js +0 -229
- package/src/global/primitive/breakpoint.js +0 -19
- package/src/global/primitive/color.js +0 -231
- package/src/global/primitive/duration.js +0 -16
- package/src/global/primitive/font.js +0 -60
- package/src/global/primitive/radius.js +0 -31
- package/src/global/primitive/size.js +0 -55
- package/src/global/primitive/transition.js +0 -16
- package/src/theme/core/background.js +0 -170
- package/src/theme/core/border.js +0 -103
- package/src/theme/core/charts.js +0 -439
- package/src/theme/core/component/button.js +0 -708
- package/src/theme/core/component/checkbox.js +0 -405
- package/src/theme/core/focus.js +0 -35
- package/src/theme/core/foreground.js +0 -148
- package/src/theme/core/overlay.js +0 -137
- package/src/theme/core/shadow.js +0 -29
- package/src/theme/core/status.js +0 -49
- package/src/theme/core/typography.js +0 -82
- package/type/types.ts +0 -341
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Semantic Token Sync
|
|
5
|
+
*
|
|
6
|
+
* Functions for building variable requests for semantic (non-component) tokens.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { isComponentToken } = require("./token-parsing");
|
|
10
|
+
const {
|
|
11
|
+
isReference,
|
|
12
|
+
isCompositeColorWithReference,
|
|
13
|
+
extractReferencePath,
|
|
14
|
+
resolveMultipleReferences,
|
|
15
|
+
resolveReferencePath,
|
|
16
|
+
topologicalSortSemanticTokens,
|
|
17
|
+
getTokenDescription,
|
|
18
|
+
buildReferenceChain,
|
|
19
|
+
} = require("./token-resolution");
|
|
20
|
+
const {
|
|
21
|
+
convertColorValue,
|
|
22
|
+
convertTokenValueToFigma,
|
|
23
|
+
getFigmaVariableType,
|
|
24
|
+
isShadowSizeToken,
|
|
25
|
+
parseShadowSizeValue,
|
|
26
|
+
isGradientToken,
|
|
27
|
+
parseGradientStops,
|
|
28
|
+
} = require("./token-conversion");
|
|
29
|
+
const {
|
|
30
|
+
valuesEqual,
|
|
31
|
+
getVariableIdByName,
|
|
32
|
+
determineVariableName: determineVariableNameHelper,
|
|
33
|
+
} = require("./figma-api");
|
|
34
|
+
/**
|
|
35
|
+
* Get Figma variable scopes from token $extensions["com.figma.scopes"]. Missing or empty = ["ALL_SCOPES"].
|
|
36
|
+
* @param {Object} [token] - Leaf token object
|
|
37
|
+
* @returns {string[]} Scopes array
|
|
38
|
+
*/
|
|
39
|
+
function getScopesForVariable(token) {
|
|
40
|
+
const fromToken = token?.$extensions?.["com.figma.scopes"];
|
|
41
|
+
return Array.isArray(fromToken) && fromToken.length > 0
|
|
42
|
+
? fromToken
|
|
43
|
+
: ["ALL_SCOPES"];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Determine the variable name for a token path (wrapper that includes isComponentToken)
|
|
48
|
+
* @param {string} targetPath - Token path (e.g., "color/neutral/0")
|
|
49
|
+
* @param {Map} resolvedTokensForRefs - Map of resolved tokens for reference resolution
|
|
50
|
+
* @param {Array} existingVariables - Array of existing Figma variables
|
|
51
|
+
* @param {Map} tempIdMap - Temporary ID map for tracking created variables
|
|
52
|
+
* @returns {string} Variable name with correct prefix (primitive/, semantic/, or component/)
|
|
53
|
+
*/
|
|
54
|
+
function determineVariableName(
|
|
55
|
+
targetPath,
|
|
56
|
+
resolvedTokensForRefs,
|
|
57
|
+
existingVariables,
|
|
58
|
+
tempIdMap,
|
|
59
|
+
) {
|
|
60
|
+
return determineVariableNameHelper(
|
|
61
|
+
targetPath,
|
|
62
|
+
resolvedTokensForRefs,
|
|
63
|
+
existingVariables,
|
|
64
|
+
tempIdMap,
|
|
65
|
+
isComponentToken,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build bulk request for semantic variable changes only
|
|
71
|
+
* Returns an object with variables array and variableModeValues array
|
|
72
|
+
* @param {Map} tokensToSync - Map of tokens to sync (may be filtered for extended themes)
|
|
73
|
+
* @param {Array} existingVariables - Array of existing variables
|
|
74
|
+
* @param {string} collectionId - Collection ID
|
|
75
|
+
* @param {Object} modes - Modes object with light and dark
|
|
76
|
+
* @param {Map} tempIdMap - Temporary ID map for tracking created variables
|
|
77
|
+
* @param {Map} [allResolvedTokens] - Optional full map of all resolved tokens for reference resolution
|
|
78
|
+
* @returns {Object} Object with variableChanges, variableModeValues, stats, errors, and tempIdMap
|
|
79
|
+
*/
|
|
80
|
+
function buildSemanticVariableRequest(
|
|
81
|
+
tokensToSync,
|
|
82
|
+
existingVariables,
|
|
83
|
+
collectionId,
|
|
84
|
+
modes,
|
|
85
|
+
tempIdMap = new Map(),
|
|
86
|
+
allResolvedTokens = null,
|
|
87
|
+
) {
|
|
88
|
+
const variableChanges = [];
|
|
89
|
+
const variableModeValues = [];
|
|
90
|
+
const stats = { created: 0, updated: 0, skipped: 0, deleted: 0, errors: 0 };
|
|
91
|
+
const errors = [];
|
|
92
|
+
const keptSemanticNames = new Set();
|
|
93
|
+
|
|
94
|
+
// Use allResolvedTokens for reference resolution if provided, otherwise use tokensToSync
|
|
95
|
+
const resolvedTokensForRefs = allResolvedTokens || tokensToSync;
|
|
96
|
+
|
|
97
|
+
// Filter to only semantic tokens (exclude primitives and component tokens)
|
|
98
|
+
const semanticTokens = [];
|
|
99
|
+
for (const [tokenPath, tokenData] of tokensToSync.entries()) {
|
|
100
|
+
if (!tokenData.isPrimitive && !isComponentToken(tokenData.sourceFilePath)) {
|
|
101
|
+
semanticTokens.push([tokenPath, tokenData]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Topologically sort semantic tokens to process dependencies first
|
|
106
|
+
const sortedSemanticTokens = topologicalSortSemanticTokens(
|
|
107
|
+
semanticTokens,
|
|
108
|
+
resolvedTokensForRefs,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Validate modes before processing
|
|
112
|
+
if (!modes?.light || !modes?.dark) {
|
|
113
|
+
errors.push(
|
|
114
|
+
`Modes are missing: light=${!!modes?.light}, dark=${!!modes?.dark}`,
|
|
115
|
+
);
|
|
116
|
+
stats.errors++;
|
|
117
|
+
}
|
|
118
|
+
if (!modes?.light?.id || !modes?.dark?.id) {
|
|
119
|
+
errors.push(
|
|
120
|
+
`Mode IDs are missing: light.id=${modes?.light?.id}, dark.id=${modes?.dark?.id}`,
|
|
121
|
+
);
|
|
122
|
+
stats.errors++;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Process semantic tokens in dependency order
|
|
126
|
+
for (const [tokenPath, tokenData] of sortedSemanticTokens) {
|
|
127
|
+
try {
|
|
128
|
+
const { token, resolvedLight, resolvedDark } = tokenData;
|
|
129
|
+
const tokenType = token.$type || "string";
|
|
130
|
+
|
|
131
|
+
// Add "semantic/" prefix to variable name for organization
|
|
132
|
+
const variableName = `semantic/${tokenPath}`;
|
|
133
|
+
|
|
134
|
+
// Track kept variable names for delete detection (remove vars no longer in token source)
|
|
135
|
+
if (
|
|
136
|
+
isShadowSizeToken(tokenPath) &&
|
|
137
|
+
typeof resolvedLight === "string" &&
|
|
138
|
+
typeof resolvedDark === "string"
|
|
139
|
+
) {
|
|
140
|
+
const lightComponents = parseShadowSizeValue(resolvedLight);
|
|
141
|
+
const darkComponents = parseShadowSizeValue(resolvedDark);
|
|
142
|
+
if (lightComponents && darkComponents) {
|
|
143
|
+
keptSemanticNames.add(`${variableName}/x`);
|
|
144
|
+
keptSemanticNames.add(`${variableName}/y`);
|
|
145
|
+
keptSemanticNames.add(`${variableName}/blur`);
|
|
146
|
+
} else {
|
|
147
|
+
keptSemanticNames.add(variableName);
|
|
148
|
+
}
|
|
149
|
+
} else if (isGradientToken(tokenType)) {
|
|
150
|
+
const gradientValue =
|
|
151
|
+
typeof token.$value === "object" ? token.$value : null;
|
|
152
|
+
const stops = parseGradientStops(gradientValue);
|
|
153
|
+
if (stops) {
|
|
154
|
+
for (let i = 0; i < stops.length; i++) {
|
|
155
|
+
keptSemanticNames.add(`${variableName}/stop-${i}`);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
keptSemanticNames.add(variableName);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
keptSemanticNames.add(variableName);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Special handling for shadow size tokens: split into x, y, blur variables
|
|
165
|
+
if (
|
|
166
|
+
isShadowSizeToken(tokenPath) &&
|
|
167
|
+
typeof resolvedLight === "string" &&
|
|
168
|
+
typeof resolvedDark === "string"
|
|
169
|
+
) {
|
|
170
|
+
const lightComponents = parseShadowSizeValue(resolvedLight);
|
|
171
|
+
const darkComponents = parseShadowSizeValue(resolvedDark);
|
|
172
|
+
|
|
173
|
+
if (lightComponents && darkComponents) {
|
|
174
|
+
// Create three separate variables: x, y, blur
|
|
175
|
+
const components = [
|
|
176
|
+
{
|
|
177
|
+
suffix: "x",
|
|
178
|
+
lightRef: lightComponents.x,
|
|
179
|
+
darkRef: darkComponents.x,
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
suffix: "y",
|
|
183
|
+
lightRef: lightComponents.y,
|
|
184
|
+
darkRef: darkComponents.y,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
suffix: "blur",
|
|
188
|
+
lightRef: lightComponents.blur,
|
|
189
|
+
darkRef: darkComponents.blur,
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
for (const component of components) {
|
|
194
|
+
const componentVariableName = `${variableName}/${component.suffix}`;
|
|
195
|
+
const componentTokenPath = `${tokenPath}/${component.suffix}`;
|
|
196
|
+
|
|
197
|
+
// Per-component description: follow the component's own ref to its resolved value
|
|
198
|
+
// component.lightRef is a dot-path (e.g. "size.0"), wrap in braces for buildReferenceChain
|
|
199
|
+
const componentChain = buildReferenceChain(
|
|
200
|
+
`{${component.lightRef}}`,
|
|
201
|
+
resolvedTokensForRefs,
|
|
202
|
+
);
|
|
203
|
+
const baseDesc = token.$description || "";
|
|
204
|
+
const componentDescription =
|
|
205
|
+
baseDesc && componentChain
|
|
206
|
+
? `${baseDesc}\n${componentChain}`
|
|
207
|
+
: componentChain || baseDesc;
|
|
208
|
+
|
|
209
|
+
// Resolve light reference
|
|
210
|
+
const lightTargetPath = resolveReferencePath(
|
|
211
|
+
component.lightRef,
|
|
212
|
+
resolvedTokensForRefs,
|
|
213
|
+
);
|
|
214
|
+
const lightTargetTokenData =
|
|
215
|
+
resolvedTokensForRefs.get(lightTargetPath);
|
|
216
|
+
if (!lightTargetTokenData) {
|
|
217
|
+
const errorMsg = `Could not find light target token ${lightTargetPath} for ${componentTokenPath}`;
|
|
218
|
+
errors.push(errorMsg);
|
|
219
|
+
stats.errors++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const lightTargetVariableName = determineVariableName(
|
|
223
|
+
lightTargetPath,
|
|
224
|
+
resolvedTokensForRefs,
|
|
225
|
+
existingVariables,
|
|
226
|
+
tempIdMap,
|
|
227
|
+
);
|
|
228
|
+
const lightAliasId =
|
|
229
|
+
tempIdMap.get(lightTargetPath) ||
|
|
230
|
+
tempIdMap.get(lightTargetVariableName) ||
|
|
231
|
+
getVariableIdByName(lightTargetVariableName, existingVariables) ||
|
|
232
|
+
getVariableIdByName(lightTargetPath, existingVariables);
|
|
233
|
+
|
|
234
|
+
if (!lightAliasId) {
|
|
235
|
+
const errorMsg = `Could not resolve light alias for ${componentTokenPath} -> ${lightTargetPath}`;
|
|
236
|
+
errors.push(errorMsg);
|
|
237
|
+
stats.errors++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Resolve dark reference
|
|
242
|
+
const darkTargetPath = resolveReferencePath(
|
|
243
|
+
component.darkRef,
|
|
244
|
+
resolvedTokensForRefs,
|
|
245
|
+
);
|
|
246
|
+
const darkTargetTokenData =
|
|
247
|
+
resolvedTokensForRefs.get(darkTargetPath);
|
|
248
|
+
if (!darkTargetTokenData) {
|
|
249
|
+
errors.push(
|
|
250
|
+
`Could not find dark target token ${darkTargetPath} for ${componentTokenPath}`,
|
|
251
|
+
);
|
|
252
|
+
stats.errors++;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const darkTargetVariableName = determineVariableName(
|
|
256
|
+
darkTargetPath,
|
|
257
|
+
resolvedTokensForRefs,
|
|
258
|
+
existingVariables,
|
|
259
|
+
tempIdMap,
|
|
260
|
+
);
|
|
261
|
+
const darkAliasId =
|
|
262
|
+
tempIdMap.get(darkTargetPath) ||
|
|
263
|
+
tempIdMap.get(darkTargetVariableName) ||
|
|
264
|
+
getVariableIdByName(darkTargetVariableName, existingVariables) ||
|
|
265
|
+
getVariableIdByName(darkTargetPath, existingVariables);
|
|
266
|
+
|
|
267
|
+
if (!darkAliasId) {
|
|
268
|
+
errors.push(
|
|
269
|
+
`Could not resolve dark alias for ${componentTokenPath} -> ${darkTargetPath}`,
|
|
270
|
+
);
|
|
271
|
+
stats.errors++;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Check if variable already exists - optimize with getVariableIdByName
|
|
276
|
+
const existingId = getVariableIdByName(
|
|
277
|
+
componentVariableName,
|
|
278
|
+
existingVariables,
|
|
279
|
+
);
|
|
280
|
+
// Optimize: Use Map for O(1) lookup if we have many variables
|
|
281
|
+
let existing = null;
|
|
282
|
+
if (existingId) {
|
|
283
|
+
if (existingVariables.length > 100) {
|
|
284
|
+
const variableIdMap = new Map();
|
|
285
|
+
for (const v of existingVariables) {
|
|
286
|
+
if (v.id) variableIdMap.set(v.id, v);
|
|
287
|
+
}
|
|
288
|
+
existing = variableIdMap.get(existingId) || null;
|
|
289
|
+
} else {
|
|
290
|
+
existing = existingVariables.find((v) => v.id === existingId);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (existing) {
|
|
295
|
+
// Update existing variable
|
|
296
|
+
if (!existing.id) {
|
|
297
|
+
errors.push(
|
|
298
|
+
`Existing variable ${componentVariableName} has no ID`,
|
|
299
|
+
);
|
|
300
|
+
stats.errors++;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const lightFigmaValue = {
|
|
305
|
+
type: "VARIABLE_ALIAS",
|
|
306
|
+
id: lightAliasId,
|
|
307
|
+
};
|
|
308
|
+
const darkFigmaValue = {
|
|
309
|
+
type: "VARIABLE_ALIAS",
|
|
310
|
+
id: darkAliasId,
|
|
311
|
+
};
|
|
312
|
+
const existingValues = existing.valuesByMode || {};
|
|
313
|
+
const existingLight = existingValues[modes.light.id];
|
|
314
|
+
const existingDark = existingValues[modes.dark.id];
|
|
315
|
+
const lightSame = valuesEqual(existingLight, lightFigmaValue);
|
|
316
|
+
const darkSame = valuesEqual(existingDark, darkFigmaValue);
|
|
317
|
+
|
|
318
|
+
const componentScopes = getScopesForVariable(tokenData.token);
|
|
319
|
+
const sortedUpdate = [...componentScopes].sort();
|
|
320
|
+
const sortedExisting = [...(existing.scopes ?? [])].sort();
|
|
321
|
+
const scopesEqual =
|
|
322
|
+
sortedUpdate.length === sortedExisting.length &&
|
|
323
|
+
sortedUpdate.every((s, i) => s === sortedExisting[i]);
|
|
324
|
+
|
|
325
|
+
const shadowDescriptionSame =
|
|
326
|
+
(existing.description || "") === componentDescription;
|
|
327
|
+
|
|
328
|
+
if (
|
|
329
|
+
lightSame &&
|
|
330
|
+
darkSame &&
|
|
331
|
+
(scopesEqual || componentScopes.length === 0) &&
|
|
332
|
+
shadowDescriptionSame
|
|
333
|
+
) {
|
|
334
|
+
tempIdMap.set(componentTokenPath, existing.id);
|
|
335
|
+
tempIdMap.set(componentVariableName, existing.id);
|
|
336
|
+
stats.skipped++;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const updatePayload = {
|
|
341
|
+
action: "UPDATE",
|
|
342
|
+
id: existing.id,
|
|
343
|
+
name: componentVariableName,
|
|
344
|
+
scopes: componentScopes,
|
|
345
|
+
description: componentDescription,
|
|
346
|
+
};
|
|
347
|
+
variableChanges.push(updatePayload);
|
|
348
|
+
|
|
349
|
+
variableModeValues.push(
|
|
350
|
+
{
|
|
351
|
+
variableId: existing.id,
|
|
352
|
+
modeId: modes.light.id,
|
|
353
|
+
value: lightFigmaValue,
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
variableId: existing.id,
|
|
357
|
+
modeId: modes.dark.id,
|
|
358
|
+
value: darkFigmaValue,
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
tempIdMap.set(componentTokenPath, existing.id);
|
|
363
|
+
tempIdMap.set(componentVariableName, existing.id);
|
|
364
|
+
stats.updated++;
|
|
365
|
+
} else {
|
|
366
|
+
// Create new variable
|
|
367
|
+
const tempIdCounter = tempIdMap.size + variableChanges.length + 1;
|
|
368
|
+
const tempId = `temp_var_${tempIdCounter}_${componentVariableName.replace(/\//g, "_").replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
369
|
+
tempIdMap.set(componentTokenPath, tempId);
|
|
370
|
+
tempIdMap.set(componentVariableName, tempId);
|
|
371
|
+
|
|
372
|
+
const componentScopes = getScopesForVariable(tokenData.token);
|
|
373
|
+
const createPayload = {
|
|
374
|
+
action: "CREATE",
|
|
375
|
+
id: tempId,
|
|
376
|
+
name: componentVariableName,
|
|
377
|
+
variableCollectionId: collectionId,
|
|
378
|
+
resolvedType: "FLOAT", // Shadow size components are always FLOAT (dimension values)
|
|
379
|
+
description: componentDescription,
|
|
380
|
+
};
|
|
381
|
+
createPayload.scopes = componentScopes;
|
|
382
|
+
variableChanges.push(createPayload);
|
|
383
|
+
|
|
384
|
+
variableModeValues.push(
|
|
385
|
+
{
|
|
386
|
+
variableId: tempId,
|
|
387
|
+
modeId: modes.light.id,
|
|
388
|
+
value: { type: "VARIABLE_ALIAS", id: lightAliasId },
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
variableId: tempId,
|
|
392
|
+
modeId: modes.dark.id,
|
|
393
|
+
value: { type: "VARIABLE_ALIAS", id: darkAliasId },
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
stats.created++;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Skip normal processing for shadow size tokens (we've already handled them)
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Special handling for gradient tokens: unpack each stop into a COLOR variable
|
|
407
|
+
// Figma does not support a gradient variable type; stops are individual COLOR variables.
|
|
408
|
+
if (isGradientToken(tokenType)) {
|
|
409
|
+
const gradientValue =
|
|
410
|
+
typeof token.$value === "object" ? token.$value : null;
|
|
411
|
+
const stops = parseGradientStops(gradientValue);
|
|
412
|
+
|
|
413
|
+
if (!stops) {
|
|
414
|
+
errors.push(`Could not parse gradient stops for ${tokenPath}`);
|
|
415
|
+
stats.errors++;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const gradientDescription = getTokenDescription(
|
|
420
|
+
token,
|
|
421
|
+
resolvedTokensForRefs,
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
for (let i = 0; i < stops.length; i++) {
|
|
425
|
+
const { lightRef, darkRef } = stops[i];
|
|
426
|
+
const stopVariableName = `${variableName}/stop-${i}`;
|
|
427
|
+
const stopTokenPath = `${tokenPath}/stop-${i}`;
|
|
428
|
+
const lightChain =
|
|
429
|
+
buildReferenceChain(lightRef, resolvedTokensForRefs) || lightRef;
|
|
430
|
+
const darkChain =
|
|
431
|
+
buildReferenceChain(darkRef, resolvedTokensForRefs) || darkRef;
|
|
432
|
+
const stopColorDetail =
|
|
433
|
+
lightRef === darkRef
|
|
434
|
+
? lightChain
|
|
435
|
+
: `light: ${lightChain}, dark: ${darkChain}`;
|
|
436
|
+
const rawPosition = gradientValue.stops[i]?.position;
|
|
437
|
+
const stopPosition =
|
|
438
|
+
typeof rawPosition === "number"
|
|
439
|
+
? `${rawPosition * 100}%`
|
|
440
|
+
: String(rawPosition ?? i);
|
|
441
|
+
const stopDescription = gradientDescription
|
|
442
|
+
? `${gradientDescription}\nstop ${i} at ${stopPosition}: ${stopColorDetail}`
|
|
443
|
+
: `stop ${i} at ${stopPosition}: ${stopColorDetail}`;
|
|
444
|
+
|
|
445
|
+
// Resolve light color reference to a primitive variable alias
|
|
446
|
+
// lightRef may be a reference string like "{color.blue.600}" — extract path first
|
|
447
|
+
const lightRefPath = extractReferencePath(lightRef) ?? lightRef;
|
|
448
|
+
const lightTargetPath = resolveReferencePath(
|
|
449
|
+
lightRefPath,
|
|
450
|
+
resolvedTokensForRefs,
|
|
451
|
+
);
|
|
452
|
+
const lightTargetVariableName = determineVariableName(
|
|
453
|
+
lightTargetPath,
|
|
454
|
+
resolvedTokensForRefs,
|
|
455
|
+
existingVariables,
|
|
456
|
+
tempIdMap,
|
|
457
|
+
);
|
|
458
|
+
const lightAliasId =
|
|
459
|
+
tempIdMap.get(lightTargetPath) ||
|
|
460
|
+
tempIdMap.get(lightTargetVariableName) ||
|
|
461
|
+
getVariableIdByName(lightTargetVariableName, existingVariables) ||
|
|
462
|
+
getVariableIdByName(lightTargetPath, existingVariables);
|
|
463
|
+
|
|
464
|
+
if (!lightAliasId) {
|
|
465
|
+
errors.push(
|
|
466
|
+
`Could not resolve light alias for ${stopTokenPath} -> ${lightTargetPath}`,
|
|
467
|
+
);
|
|
468
|
+
stats.errors++;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Resolve dark color reference to a primitive variable alias
|
|
473
|
+
// darkRef may be a reference string like "{color.cyan.300}" — extract path first
|
|
474
|
+
const darkRefPath = extractReferencePath(darkRef) ?? darkRef;
|
|
475
|
+
const darkTargetPath = resolveReferencePath(
|
|
476
|
+
darkRefPath,
|
|
477
|
+
resolvedTokensForRefs,
|
|
478
|
+
);
|
|
479
|
+
const darkTargetVariableName = determineVariableName(
|
|
480
|
+
darkTargetPath,
|
|
481
|
+
resolvedTokensForRefs,
|
|
482
|
+
existingVariables,
|
|
483
|
+
tempIdMap,
|
|
484
|
+
);
|
|
485
|
+
const darkAliasId =
|
|
486
|
+
tempIdMap.get(darkTargetPath) ||
|
|
487
|
+
tempIdMap.get(darkTargetVariableName) ||
|
|
488
|
+
getVariableIdByName(darkTargetVariableName, existingVariables) ||
|
|
489
|
+
getVariableIdByName(darkTargetPath, existingVariables);
|
|
490
|
+
|
|
491
|
+
if (!darkAliasId) {
|
|
492
|
+
errors.push(
|
|
493
|
+
`Could not resolve dark alias for ${stopTokenPath} -> ${darkTargetPath}`,
|
|
494
|
+
);
|
|
495
|
+
stats.errors++;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const lightFigmaValue = { type: "VARIABLE_ALIAS", id: lightAliasId };
|
|
500
|
+
const darkFigmaValue = { type: "VARIABLE_ALIAS", id: darkAliasId };
|
|
501
|
+
|
|
502
|
+
// Get scopes from the stop color's extensions
|
|
503
|
+
const stopColor = gradientValue.stops[i]?.color;
|
|
504
|
+
const stopScopes =
|
|
505
|
+
Array.isArray(stopColor?.$extensions?.["com.figma.scopes"]) &&
|
|
506
|
+
stopColor.$extensions["com.figma.scopes"].length > 0
|
|
507
|
+
? stopColor.$extensions["com.figma.scopes"]
|
|
508
|
+
: ["ALL_SCOPES"];
|
|
509
|
+
|
|
510
|
+
const existingId = getVariableIdByName(
|
|
511
|
+
stopVariableName,
|
|
512
|
+
existingVariables,
|
|
513
|
+
);
|
|
514
|
+
let existing = null;
|
|
515
|
+
if (existingId) {
|
|
516
|
+
existing =
|
|
517
|
+
existingVariables.length > 100
|
|
518
|
+
? (() => {
|
|
519
|
+
const m = new Map(
|
|
520
|
+
existingVariables
|
|
521
|
+
.filter((v) => v.id)
|
|
522
|
+
.map((v) => [v.id, v]),
|
|
523
|
+
);
|
|
524
|
+
return m.get(existingId) || null;
|
|
525
|
+
})()
|
|
526
|
+
: existingVariables.find((v) => v.id === existingId) || null;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (existing) {
|
|
530
|
+
const existingValues = existing.valuesByMode || {};
|
|
531
|
+
const lightSame = valuesEqual(
|
|
532
|
+
existingValues[modes.light.id],
|
|
533
|
+
lightFigmaValue,
|
|
534
|
+
);
|
|
535
|
+
const darkSame = valuesEqual(
|
|
536
|
+
existingValues[modes.dark.id],
|
|
537
|
+
darkFigmaValue,
|
|
538
|
+
);
|
|
539
|
+
const sortedUpdate = [...stopScopes].sort();
|
|
540
|
+
const sortedExisting = [...(existing.scopes ?? [])].sort();
|
|
541
|
+
const scopesEqual =
|
|
542
|
+
sortedUpdate.length === sortedExisting.length &&
|
|
543
|
+
sortedUpdate.every((s, idx) => s === sortedExisting[idx]);
|
|
544
|
+
|
|
545
|
+
const stopDescriptionSame =
|
|
546
|
+
(existing.description || "") === stopDescription;
|
|
547
|
+
if (lightSame && darkSame && scopesEqual && stopDescriptionSame) {
|
|
548
|
+
tempIdMap.set(stopTokenPath, existing.id);
|
|
549
|
+
tempIdMap.set(stopVariableName, existing.id);
|
|
550
|
+
stats.skipped++;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
variableChanges.push({
|
|
555
|
+
action: "UPDATE",
|
|
556
|
+
id: existing.id,
|
|
557
|
+
name: stopVariableName,
|
|
558
|
+
scopes: stopScopes,
|
|
559
|
+
description: stopDescription,
|
|
560
|
+
});
|
|
561
|
+
variableModeValues.push(
|
|
562
|
+
{
|
|
563
|
+
variableId: existing.id,
|
|
564
|
+
modeId: modes.light.id,
|
|
565
|
+
value: lightFigmaValue,
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
variableId: existing.id,
|
|
569
|
+
modeId: modes.dark.id,
|
|
570
|
+
value: darkFigmaValue,
|
|
571
|
+
},
|
|
572
|
+
);
|
|
573
|
+
tempIdMap.set(stopTokenPath, existing.id);
|
|
574
|
+
tempIdMap.set(stopVariableName, existing.id);
|
|
575
|
+
stats.updated++;
|
|
576
|
+
} else {
|
|
577
|
+
const tempIdCounter = tempIdMap.size + variableChanges.length + 1;
|
|
578
|
+
const tempId = `temp_var_${tempIdCounter}_${stopVariableName.replace(/\//g, "_").replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
579
|
+
tempIdMap.set(stopTokenPath, tempId);
|
|
580
|
+
tempIdMap.set(stopVariableName, tempId);
|
|
581
|
+
|
|
582
|
+
variableChanges.push({
|
|
583
|
+
action: "CREATE",
|
|
584
|
+
id: tempId,
|
|
585
|
+
name: stopVariableName,
|
|
586
|
+
variableCollectionId: collectionId,
|
|
587
|
+
resolvedType: "COLOR",
|
|
588
|
+
scopes: stopScopes,
|
|
589
|
+
description: stopDescription,
|
|
590
|
+
});
|
|
591
|
+
variableModeValues.push(
|
|
592
|
+
{
|
|
593
|
+
variableId: tempId,
|
|
594
|
+
modeId: modes.light.id,
|
|
595
|
+
value: lightFigmaValue,
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
variableId: tempId,
|
|
599
|
+
modeId: modes.dark.id,
|
|
600
|
+
value: darkFigmaValue,
|
|
601
|
+
},
|
|
602
|
+
);
|
|
603
|
+
stats.created++;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Skip normal processing — gradient token itself has no Figma variable
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Check if this should be an alias (only for direct string references, not composite colors)
|
|
612
|
+
// For composite colors with references, we need to resolve the color value and apply alpha
|
|
613
|
+
const isLightReference = isReference(resolvedLight);
|
|
614
|
+
const isDarkReference = isReference(resolvedDark);
|
|
615
|
+
const isLightCompositeWithRef =
|
|
616
|
+
isCompositeColorWithReference(resolvedLight);
|
|
617
|
+
const isDarkCompositeWithRef =
|
|
618
|
+
isCompositeColorWithReference(resolvedDark);
|
|
619
|
+
|
|
620
|
+
let lightFigmaValue;
|
|
621
|
+
let darkFigmaValue;
|
|
622
|
+
let lightAliasId = null;
|
|
623
|
+
let darkAliasId = null;
|
|
624
|
+
|
|
625
|
+
// Handle composite colors with references - resolve the color and apply alpha
|
|
626
|
+
if (isLightCompositeWithRef) {
|
|
627
|
+
// Resolve the color reference to its actual value
|
|
628
|
+
const refString = resolvedLight.color;
|
|
629
|
+
const refPath = extractReferencePath(refString);
|
|
630
|
+
if (!refPath) {
|
|
631
|
+
errors.push(
|
|
632
|
+
`Could not extract reference path from ${refString} for ${tokenPath}`,
|
|
633
|
+
);
|
|
634
|
+
stats.errors++;
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
|
|
639
|
+
const targetTokenData = resolvedTokensForRefs.get(targetPath);
|
|
640
|
+
if (!targetTokenData) {
|
|
641
|
+
errors.push(
|
|
642
|
+
`Could not find target token ${targetPath} for ${tokenPath}`,
|
|
643
|
+
);
|
|
644
|
+
stats.errors++;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Get the resolved color value (use light value for light mode)
|
|
649
|
+
const resolvedColorValue = targetTokenData.resolvedLight;
|
|
650
|
+
if (!resolvedColorValue) {
|
|
651
|
+
errors.push(
|
|
652
|
+
`Target token ${targetPath} has no resolved light value for ${tokenPath}`,
|
|
653
|
+
);
|
|
654
|
+
stats.errors++;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Convert the resolved color to Figma format and apply alpha
|
|
659
|
+
const baseColor = convertColorValue(resolvedColorValue);
|
|
660
|
+
if (baseColor === null) {
|
|
661
|
+
errors.push(
|
|
662
|
+
`Could not convert resolved color value for ${tokenPath}: ${JSON.stringify(resolvedColorValue)}`,
|
|
663
|
+
);
|
|
664
|
+
stats.errors++;
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Apply the alpha from the composite color
|
|
669
|
+
lightFigmaValue = {
|
|
670
|
+
...baseColor,
|
|
671
|
+
a: resolvedLight.alpha ?? resolvedLight.a ?? baseColor.a,
|
|
672
|
+
};
|
|
673
|
+
} else if (isLightReference) {
|
|
674
|
+
// Direct string reference - create alias
|
|
675
|
+
const refPath = extractReferencePath(resolvedLight);
|
|
676
|
+
if (!refPath) {
|
|
677
|
+
errors.push(
|
|
678
|
+
`Could not extract reference path from ${resolvedLight} for ${tokenPath}`,
|
|
679
|
+
);
|
|
680
|
+
stats.errors++;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
|
|
685
|
+
// Determine the correct variable name based on token type
|
|
686
|
+
const targetVariableName = determineVariableName(
|
|
687
|
+
targetPath,
|
|
688
|
+
resolvedTokensForRefs,
|
|
689
|
+
existingVariables,
|
|
690
|
+
tempIdMap,
|
|
691
|
+
);
|
|
692
|
+
lightAliasId =
|
|
693
|
+
tempIdMap.get(targetPath) ||
|
|
694
|
+
tempIdMap.get(targetVariableName) ||
|
|
695
|
+
getVariableIdByName(targetVariableName, existingVariables) ||
|
|
696
|
+
getVariableIdByName(targetPath, existingVariables);
|
|
697
|
+
|
|
698
|
+
if (!lightAliasId) {
|
|
699
|
+
const errorMsg = `Could not resolve light alias for ${tokenPath} -> ${targetPath}`;
|
|
700
|
+
errors.push(errorMsg);
|
|
701
|
+
stats.errors++;
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
lightFigmaValue = { type: "VARIABLE_ALIAS", id: lightAliasId };
|
|
706
|
+
} else {
|
|
707
|
+
// Check if the value is a string with multiple references (e.g., "{size.0} {size.0} {size.0}")
|
|
708
|
+
// If so, resolve all references to their actual values
|
|
709
|
+
let valueToConvert = resolvedLight;
|
|
710
|
+
if (
|
|
711
|
+
typeof resolvedLight === "string" &&
|
|
712
|
+
resolvedLight.includes("{") &&
|
|
713
|
+
resolvedLight.includes("}") &&
|
|
714
|
+
!isReference(resolvedLight)
|
|
715
|
+
) {
|
|
716
|
+
valueToConvert = resolveMultipleReferences(
|
|
717
|
+
resolvedLight,
|
|
718
|
+
resolvedTokensForRefs,
|
|
719
|
+
false,
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const converted = convertTokenValueToFigma(tokenType, valueToConvert, {
|
|
724
|
+
tokenPath,
|
|
725
|
+
scopes: getScopesForVariable(token),
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
if (converted === null) {
|
|
729
|
+
errors.push(`Could not convert light value for ${tokenPath}`);
|
|
730
|
+
stats.errors++;
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
lightFigmaValue = converted;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Handle composite colors with references - resolve the color and apply alpha
|
|
737
|
+
if (isDarkCompositeWithRef) {
|
|
738
|
+
// Resolve the color reference to its actual value
|
|
739
|
+
const refString = resolvedDark.color;
|
|
740
|
+
const refPath = extractReferencePath(refString);
|
|
741
|
+
if (!refPath) {
|
|
742
|
+
errors.push(
|
|
743
|
+
`Could not extract reference path from ${refString} for ${tokenPath}`,
|
|
744
|
+
);
|
|
745
|
+
stats.errors++;
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
|
|
750
|
+
const targetTokenData = resolvedTokensForRefs.get(targetPath);
|
|
751
|
+
if (!targetTokenData) {
|
|
752
|
+
errors.push(
|
|
753
|
+
`Could not find target token ${targetPath} for ${tokenPath}`,
|
|
754
|
+
);
|
|
755
|
+
stats.errors++;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Get the resolved color value (use dark value for dark mode)
|
|
760
|
+
const resolvedColorValue = targetTokenData.resolvedDark;
|
|
761
|
+
if (!resolvedColorValue) {
|
|
762
|
+
errors.push(
|
|
763
|
+
`Target token ${targetPath} has no resolved dark value for ${tokenPath}`,
|
|
764
|
+
);
|
|
765
|
+
stats.errors++;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Convert the resolved color to Figma format and apply alpha
|
|
770
|
+
const baseColor = convertColorValue(resolvedColorValue);
|
|
771
|
+
if (baseColor === null) {
|
|
772
|
+
errors.push(
|
|
773
|
+
`Could not convert resolved color value for ${tokenPath}: ${JSON.stringify(resolvedColorValue)}`,
|
|
774
|
+
);
|
|
775
|
+
stats.errors++;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Apply the alpha from the composite color
|
|
780
|
+
darkFigmaValue = {
|
|
781
|
+
...baseColor,
|
|
782
|
+
a: resolvedDark.alpha ?? resolvedDark.a ?? baseColor.a,
|
|
783
|
+
};
|
|
784
|
+
} else if (isDarkReference) {
|
|
785
|
+
// Direct string reference - create alias
|
|
786
|
+
const refPath = extractReferencePath(resolvedDark);
|
|
787
|
+
if (!refPath) {
|
|
788
|
+
errors.push(
|
|
789
|
+
`Could not extract reference path from ${resolvedDark} for ${tokenPath}`,
|
|
790
|
+
);
|
|
791
|
+
stats.errors++;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const targetPath = resolveReferencePath(refPath, resolvedTokensForRefs);
|
|
796
|
+
// Determine the correct variable name based on token type
|
|
797
|
+
const targetVariableName = determineVariableName(
|
|
798
|
+
targetPath,
|
|
799
|
+
resolvedTokensForRefs,
|
|
800
|
+
existingVariables,
|
|
801
|
+
tempIdMap,
|
|
802
|
+
);
|
|
803
|
+
darkAliasId =
|
|
804
|
+
tempIdMap.get(targetPath) ||
|
|
805
|
+
tempIdMap.get(targetVariableName) ||
|
|
806
|
+
getVariableIdByName(targetVariableName, existingVariables) ||
|
|
807
|
+
getVariableIdByName(targetPath, existingVariables);
|
|
808
|
+
|
|
809
|
+
if (!darkAliasId) {
|
|
810
|
+
const errorMsg = `Could not resolve dark alias for ${tokenPath} -> ${targetPath}`;
|
|
811
|
+
errors.push(errorMsg);
|
|
812
|
+
stats.errors++;
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
darkFigmaValue = { type: "VARIABLE_ALIAS", id: darkAliasId };
|
|
817
|
+
} else {
|
|
818
|
+
// Check if the value is a string with multiple references (e.g., "{size.0} {size.0} {size.0}")
|
|
819
|
+
// If so, resolve all references to their actual values
|
|
820
|
+
let valueToConvert = resolvedDark;
|
|
821
|
+
if (
|
|
822
|
+
typeof resolvedDark === "string" &&
|
|
823
|
+
resolvedDark.includes("{") &&
|
|
824
|
+
resolvedDark.includes("}") &&
|
|
825
|
+
!isReference(resolvedDark)
|
|
826
|
+
) {
|
|
827
|
+
valueToConvert = resolveMultipleReferences(
|
|
828
|
+
resolvedDark,
|
|
829
|
+
resolvedTokensForRefs,
|
|
830
|
+
true,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const converted = convertTokenValueToFigma(tokenType, valueToConvert, {
|
|
835
|
+
tokenPath,
|
|
836
|
+
scopes: getScopesForVariable(token),
|
|
837
|
+
});
|
|
838
|
+
if (converted === null) {
|
|
839
|
+
errors.push(`Could not convert dark value for ${tokenPath}`);
|
|
840
|
+
stats.errors++;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
darkFigmaValue = converted;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Get existing variable (check both with and without prefix for backward compatibility)
|
|
847
|
+
// Optimize: Use getVariableIdByName for better performance
|
|
848
|
+
const existingId =
|
|
849
|
+
getVariableIdByName(variableName, existingVariables) ||
|
|
850
|
+
getVariableIdByName(tokenPath, existingVariables);
|
|
851
|
+
// Optimize: Use Map for O(1) lookup if we have many variables
|
|
852
|
+
let existing = null;
|
|
853
|
+
if (existingId) {
|
|
854
|
+
if (existingVariables.length > 100) {
|
|
855
|
+
const variableIdMap = new Map();
|
|
856
|
+
for (const v of existingVariables) {
|
|
857
|
+
if (v.id) variableIdMap.set(v.id, v);
|
|
858
|
+
}
|
|
859
|
+
existing = variableIdMap.get(existingId) || null;
|
|
860
|
+
} else {
|
|
861
|
+
existing = existingVariables.find((v) => v.id === existingId);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (existing) {
|
|
866
|
+
// Validate that existing variable has an ID
|
|
867
|
+
if (!existing.id) {
|
|
868
|
+
const errorMsg = `Existing variable for ${tokenPath} has no ID`;
|
|
869
|
+
errors.push(errorMsg);
|
|
870
|
+
stats.errors++;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Validate mode IDs
|
|
875
|
+
if (!modes.light?.id || !modes.dark?.id) {
|
|
876
|
+
errors.push(
|
|
877
|
+
`Mode IDs are missing: light=${modes.light?.id}, dark=${modes.dark?.id}`,
|
|
878
|
+
);
|
|
879
|
+
stats.errors++;
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// Check if values are the same
|
|
884
|
+
const existingValues = existing.valuesByMode || {};
|
|
885
|
+
const existingLight = existingValues[modes.light.id];
|
|
886
|
+
const existingDark = existingValues[modes.dark.id];
|
|
887
|
+
|
|
888
|
+
const lightSame = valuesEqual(existingLight, lightFigmaValue);
|
|
889
|
+
const darkSame = valuesEqual(existingDark, darkFigmaValue);
|
|
890
|
+
|
|
891
|
+
const updateScopes = getScopesForVariable(tokenData.token);
|
|
892
|
+
const existingScopes = existing.scopes || [];
|
|
893
|
+
const sortedUpdate = [...updateScopes].sort();
|
|
894
|
+
const sortedExisting = [...existingScopes].sort();
|
|
895
|
+
const scopesEqual =
|
|
896
|
+
sortedUpdate.length === sortedExisting.length &&
|
|
897
|
+
sortedUpdate.every((s, i) => s === sortedExisting[i]);
|
|
898
|
+
|
|
899
|
+
const tokenDescription = getTokenDescription(
|
|
900
|
+
token,
|
|
901
|
+
resolvedTokensForRefs,
|
|
902
|
+
);
|
|
903
|
+
const descriptionSame =
|
|
904
|
+
(existing.description || "") === tokenDescription;
|
|
905
|
+
|
|
906
|
+
if (lightSame && darkSame) {
|
|
907
|
+
// Values unchanged: still send UPDATE if scopes or description need to be applied/updated
|
|
908
|
+
if (!scopesEqual || !descriptionSame) {
|
|
909
|
+
variableChanges.push({
|
|
910
|
+
action: "UPDATE",
|
|
911
|
+
id: existing.id,
|
|
912
|
+
name: variableName,
|
|
913
|
+
scopes: updateScopes,
|
|
914
|
+
description: tokenDescription,
|
|
915
|
+
});
|
|
916
|
+
stats.updated++;
|
|
917
|
+
} else {
|
|
918
|
+
stats.skipped++;
|
|
919
|
+
}
|
|
920
|
+
tempIdMap.set(tokenPath, existing.id);
|
|
921
|
+
tempIdMap.set(variableName, existing.id);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Update variable (values changed) — always send our scopes so Figma matches code
|
|
926
|
+
const updatePayload = {
|
|
927
|
+
action: "UPDATE",
|
|
928
|
+
id: existing.id,
|
|
929
|
+
name: variableName,
|
|
930
|
+
scopes: updateScopes,
|
|
931
|
+
description: tokenDescription,
|
|
932
|
+
};
|
|
933
|
+
variableChanges.push(updatePayload);
|
|
934
|
+
|
|
935
|
+
// Validate IDs before pushing
|
|
936
|
+
if (!existing.id || !modes.light.id || !modes.dark.id) {
|
|
937
|
+
const errorMsg = `Invalid IDs for ${tokenPath}: existing.id=${existing.id}, light.id=${modes.light.id}, dark.id=${modes.dark.id}`;
|
|
938
|
+
errors.push(errorMsg);
|
|
939
|
+
stats.errors++;
|
|
940
|
+
continue;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
variableModeValues.push(
|
|
944
|
+
{
|
|
945
|
+
variableId: existing.id,
|
|
946
|
+
modeId: modes.light.id,
|
|
947
|
+
value: lightFigmaValue,
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
variableId: existing.id,
|
|
951
|
+
modeId: modes.dark.id,
|
|
952
|
+
value: darkFigmaValue,
|
|
953
|
+
},
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
stats.updated++;
|
|
957
|
+
tempIdMap.set(tokenPath, existing.id);
|
|
958
|
+
tempIdMap.set(variableName, existing.id);
|
|
959
|
+
} else {
|
|
960
|
+
// Validate mode IDs before creating new variable
|
|
961
|
+
if (!modes.light?.id || !modes.dark?.id) {
|
|
962
|
+
errors.push(
|
|
963
|
+
`Mode IDs are missing: light=${modes.light?.id}, dark=${modes.dark?.id}`,
|
|
964
|
+
);
|
|
965
|
+
stats.errors++;
|
|
966
|
+
continue;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Create new variable with temporary ID (use a counter to ensure uniqueness)
|
|
970
|
+
const tempIdCounter = tempIdMap.size + variableChanges.length + 1;
|
|
971
|
+
const tempId = `temp_var_${tempIdCounter}_${variableName.replace(/\//g, "_").replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
972
|
+
tempIdMap.set(tokenPath, tempId);
|
|
973
|
+
tempIdMap.set(variableName, tempId);
|
|
974
|
+
|
|
975
|
+
// Determine variable type based on the actual resolved value
|
|
976
|
+
// If the value is a space-separated string (like "0rem 0rem 0rem"), it must be STRING type
|
|
977
|
+
// Line-height is sent as % string (e.g. "150%") - must be STRING
|
|
978
|
+
// Otherwise, use the token type to determine the Figma variable type
|
|
979
|
+
let variableType = getFigmaVariableType(tokenType);
|
|
980
|
+
if (
|
|
981
|
+
variableType === "FLOAT" &&
|
|
982
|
+
typeof lightFigmaValue === "string" &&
|
|
983
|
+
lightFigmaValue.includes(" ")
|
|
984
|
+
) {
|
|
985
|
+
// Space-separated strings can't be FLOAT - must be STRING
|
|
986
|
+
variableType = "STRING";
|
|
987
|
+
}
|
|
988
|
+
if (
|
|
989
|
+
tokenPath.includes("line-height") &&
|
|
990
|
+
typeof lightFigmaValue === "string"
|
|
991
|
+
) {
|
|
992
|
+
variableType = "STRING";
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const createScopes = getScopesForVariable(tokenData.token);
|
|
996
|
+
const createPayload = {
|
|
997
|
+
action: "CREATE",
|
|
998
|
+
id: tempId,
|
|
999
|
+
name: variableName,
|
|
1000
|
+
variableCollectionId: collectionId,
|
|
1001
|
+
resolvedType: variableType,
|
|
1002
|
+
description: getTokenDescription(token, resolvedTokensForRefs),
|
|
1003
|
+
};
|
|
1004
|
+
createPayload.scopes = createScopes;
|
|
1005
|
+
variableChanges.push(createPayload);
|
|
1006
|
+
|
|
1007
|
+
// Validate IDs before pushing
|
|
1008
|
+
if (!tempId || !modes.light.id || !modes.dark.id) {
|
|
1009
|
+
errors.push(
|
|
1010
|
+
`Invalid IDs for ${tokenPath}: tempId=${tempId}, light.id=${modes.light.id}, dark.id=${modes.dark.id}`,
|
|
1011
|
+
);
|
|
1012
|
+
stats.errors++;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
variableModeValues.push(
|
|
1017
|
+
{
|
|
1018
|
+
variableId: tempId,
|
|
1019
|
+
modeId: modes.light.id,
|
|
1020
|
+
value: lightFigmaValue,
|
|
1021
|
+
},
|
|
1022
|
+
{
|
|
1023
|
+
variableId: tempId,
|
|
1024
|
+
modeId: modes.dark.id,
|
|
1025
|
+
value: darkFigmaValue,
|
|
1026
|
+
},
|
|
1027
|
+
);
|
|
1028
|
+
|
|
1029
|
+
stats.created++;
|
|
1030
|
+
}
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
errors.push(`${tokenPath}: ${error.message}`);
|
|
1033
|
+
stats.errors++;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Delete semantic variables that are no longer in token source
|
|
1038
|
+
for (const v of existingVariables) {
|
|
1039
|
+
if (v.name?.startsWith("semantic/") && !keptSemanticNames.has(v.name)) {
|
|
1040
|
+
variableChanges.push({ action: "DELETE", id: v.id });
|
|
1041
|
+
stats.deleted++;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
return {
|
|
1046
|
+
variableChanges,
|
|
1047
|
+
variableModeValues,
|
|
1048
|
+
stats,
|
|
1049
|
+
errors,
|
|
1050
|
+
tempIdMap,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
module.exports = {
|
|
1055
|
+
buildSemanticVariableRequest,
|
|
1056
|
+
};
|