inkhouse 0.1.0-beta.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/README.md +201 -0
- package/bin/inkhouse.mjs +171 -0
- package/code.js +11802 -0
- package/manifest.json +30 -0
- package/package.json +45 -0
- package/scanner/blob-placement-regression.ts +132 -0
- package/scanner/class-collector.ts +69 -0
- package/scanner/cli.ts +336 -0
- package/scanner/component-scanner.ts +2876 -0
- package/scanner/css-patch-regression.ts +112 -0
- package/scanner/css-token-reader-regression.ts +92 -0
- package/scanner/css-token-reader.ts +477 -0
- package/scanner/font-style-resolver-regression.ts +32 -0
- package/scanner/index.ts +9 -0
- package/scanner/radial-gradient-regression.ts +53 -0
- package/scanner/style-map.ts +145 -0
- package/scanner/tailwind-parser.ts +644 -0
- package/scanner/transform-math-regression.ts +42 -0
- package/scanner/types.ts +298 -0
- package/src/blob-placement.ts +111 -0
- package/src/change-detection.ts +204 -0
- package/src/class-utils.ts +105 -0
- package/src/clip-path-decorative.ts +194 -0
- package/src/color-resolver.ts +98 -0
- package/src/colors.ts +196 -0
- package/src/component-defs.ts +54 -0
- package/src/component-gen.ts +561 -0
- package/src/component-lookup.ts +82 -0
- package/src/config.ts +115 -0
- package/src/design-system.ts +59 -0
- package/src/dev-server.ts +173 -0
- package/src/figma-globals.d.ts +3 -0
- package/src/font-style-resolver.ts +171 -0
- package/src/github.ts +1465 -0
- package/src/icon-builder.ts +607 -0
- package/src/image-cache.ts +22 -0
- package/src/inline-text.ts +271 -0
- package/src/layout-parser.ts +667 -0
- package/src/layout-utils.ts +155 -0
- package/src/main.ts +687 -0
- package/src/node-ir.ts +595 -0
- package/src/pack-provider.ts +148 -0
- package/src/packs.ts +126 -0
- package/src/radial-gradient.ts +84 -0
- package/src/render-context.ts +138 -0
- package/src/responsive-analyzer.ts +139 -0
- package/src/state-analyzer.ts +143 -0
- package/src/story-builder.ts +1706 -0
- package/src/story-layout.ts +38 -0
- package/src/tailwind.ts +2379 -0
- package/src/text-builder.ts +116 -0
- package/src/text-line.ts +42 -0
- package/src/token-source.ts +43 -0
- package/src/tokens.ts +717 -0
- package/src/transform-math.ts +44 -0
- package/src/ui-builder.ts +1996 -0
- package/src/utility-resolver.ts +125 -0
- package/src/variables.ts +1042 -0
- package/src/width-solver.ts +466 -0
- package/templates/patch-tokens-route.ts +165 -0
- package/templates/scan-components-route.ts +57 -0
- package/ui.html +1222 -0
package/src/variables.ts
ADDED
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
import { TOKENS } from './tokens';
|
|
2
|
+
import { parseColor, colorToLabel, debug, normalizeThemeName, normalizeGroupName, normalizeSizeValue } from './colors';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Module-level state
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
let _variableCollection: any = null; // Single-collection (paid) or default-theme collection (free)
|
|
9
|
+
let _themeCollections: Record<string, any> = {}; // Free-plan: one collection per theme
|
|
10
|
+
let _variableModeIds: Record<string, string> = {}; // Multi-mode: theme -> modeId
|
|
11
|
+
let _defaultThemeName = 'primary';
|
|
12
|
+
let _colorVariables: Record<string, any> = {}; // default theme tokenKey -> Variable
|
|
13
|
+
let _themeColorVariables: Record<string, Record<string, any>> = {}; // theme -> tokenKey -> Variable
|
|
14
|
+
let _radiusVariables: Record<string, any> = {}; // key -> FLOAT Variable (radius/sm, radius/md …)
|
|
15
|
+
let _fontVariables: Record<string, any> = {}; // key -> STRING Variable (font/sans …)
|
|
16
|
+
let _spacingVariables: Record<string, any> = {}; // key -> FLOAT Variable (spacing/sm, spacing/lg …)
|
|
17
|
+
let _fontSizeVariables: Record<string, any> = {}; // key -> FLOAT Variable (fontSize/sm, fontSize/xl …)
|
|
18
|
+
let _useMultiMode = false; // true if paid plan (multi-mode available)
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Functions
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
function normalizeCustomThemeName(value: unknown): string | null {
|
|
25
|
+
const raw = String(value || '').trim();
|
|
26
|
+
if (!raw) return null;
|
|
27
|
+
const known = normalizeThemeName(raw);
|
|
28
|
+
if (known) return known;
|
|
29
|
+
const slug = raw
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9_-]+/g, '-')
|
|
32
|
+
.replace(/-+/g, '-')
|
|
33
|
+
.replace(/^-|-$/g, '');
|
|
34
|
+
if (!slug) return null;
|
|
35
|
+
if (/^mode[-_ ]?\d+$/i.test(slug)) return null;
|
|
36
|
+
if (slug === 'default') return 'primary';
|
|
37
|
+
return slug;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getThemeDisplayName(theme: string): string {
|
|
41
|
+
return String(theme || '')
|
|
42
|
+
.split(/[-_\s]+/)
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
45
|
+
.join(' ');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getThemeNamesFromTokens(): string[] {
|
|
49
|
+
const names = Object.keys(TOKENS || {}).filter((key) => {
|
|
50
|
+
if (key === 'core') return false;
|
|
51
|
+
const block = (TOKENS as any)[key];
|
|
52
|
+
if (!block || typeof block !== 'object') return false;
|
|
53
|
+
return Boolean(
|
|
54
|
+
block.color || block.radius || block.font || block.spacing || block.fontSize || block.shadow
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
if (names.length === 0) return ['primary'];
|
|
58
|
+
if (!names.includes('primary')) return names;
|
|
59
|
+
return ['primary'].concat(names.filter((name) => name !== 'primary'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getThemeGroup(theme: string, group: string): Record<string, any> {
|
|
63
|
+
const block = ((TOKENS as any)[theme] && typeof (TOKENS as any)[theme] === 'object')
|
|
64
|
+
? (TOKENS as any)[theme]
|
|
65
|
+
: {};
|
|
66
|
+
const value = block[group];
|
|
67
|
+
return value && typeof value === 'object' ? value : {};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findCollectionByName(collections: any[], name: string): any {
|
|
71
|
+
const needle = String(name || '').toLowerCase();
|
|
72
|
+
return collections.find((c: any) => String(c.name || '').toLowerCase() === needle) || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveVariableValue(value: any, modeId?: string, visited?: Record<string, boolean>): any {
|
|
76
|
+
if (!value) return null;
|
|
77
|
+
if (value.type === 'VARIABLE_ALIAS' && value.id) {
|
|
78
|
+
if (!visited) visited = {};
|
|
79
|
+
if (visited[value.id]) return null;
|
|
80
|
+
visited[value.id] = true;
|
|
81
|
+
if (!figma.variables || !figma.variables.getVariableById) return null;
|
|
82
|
+
const target = figma.variables.getVariableById(value.id);
|
|
83
|
+
if (!target || !target.valuesByMode) return null;
|
|
84
|
+
const vbm = target.valuesByMode;
|
|
85
|
+
const direct = (modeId && vbm[modeId] !== undefined) ? vbm[modeId] : vbm[Object.keys(vbm)[0]];
|
|
86
|
+
return resolveVariableValue(direct, modeId, visited);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readVariableTokens(): any {
|
|
92
|
+
if (!figma.variables || !figma.variables.getLocalVariables) {
|
|
93
|
+
debug('Variables API unavailable, using embedded tokens.');
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const vars = figma.variables.getLocalVariables();
|
|
97
|
+
if (!vars || !vars.length) {
|
|
98
|
+
debug('No local variables found, using embedded tokens.');
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const collections = figma.variables.getLocalVariableCollections ? figma.variables.getLocalVariableCollections() : [];
|
|
102
|
+
const collectionById: Record<string, any> = {};
|
|
103
|
+
const modeNameById: Record<string, string> = {};
|
|
104
|
+
for (const col of collections) {
|
|
105
|
+
collectionById[col.id] = col;
|
|
106
|
+
if (col.modes) {
|
|
107
|
+
for (const mode of col.modes) {
|
|
108
|
+
modeNameById[mode.modeId] = mode.name;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Full token structure read back from Figma Variables.
|
|
114
|
+
// Keep this dynamic: theme keys can be arbitrary (not just primary/secondary).
|
|
115
|
+
const out: Record<string, any> = {
|
|
116
|
+
core: { font: {} },
|
|
117
|
+
primary: { color: {}, radius: {}, font: {}, spacing: {}, fontSize: {} },
|
|
118
|
+
};
|
|
119
|
+
let wrote = false;
|
|
120
|
+
|
|
121
|
+
function ensureTheme(themeName: string): Record<string, any> {
|
|
122
|
+
if (!out[themeName] || typeof out[themeName] !== 'object') {
|
|
123
|
+
out[themeName] = {};
|
|
124
|
+
}
|
|
125
|
+
const block = out[themeName] as Record<string, any>;
|
|
126
|
+
if (!block.color || typeof block.color !== 'object') block.color = {};
|
|
127
|
+
if (!block.radius || typeof block.radius !== 'object') block.radius = {};
|
|
128
|
+
if (!block.font || typeof block.font !== 'object') block.font = {};
|
|
129
|
+
if (!block.spacing || typeof block.spacing !== 'object') block.spacing = {};
|
|
130
|
+
if (!block.fontSize || typeof block.fontSize !== 'object') block.fontSize = {};
|
|
131
|
+
return block;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function assign(theme: string, group: string, parts: string[], value: any): void {
|
|
135
|
+
if (!value) return;
|
|
136
|
+
const key = parts && parts.length ? parts.join('-') : null;
|
|
137
|
+
if (!key) return;
|
|
138
|
+
|
|
139
|
+
const resolvedTheme = theme && theme !== 'core' ? theme : 'primary';
|
|
140
|
+
let target: Record<string, any>;
|
|
141
|
+
|
|
142
|
+
if (group === 'color') {
|
|
143
|
+
target = ensureTheme(resolvedTheme).color;
|
|
144
|
+
target[key] = value;
|
|
145
|
+
wrote = true;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (group === 'radius') {
|
|
149
|
+
target = ensureTheme(resolvedTheme).radius;
|
|
150
|
+
target[key] = normalizeSizeValue(value);
|
|
151
|
+
wrote = true;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (group === 'font') {
|
|
155
|
+
// Route unscoped fonts into core, scoped fonts into their theme block.
|
|
156
|
+
if (!theme || theme === 'core') target = out.core.font;
|
|
157
|
+
else target = ensureTheme(theme).font;
|
|
158
|
+
target[key] = String(value);
|
|
159
|
+
wrote = true;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (group === 'spacing') {
|
|
163
|
+
target = ensureTheme(resolvedTheme).spacing;
|
|
164
|
+
target[key] = normalizeSizeValue(value);
|
|
165
|
+
wrote = true;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (group === 'fontsize') {
|
|
169
|
+
target = ensureTheme(resolvedTheme).fontSize;
|
|
170
|
+
target[key] = normalizeSizeValue(value);
|
|
171
|
+
wrote = true;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// Ignore fontWeight, tracking, typography - these come from Tailwind
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const variable of vars) {
|
|
178
|
+
const rawName = String(variable.name || '');
|
|
179
|
+
if (!rawName) continue;
|
|
180
|
+
const parts = rawName.split(/[./]/).filter(Boolean);
|
|
181
|
+
if (!parts.length) continue;
|
|
182
|
+
const firstPartIsGroup = !!normalizeGroupName(parts[0]);
|
|
183
|
+
let theme = firstPartIsGroup ? null : (normalizeThemeName(parts[0]) || normalizeCustomThemeName(parts[0]));
|
|
184
|
+
let cursor = parts.slice(theme ? 1 : 0);
|
|
185
|
+
let group = normalizeGroupName(cursor[0]);
|
|
186
|
+
|
|
187
|
+
if (!group) {
|
|
188
|
+
// Backward compatibility: older variable sets used flat color names like
|
|
189
|
+
// `primary`, `foreground` (without `color/` prefix). Infer group by type.
|
|
190
|
+
if (String((variable as any).resolvedType || '').toUpperCase() === 'COLOR') {
|
|
191
|
+
group = 'color';
|
|
192
|
+
if (cursor.length === 0) {
|
|
193
|
+
theme = null;
|
|
194
|
+
cursor = parts.slice(0);
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
cursor = cursor.slice(1);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const valuesByMode = variable.valuesByMode || {};
|
|
204
|
+
const modeIds = Object.keys(valuesByMode);
|
|
205
|
+
if (!modeIds.length) continue;
|
|
206
|
+
|
|
207
|
+
const collection = collectionById[variable.variableCollectionId];
|
|
208
|
+
const isSingleModeCollection = !!(collection && Array.isArray(collection.modes) && collection.modes.length === 1);
|
|
209
|
+
for (const modeId of modeIds) {
|
|
210
|
+
const modeName = modeNameById[modeId];
|
|
211
|
+
const modeTheme =
|
|
212
|
+
theme ||
|
|
213
|
+
normalizeThemeName(modeName) ||
|
|
214
|
+
normalizeCustomThemeName(modeName) ||
|
|
215
|
+
(isSingleModeCollection
|
|
216
|
+
? (
|
|
217
|
+
normalizeThemeName(collection && collection.name) ||
|
|
218
|
+
normalizeCustomThemeName(collection && collection.name)
|
|
219
|
+
)
|
|
220
|
+
: null);
|
|
221
|
+
if (!modeTheme && !isSingleModeCollection) {
|
|
222
|
+
// Ignore unknown/legacy extra modes in multi-mode collections.
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const value = resolveVariableValue(valuesByMode[modeId], modeId);
|
|
226
|
+
const finalTheme = modeTheme || (group === 'font' ? 'core' : 'primary');
|
|
227
|
+
assign(finalTheme, group, cursor, value);
|
|
228
|
+
if (theme) break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return wrote ? out : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function upsertPaintStyle(name: string, color: { r: number; g: number; b: number; a?: number }): any {
|
|
236
|
+
const styles = figma.getLocalPaintStyles();
|
|
237
|
+
const existing = styles.find((s: any) => s.name === name);
|
|
238
|
+
const paint = { type: 'SOLID' as const, color: { r: color.r, g: color.g, b: color.b }, opacity: (color.a == null ? 1 : color.a) };
|
|
239
|
+
if (existing) {
|
|
240
|
+
existing.paints = [paint];
|
|
241
|
+
return existing;
|
|
242
|
+
} else {
|
|
243
|
+
const s = figma.createPaintStyle();
|
|
244
|
+
s.name = name;
|
|
245
|
+
s.paints = [paint];
|
|
246
|
+
return s;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Shadow Effect Styles
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
interface ShadowLayer {
|
|
255
|
+
inset: boolean;
|
|
256
|
+
x: number;
|
|
257
|
+
y: number;
|
|
258
|
+
blur: number;
|
|
259
|
+
spread: number;
|
|
260
|
+
r: number;
|
|
261
|
+
g: number;
|
|
262
|
+
b: number;
|
|
263
|
+
a: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Parse a CSS `rgb(r g b / a)` or `rgba(r, g, b, a)` color string into 0–1 floats.
|
|
268
|
+
*/
|
|
269
|
+
function parseCssShadowColor(raw: string): { r: number; g: number; b: number; a: number } {
|
|
270
|
+
// rgb(r g b / a) — CSS Color Level 4
|
|
271
|
+
const lvl4 = raw.match(/^rgb\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\/\s*([\d.]+)\s*\)$/);
|
|
272
|
+
if (lvl4) {
|
|
273
|
+
return {
|
|
274
|
+
r: parseFloat(lvl4[1]) / 255,
|
|
275
|
+
g: parseFloat(lvl4[2]) / 255,
|
|
276
|
+
b: parseFloat(lvl4[3]) / 255,
|
|
277
|
+
a: parseFloat(lvl4[4]),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// rgba(r, g, b, a)
|
|
281
|
+
const legacy = raw.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/);
|
|
282
|
+
if (legacy) {
|
|
283
|
+
return {
|
|
284
|
+
r: parseFloat(legacy[1]) / 255,
|
|
285
|
+
g: parseFloat(legacy[2]) / 255,
|
|
286
|
+
b: parseFloat(legacy[3]) / 255,
|
|
287
|
+
a: legacy[4] != null ? parseFloat(legacy[4]) : 1,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
return { r: 0, g: 0, b: 0, a: 0.1 };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Parse a CSS box-shadow value string (possibly multi-layer, comma-separated)
|
|
295
|
+
* into an array of ShadowLayer objects.
|
|
296
|
+
* Format per layer: [inset] <offset-x> <offset-y> [blur] [spread] <color>
|
|
297
|
+
*/
|
|
298
|
+
function parseCssBoxShadow(value: string): ShadowLayer[] {
|
|
299
|
+
// Split on commas that are NOT inside parentheses
|
|
300
|
+
const layers: string[] = [];
|
|
301
|
+
let depth = 0;
|
|
302
|
+
let start = 0;
|
|
303
|
+
for (let i = 0; i < value.length; i++) {
|
|
304
|
+
if (value[i] === '(') depth++;
|
|
305
|
+
else if (value[i] === ')') depth--;
|
|
306
|
+
else if (value[i] === ',' && depth === 0) {
|
|
307
|
+
layers.push(value.slice(start, i).trim());
|
|
308
|
+
start = i + 1;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
layers.push(value.slice(start).trim());
|
|
312
|
+
|
|
313
|
+
const result: ShadowLayer[] = [];
|
|
314
|
+
for (const layer of layers) {
|
|
315
|
+
if (!layer) continue;
|
|
316
|
+
const inset = /\binset\b/.test(layer);
|
|
317
|
+
const withoutInset = layer.replace(/\binset\b/, '').trim();
|
|
318
|
+
|
|
319
|
+
// Extract color: find rgb/rgba(...) or hex
|
|
320
|
+
const colorMatch = withoutInset.match(/(rgba?\([^)]+\)|#[0-9a-fA-F]{3,8})/);
|
|
321
|
+
const colorStr = colorMatch ? colorMatch[1] : 'rgb(0 0 0 / 0.1)';
|
|
322
|
+
const withoutColor = withoutInset.replace(colorStr, '').trim();
|
|
323
|
+
|
|
324
|
+
const lengths = withoutColor.split(/\s+/).filter(Boolean);
|
|
325
|
+
const px = (s: string): number => {
|
|
326
|
+
if (!s) return 0;
|
|
327
|
+
if (s.endsWith('px')) return parseFloat(s);
|
|
328
|
+
if (s === '0') return 0;
|
|
329
|
+
return parseFloat(s) || 0;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const color = parseCssShadowColor(colorStr);
|
|
333
|
+
result.push({
|
|
334
|
+
inset,
|
|
335
|
+
x: px(lengths[0] || '0'),
|
|
336
|
+
y: px(lengths[1] || '0'),
|
|
337
|
+
blur: px(lengths[2] || '0'),
|
|
338
|
+
spread: px(lengths[3] || '0'),
|
|
339
|
+
r: color.r,
|
|
340
|
+
g: color.g,
|
|
341
|
+
b: color.b,
|
|
342
|
+
a: color.a,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Create or update a Figma Effect Style for a shadow token.
|
|
350
|
+
* Name format: "shadow/sm", "shadow/DEFAULT" → "shadow", etc.
|
|
351
|
+
*/
|
|
352
|
+
function upsertEffectStyle(name: string, cssValue: string): void {
|
|
353
|
+
if (!figma.getLocalEffectStyles) return;
|
|
354
|
+
const layers = parseCssBoxShadow(cssValue);
|
|
355
|
+
if (!layers.length) return;
|
|
356
|
+
|
|
357
|
+
const effects: any[] = [];
|
|
358
|
+
for (const l of layers) {
|
|
359
|
+
effects.push({
|
|
360
|
+
type: l.inset ? 'INNER_SHADOW' : 'DROP_SHADOW',
|
|
361
|
+
color: { r: l.r, g: l.g, b: l.b, a: l.a },
|
|
362
|
+
offset: { x: l.x, y: l.y },
|
|
363
|
+
radius: l.blur,
|
|
364
|
+
spread: l.spread,
|
|
365
|
+
visible: true,
|
|
366
|
+
blendMode: 'NORMAL',
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const existing = figma.getLocalEffectStyles().find((s: any) => s.name === name);
|
|
371
|
+
if (existing) {
|
|
372
|
+
existing.effects = effects;
|
|
373
|
+
} else {
|
|
374
|
+
const s = figma.createEffectStyle();
|
|
375
|
+
s.name = name;
|
|
376
|
+
s.effects = effects;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Create/update Figma Effect Styles for all shadow tokens.
|
|
382
|
+
*/
|
|
383
|
+
export function populateShadowStyles(shadowTokens: Record<string, string>): void {
|
|
384
|
+
for (const key in shadowTokens) {
|
|
385
|
+
const styleName = key === 'DEFAULT' ? 'shadow' : 'shadow/' + key;
|
|
386
|
+
upsertEffectStyle(styleName, shadowTokens[key]);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Remove stale paint styles from previous runs (LIGHT, DARK, PRIMARY, SECONDARY groups)
|
|
392
|
+
*/
|
|
393
|
+
export function removeStaleColorStyles(): void {
|
|
394
|
+
const styles = figma.getLocalPaintStyles();
|
|
395
|
+
const stalePrefix = ['LIGHT/', 'DARK/', 'PRIMARY/', 'SECONDARY/'];
|
|
396
|
+
for (const s of styles) {
|
|
397
|
+
if (stalePrefix.some(p => s.name.startsWith(p))) {
|
|
398
|
+
s.remove();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Populate variables in a collection for a given theme's color tokens.
|
|
405
|
+
*/
|
|
406
|
+
export function populateColorVariables(collection: any, modeId: string, colorTokens: Record<string, any>, outMap: Record<string, any>): void {
|
|
407
|
+
// Index existing variables in this collection
|
|
408
|
+
const existingVars = figma.variables.getLocalVariables('COLOR');
|
|
409
|
+
const existingByName: Record<string, any> = {};
|
|
410
|
+
for (const v of existingVars) {
|
|
411
|
+
if (v.variableCollectionId === collection.id) {
|
|
412
|
+
existingByName[v.name] = v;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const key in colorTokens) {
|
|
417
|
+
const varName = 'color/' + key;
|
|
418
|
+
let variable = existingByName[varName];
|
|
419
|
+
if (!variable) {
|
|
420
|
+
variable = figma.variables.createVariable(varName, collection, 'COLOR');
|
|
421
|
+
}
|
|
422
|
+
const rgb = parseColor(colorTokens[key]);
|
|
423
|
+
variable.setValueForMode(modeId, { r: rgb.r, g: rgb.g, b: rgb.b, a: rgb.a == null ? 1 : rgb.a });
|
|
424
|
+
outMap[key] = variable;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Populate FLOAT radius variables in a collection for a given mode.
|
|
430
|
+
*/
|
|
431
|
+
export function populateRadiusVariables(collection: any, modeId: string, radiusTokens: Record<string, string>, outMap: Record<string, any>): void {
|
|
432
|
+
const existingVars = figma.variables.getLocalVariables('FLOAT');
|
|
433
|
+
const existingByName: Record<string, any> = {};
|
|
434
|
+
for (const v of existingVars) {
|
|
435
|
+
if (v.variableCollectionId === collection.id) {
|
|
436
|
+
existingByName[v.name] = v;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
for (const key in radiusTokens) {
|
|
440
|
+
const varName = 'radius/' + key;
|
|
441
|
+
let variable = existingByName[varName];
|
|
442
|
+
if (!variable) {
|
|
443
|
+
variable = figma.variables.createVariable(varName, collection, 'FLOAT');
|
|
444
|
+
}
|
|
445
|
+
variable.setValueForMode(modeId, pxFromSizeToken(radiusTokens[key]));
|
|
446
|
+
outMap[key] = variable;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Populate STRING font-family variables in a collection for a given mode.
|
|
452
|
+
*/
|
|
453
|
+
export function populateFontVariables(collection: any, modeId: string, fontTokens: Record<string, string>, outMap: Record<string, any>): void {
|
|
454
|
+
const existingVars = figma.variables.getLocalVariables('STRING');
|
|
455
|
+
const existingByName: Record<string, any> = {};
|
|
456
|
+
for (const v of existingVars) {
|
|
457
|
+
if (v.variableCollectionId === collection.id) {
|
|
458
|
+
existingByName[v.name] = v;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (const key in fontTokens) {
|
|
462
|
+
const varName = 'font/' + key;
|
|
463
|
+
let variable = existingByName[varName];
|
|
464
|
+
if (!variable) {
|
|
465
|
+
variable = figma.variables.createVariable(varName, collection, 'STRING');
|
|
466
|
+
}
|
|
467
|
+
// Store the first family name only (no fallback stack in Figma)
|
|
468
|
+
const first = String(fontTokens[key]).split(',')[0].trim().replace(/^["']|["']$/g, '');
|
|
469
|
+
variable.setValueForMode(modeId, first);
|
|
470
|
+
outMap[key] = variable;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Populate FLOAT spacing variables in a collection for a given mode.
|
|
476
|
+
*/
|
|
477
|
+
export function populateSpacingVariables(collection: any, modeId: string, spacingTokens: Record<string, string>, outMap: Record<string, any>): void {
|
|
478
|
+
const existingVars = figma.variables.getLocalVariables('FLOAT');
|
|
479
|
+
const existingByName: Record<string, any> = {};
|
|
480
|
+
for (const v of existingVars) {
|
|
481
|
+
if (v.variableCollectionId === collection.id) existingByName[v.name] = v;
|
|
482
|
+
}
|
|
483
|
+
for (const key in spacingTokens) {
|
|
484
|
+
const varName = 'spacing/' + key;
|
|
485
|
+
let variable = existingByName[varName];
|
|
486
|
+
if (!variable) {
|
|
487
|
+
variable = figma.variables.createVariable(varName, collection, 'FLOAT');
|
|
488
|
+
}
|
|
489
|
+
variable.setValueForMode(modeId, pxFromSizeToken(spacingTokens[key]));
|
|
490
|
+
outMap[key] = variable;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Populate FLOAT font-size variables in a collection for a given mode.
|
|
496
|
+
*/
|
|
497
|
+
export function populateFontSizeVariables(collection: any, modeId: string, fontSizeTokens: Record<string, string>, outMap: Record<string, any>): void {
|
|
498
|
+
const existingVars = figma.variables.getLocalVariables('FLOAT');
|
|
499
|
+
const existingByName: Record<string, any> = {};
|
|
500
|
+
for (const v of existingVars) {
|
|
501
|
+
if (v.variableCollectionId === collection.id) existingByName[v.name] = v;
|
|
502
|
+
}
|
|
503
|
+
for (const key in fontSizeTokens) {
|
|
504
|
+
const varName = 'fontSize/' + key;
|
|
505
|
+
let variable = existingByName[varName];
|
|
506
|
+
if (!variable) {
|
|
507
|
+
variable = figma.variables.createVariable(varName, collection, 'FLOAT');
|
|
508
|
+
}
|
|
509
|
+
variable.setValueForMode(modeId, pxFromSizeToken(fontSizeTokens[key]));
|
|
510
|
+
outMap[key] = variable;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Create or update color variables.
|
|
516
|
+
* Tries multi-mode first (paid plans). Falls back to two collections (free plan).
|
|
517
|
+
*/
|
|
518
|
+
export function createOrUpdateVariables(): void {
|
|
519
|
+
if (!figma.variables || !figma.variables.createVariableCollection) {
|
|
520
|
+
debug('Variables API not available, falling back to paint styles');
|
|
521
|
+
createOrUpdateStylesFallback();
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const themeNames = getThemeNamesFromTokens();
|
|
526
|
+
_defaultThemeName = themeNames.includes('primary') ? 'primary' : themeNames[0];
|
|
527
|
+
const defaultTheme = _defaultThemeName;
|
|
528
|
+
const defaultDisplayName = getThemeDisplayName(defaultTheme);
|
|
529
|
+
|
|
530
|
+
const themeColors: Record<string, Record<string, any>> = {};
|
|
531
|
+
const themeRadius: Record<string, Record<string, any>> = {};
|
|
532
|
+
const themeFont: Record<string, Record<string, any>> = {};
|
|
533
|
+
const themeSpacing: Record<string, Record<string, any>> = {};
|
|
534
|
+
const themeFontSize: Record<string, Record<string, any>> = {};
|
|
535
|
+
for (const theme of themeNames) {
|
|
536
|
+
themeColors[theme] = getThemeGroup(theme, 'color');
|
|
537
|
+
themeRadius[theme] = getThemeGroup(theme, 'radius');
|
|
538
|
+
themeFont[theme] = getThemeGroup(theme, 'font');
|
|
539
|
+
themeSpacing[theme] = getThemeGroup(theme, 'spacing');
|
|
540
|
+
themeFontSize[theme] = getThemeGroup(theme, 'fontSize');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
_themeCollections = {};
|
|
544
|
+
_themeColorVariables = {};
|
|
545
|
+
_variableModeIds = {};
|
|
546
|
+
_colorVariables = {};
|
|
547
|
+
_radiusVariables = {};
|
|
548
|
+
_fontVariables = {};
|
|
549
|
+
_spacingVariables = {};
|
|
550
|
+
_fontSizeVariables = {};
|
|
551
|
+
|
|
552
|
+
const collections = figma.variables.getLocalVariableCollections();
|
|
553
|
+
|
|
554
|
+
function ensureThemeModes(collection: any): boolean {
|
|
555
|
+
const map: Record<string, string> = {};
|
|
556
|
+
const modes = Array.isArray(collection.modes) ? collection.modes.slice() : [];
|
|
557
|
+
const unclaimed: string[] = modes.map((mode: any) => mode.modeId);
|
|
558
|
+
const removeUnclaimed = (modeId: string): void => {
|
|
559
|
+
const idx = unclaimed.indexOf(modeId);
|
|
560
|
+
if (idx >= 0) unclaimed.splice(idx, 1);
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
for (const mode of modes) {
|
|
564
|
+
const mapped = normalizeThemeName(mode.name) || normalizeCustomThemeName(mode.name);
|
|
565
|
+
if (mapped && themeNames.includes(mapped) && !map[mapped]) {
|
|
566
|
+
map[mapped] = mode.modeId;
|
|
567
|
+
removeUnclaimed(mode.modeId);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
for (const theme of themeNames) {
|
|
572
|
+
if (map[theme]) continue;
|
|
573
|
+
if (unclaimed.length > 0) {
|
|
574
|
+
const modeId = unclaimed.shift() as string;
|
|
575
|
+
try {
|
|
576
|
+
collection.renameMode(modeId, getThemeDisplayName(theme));
|
|
577
|
+
} catch (_e) {}
|
|
578
|
+
map[theme] = modeId;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
map[theme] = collection.addMode(getThemeDisplayName(theme));
|
|
583
|
+
} catch (e: any) {
|
|
584
|
+
debug('Cannot add mode (free plan): ' + (e.message || e));
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
for (const theme of themeNames) {
|
|
590
|
+
const modeId = map[theme];
|
|
591
|
+
if (!modeId) continue;
|
|
592
|
+
try {
|
|
593
|
+
collection.renameMode(modeId, getThemeDisplayName(theme));
|
|
594
|
+
} catch (_e) {}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Rename leftover modes to neutral names so they don't map to semantic themes.
|
|
598
|
+
let legacyIndex = 1;
|
|
599
|
+
for (const modeId of unclaimed) {
|
|
600
|
+
try {
|
|
601
|
+
collection.renameMode(modeId, 'Mode ' + legacyIndex++);
|
|
602
|
+
} catch (_e) {}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
_variableModeIds = map;
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
_variableCollection =
|
|
610
|
+
findCollectionByName(collections, 'Design Tokens') ||
|
|
611
|
+
findCollectionByName(collections, defaultDisplayName) ||
|
|
612
|
+
findCollectionByName(collections, 'Primary');
|
|
613
|
+
|
|
614
|
+
_useMultiMode = false;
|
|
615
|
+
if (_variableCollection) {
|
|
616
|
+
_useMultiMode = ensureThemeModes(_variableCollection);
|
|
617
|
+
} else {
|
|
618
|
+
_variableCollection = figma.variables.createVariableCollection('Design Tokens');
|
|
619
|
+
_useMultiMode = ensureThemeModes(_variableCollection);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (_useMultiMode) {
|
|
623
|
+
const allColorKeys: Record<string, boolean> = {};
|
|
624
|
+
const allRadiusKeys: Record<string, boolean> = {};
|
|
625
|
+
const allFontKeys: Record<string, boolean> = {};
|
|
626
|
+
const allSpacingKeys: Record<string, boolean> = {};
|
|
627
|
+
const allFontSizeKeys: Record<string, boolean> = {};
|
|
628
|
+
for (const theme of themeNames) {
|
|
629
|
+
for (const key in themeColors[theme]) allColorKeys[key] = true;
|
|
630
|
+
for (const key in themeRadius[theme]) allRadiusKeys[key] = true;
|
|
631
|
+
for (const key in themeFont[theme]) allFontKeys[key] = true;
|
|
632
|
+
for (const key in themeSpacing[theme]) allSpacingKeys[key] = true;
|
|
633
|
+
for (const key in themeFontSize[theme]) allFontSizeKeys[key] = true;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const existingColorVars = figma.variables.getLocalVariables('COLOR');
|
|
637
|
+
const existingColorByName: Record<string, any> = {};
|
|
638
|
+
for (const v of existingColorVars) {
|
|
639
|
+
if (v.variableCollectionId === _variableCollection.id) {
|
|
640
|
+
existingColorByName[v.name] = v;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const existingFloatVars = figma.variables.getLocalVariables('FLOAT');
|
|
645
|
+
const existingFloatByName: Record<string, any> = {};
|
|
646
|
+
for (const v of existingFloatVars) {
|
|
647
|
+
if (v.variableCollectionId === _variableCollection.id) {
|
|
648
|
+
existingFloatByName[v.name] = v;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const existingStringVars = figma.variables.getLocalVariables('STRING');
|
|
653
|
+
const existingStringByName: Record<string, any> = {};
|
|
654
|
+
for (const v of existingStringVars) {
|
|
655
|
+
if (v.variableCollectionId === _variableCollection.id) {
|
|
656
|
+
existingStringByName[v.name] = v;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
for (const theme of themeNames) _themeColorVariables[theme] = {};
|
|
661
|
+
|
|
662
|
+
const defaultColors = themeColors[defaultTheme] || {};
|
|
663
|
+
for (const key in allColorKeys) {
|
|
664
|
+
const varName = 'color/' + key;
|
|
665
|
+
let variable = existingColorByName[varName];
|
|
666
|
+
if (!variable) variable = figma.variables.createVariable(varName, _variableCollection, 'COLOR');
|
|
667
|
+
|
|
668
|
+
for (const theme of themeNames) {
|
|
669
|
+
const modeId = _variableModeIds[theme];
|
|
670
|
+
if (!modeId) continue;
|
|
671
|
+
const value = (themeColors[theme] && themeColors[theme][key] !== undefined)
|
|
672
|
+
? themeColors[theme][key]
|
|
673
|
+
: defaultColors[key];
|
|
674
|
+
if (value === undefined) continue;
|
|
675
|
+
const rgb = parseColor(value);
|
|
676
|
+
variable.setValueForMode(modeId, { r: rgb.r, g: rgb.g, b: rgb.b, a: rgb.a == null ? 1 : rgb.a });
|
|
677
|
+
_themeColorVariables[theme][key] = variable;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
_colorVariables = _themeColorVariables[defaultTheme] || {};
|
|
681
|
+
|
|
682
|
+
const defaultRadius = themeRadius[defaultTheme] || {};
|
|
683
|
+
for (const key in allRadiusKeys) {
|
|
684
|
+
const varName = 'radius/' + key;
|
|
685
|
+
let variable = existingFloatByName[varName];
|
|
686
|
+
if (!variable) variable = figma.variables.createVariable(varName, _variableCollection, 'FLOAT');
|
|
687
|
+
|
|
688
|
+
for (const theme of themeNames) {
|
|
689
|
+
const modeId = _variableModeIds[theme];
|
|
690
|
+
if (!modeId) continue;
|
|
691
|
+
const value = (themeRadius[theme] && themeRadius[theme][key] !== undefined)
|
|
692
|
+
? themeRadius[theme][key]
|
|
693
|
+
: defaultRadius[key];
|
|
694
|
+
if (value === undefined) continue;
|
|
695
|
+
variable.setValueForMode(modeId, pxFromSizeToken(value));
|
|
696
|
+
}
|
|
697
|
+
_radiusVariables[key] = variable;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const defaultFont = themeFont[defaultTheme] || {};
|
|
701
|
+
for (const key in allFontKeys) {
|
|
702
|
+
const varName = 'font/' + key;
|
|
703
|
+
let variable = existingStringByName[varName];
|
|
704
|
+
if (!variable) variable = figma.variables.createVariable(varName, _variableCollection, 'STRING');
|
|
705
|
+
|
|
706
|
+
for (const theme of themeNames) {
|
|
707
|
+
const modeId = _variableModeIds[theme];
|
|
708
|
+
if (!modeId) continue;
|
|
709
|
+
const value = (themeFont[theme] && themeFont[theme][key] !== undefined)
|
|
710
|
+
? themeFont[theme][key]
|
|
711
|
+
: defaultFont[key];
|
|
712
|
+
if (value === undefined) continue;
|
|
713
|
+
const first = String(value).split(',')[0].trim().replace(/^["']|["']$/g, '');
|
|
714
|
+
variable.setValueForMode(modeId, first);
|
|
715
|
+
}
|
|
716
|
+
_fontVariables[key] = variable;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const defaultSpacing = themeSpacing[defaultTheme] || {};
|
|
720
|
+
for (const key in allSpacingKeys) {
|
|
721
|
+
const varName = 'spacing/' + key;
|
|
722
|
+
let variable = existingFloatByName[varName];
|
|
723
|
+
if (!variable) variable = figma.variables.createVariable(varName, _variableCollection, 'FLOAT');
|
|
724
|
+
|
|
725
|
+
for (const theme of themeNames) {
|
|
726
|
+
const modeId = _variableModeIds[theme];
|
|
727
|
+
if (!modeId) continue;
|
|
728
|
+
const value = (themeSpacing[theme] && themeSpacing[theme][key] !== undefined)
|
|
729
|
+
? themeSpacing[theme][key]
|
|
730
|
+
: defaultSpacing[key];
|
|
731
|
+
if (value === undefined) continue;
|
|
732
|
+
variable.setValueForMode(modeId, pxFromSizeToken(value));
|
|
733
|
+
}
|
|
734
|
+
_spacingVariables[key] = variable;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const defaultFontSize = themeFontSize[defaultTheme] || {};
|
|
738
|
+
for (const key in allFontSizeKeys) {
|
|
739
|
+
const varName = 'fontSize/' + key;
|
|
740
|
+
let variable = existingFloatByName[varName];
|
|
741
|
+
if (!variable) variable = figma.variables.createVariable(varName, _variableCollection, 'FLOAT');
|
|
742
|
+
|
|
743
|
+
for (const theme of themeNames) {
|
|
744
|
+
const modeId = _variableModeIds[theme];
|
|
745
|
+
if (!modeId) continue;
|
|
746
|
+
const value = (themeFontSize[theme] && themeFontSize[theme][key] !== undefined)
|
|
747
|
+
? themeFontSize[theme][key]
|
|
748
|
+
: defaultFontSize[key];
|
|
749
|
+
if (value === undefined) continue;
|
|
750
|
+
variable.setValueForMode(modeId, pxFromSizeToken(value));
|
|
751
|
+
}
|
|
752
|
+
_fontSizeVariables[key] = variable;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
for (const col of collections) {
|
|
756
|
+
if (!col || col.id === _variableCollection.id) continue;
|
|
757
|
+
const mapped = normalizeThemeName(col.name) || normalizeCustomThemeName(col.name);
|
|
758
|
+
if (mapped && themeNames.includes(mapped)) {
|
|
759
|
+
try { col.remove(); } catch (_e) {}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
debug(
|
|
764
|
+
'Multi-mode: ' +
|
|
765
|
+
themeNames.length +
|
|
766
|
+
' themes, ' +
|
|
767
|
+
Object.keys(_colorVariables).length + ' color + ' +
|
|
768
|
+
Object.keys(_radiusVariables).length + ' radius + ' +
|
|
769
|
+
Object.keys(_fontVariables).length + ' font variables'
|
|
770
|
+
);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// --- Free plan fallback: one collection per theme (color), default theme for shared scales ---
|
|
775
|
+
const existingCollections = figma.variables.getLocalVariableCollections();
|
|
776
|
+
for (const theme of themeNames) {
|
|
777
|
+
const displayName = getThemeDisplayName(theme);
|
|
778
|
+
let collection = findCollectionByName(existingCollections, displayName);
|
|
779
|
+
if (!collection && theme === defaultTheme && _variableCollection) {
|
|
780
|
+
collection = _variableCollection;
|
|
781
|
+
}
|
|
782
|
+
if (!collection) {
|
|
783
|
+
collection = figma.variables.createVariableCollection(displayName);
|
|
784
|
+
} else if (collection.name !== displayName) {
|
|
785
|
+
collection.name = displayName;
|
|
786
|
+
}
|
|
787
|
+
_themeCollections[theme] = collection;
|
|
788
|
+
_themeColorVariables[theme] = {};
|
|
789
|
+
|
|
790
|
+
const modeId = collection.modes[0].modeId;
|
|
791
|
+
populateColorVariables(collection, modeId, themeColors[theme] || {}, _themeColorVariables[theme]);
|
|
792
|
+
|
|
793
|
+
if (theme === defaultTheme) {
|
|
794
|
+
_variableCollection = collection;
|
|
795
|
+
_variableModeIds[theme] = modeId;
|
|
796
|
+
_colorVariables = _themeColorVariables[theme];
|
|
797
|
+
populateRadiusVariables(collection, modeId, themeRadius[theme] || {}, _radiusVariables);
|
|
798
|
+
populateFontVariables(collection, modeId, themeFont[theme] || {}, _fontVariables);
|
|
799
|
+
populateSpacingVariables(collection, modeId, themeSpacing[theme] || {}, _spacingVariables);
|
|
800
|
+
populateFontSizeVariables(collection, modeId, themeFontSize[theme] || {}, _fontSizeVariables);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Remove stale plugin theme collections no longer present in tokens.
|
|
805
|
+
for (const collection of existingCollections) {
|
|
806
|
+
if (!collection) continue;
|
|
807
|
+
const mapped = normalizeThemeName(collection.name) || normalizeCustomThemeName(collection.name);
|
|
808
|
+
if (!mapped) continue;
|
|
809
|
+
if (themeNames.includes(mapped)) continue;
|
|
810
|
+
if (collection.id === (_variableCollection && _variableCollection.id)) continue;
|
|
811
|
+
const varsInCollection = figma.variables.getLocalVariables().filter((v: any) => v.variableCollectionId === collection.id);
|
|
812
|
+
const looksLikePluginThemeCollection = varsInCollection.some((v: any) =>
|
|
813
|
+
/^color\//.test(String(v.name || '')) ||
|
|
814
|
+
/^radius\//.test(String(v.name || '')) ||
|
|
815
|
+
/^font\//.test(String(v.name || '')) ||
|
|
816
|
+
/^spacing\//.test(String(v.name || '')) ||
|
|
817
|
+
/^fontSize\//.test(String(v.name || ''))
|
|
818
|
+
);
|
|
819
|
+
if (!looksLikePluginThemeCollection) continue;
|
|
820
|
+
try { collection.remove(); } catch (_e) {}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
debug(
|
|
824
|
+
'Free plan: ' +
|
|
825
|
+
themeNames.length +
|
|
826
|
+
' theme collections, ' +
|
|
827
|
+
Object.keys(_colorVariables).length + ' default-theme colors'
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Fallback: create paint styles if Variables API isn't available
|
|
833
|
+
*/
|
|
834
|
+
export function createOrUpdateStylesFallback(): void {
|
|
835
|
+
const themes = getThemeNamesFromTokens();
|
|
836
|
+
for (const theme of themes) {
|
|
837
|
+
const col: Record<string, any> = getThemeGroup(theme, 'color');
|
|
838
|
+
for (const [key, val] of Object.entries(col)) {
|
|
839
|
+
const rgb = parseColor(val);
|
|
840
|
+
upsertPaintStyle(`${theme.toUpperCase()}/Color/${key}`, rgb);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Bind a frame's fill to a color variable (semantic token).
|
|
847
|
+
*/
|
|
848
|
+
export function bindColorVariable(node: any, tokenKey: string, fillOrStroke: string, theme?: string): boolean {
|
|
849
|
+
const resolvedTheme =
|
|
850
|
+
(theme && (_themeColorVariables[theme] || _variableModeIds[theme])) ? theme :
|
|
851
|
+
_defaultThemeName;
|
|
852
|
+
const variable =
|
|
853
|
+
(_themeColorVariables[resolvedTheme] && _themeColorVariables[resolvedTheme][tokenKey]) ||
|
|
854
|
+
(_themeColorVariables[_defaultThemeName] && _themeColorVariables[_defaultThemeName][tokenKey]) ||
|
|
855
|
+
_colorVariables[tokenKey] ||
|
|
856
|
+
null;
|
|
857
|
+
if (!variable) return false;
|
|
858
|
+
|
|
859
|
+
try {
|
|
860
|
+
if (fillOrStroke === 'fill') {
|
|
861
|
+
const fills = JSON.parse(JSON.stringify(node.fills || []));
|
|
862
|
+
if (fills.length === 0) {
|
|
863
|
+
fills.push({ type: 'SOLID', color: { r: 1, g: 1, b: 1 }, opacity: 1 });
|
|
864
|
+
}
|
|
865
|
+
fills[0] = figma.variables.setBoundVariableForPaint(fills[0], 'color', variable);
|
|
866
|
+
node.fills = fills;
|
|
867
|
+
} else if (fillOrStroke === 'stroke') {
|
|
868
|
+
const strokes = JSON.parse(JSON.stringify(node.strokes || []));
|
|
869
|
+
if (strokes.length === 0) {
|
|
870
|
+
strokes.push({ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } });
|
|
871
|
+
}
|
|
872
|
+
strokes[0] = figma.variables.setBoundVariableForPaint(strokes[0], 'color', variable);
|
|
873
|
+
node.strokes = strokes;
|
|
874
|
+
}
|
|
875
|
+
return true;
|
|
876
|
+
} catch (e: any) {
|
|
877
|
+
debug('Failed to bind variable ' + tokenKey + ': ' + (e.message || e));
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Set the theme mode on a frame (only works on paid plans with multi-mode).
|
|
884
|
+
* On free plan this is a no-op (themes are separate collections instead).
|
|
885
|
+
*/
|
|
886
|
+
export function setThemeMode(frame: any, theme: string): void {
|
|
887
|
+
if (!_useMultiMode || !_variableCollection) return;
|
|
888
|
+
const normalized = normalizeThemeName(theme) || normalizeCustomThemeName(theme);
|
|
889
|
+
const resolvedTheme = (theme && _variableModeIds[theme]) ? theme :
|
|
890
|
+
((normalized && _variableModeIds[normalized]) ? normalized : _defaultThemeName);
|
|
891
|
+
const modeId = _variableModeIds[resolvedTheme];
|
|
892
|
+
if (!modeId) return;
|
|
893
|
+
try {
|
|
894
|
+
frame.setExplicitVariableModeForCollection(_variableCollection, modeId);
|
|
895
|
+
} catch (e: any) {
|
|
896
|
+
debug('Failed to set theme mode: ' + (e.message || e));
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
export function isMultiModeEnabled(): boolean {
|
|
901
|
+
return _useMultiMode;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
export function pxFromSizeToken(v: unknown): number {
|
|
905
|
+
const baseRem = 16;
|
|
906
|
+
const toPx = (t: string): number => {
|
|
907
|
+
t = String(t).trim();
|
|
908
|
+
if (t.endsWith('rem')) return parseFloat(t) * baseRem;
|
|
909
|
+
if (t.endsWith('px')) return parseFloat(t);
|
|
910
|
+
return parseFloat(t);
|
|
911
|
+
};
|
|
912
|
+
const sv = String(v || '').trim();
|
|
913
|
+
if (sv.startsWith('calc(') && sv.endsWith(')')) {
|
|
914
|
+
const inner = sv.slice(5, -1);
|
|
915
|
+
let total = 0;
|
|
916
|
+
let cur = '';
|
|
917
|
+
let op = '+';
|
|
918
|
+
for (const ch of inner) {
|
|
919
|
+
if (ch === '+' || ch === '-') {
|
|
920
|
+
if (cur.trim()) {
|
|
921
|
+
const val = toPx(cur);
|
|
922
|
+
total = op === '+' ? total + val : total - val;
|
|
923
|
+
cur = '';
|
|
924
|
+
}
|
|
925
|
+
op = ch;
|
|
926
|
+
} else {
|
|
927
|
+
cur += ch;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
if (cur.trim()) {
|
|
931
|
+
const val = toPx(cur);
|
|
932
|
+
total = op === '+' ? total + val : total - val;
|
|
933
|
+
}
|
|
934
|
+
return Math.max(0, Math.round(total));
|
|
935
|
+
}
|
|
936
|
+
return Math.max(0, Math.round(toPx(sv)));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Bind a frame's cornerRadius corners to a radius variable.
|
|
941
|
+
* radiusKey is a token key like 'md', 'lg', 'base', etc.
|
|
942
|
+
*/
|
|
943
|
+
export function bindRadiusVariable(node: any, radiusKey: string, _theme?: string): boolean {
|
|
944
|
+
const variable = _radiusVariables[radiusKey];
|
|
945
|
+
if (!variable) return false;
|
|
946
|
+
try {
|
|
947
|
+
node.setBoundVariable('topLeftRadius', variable);
|
|
948
|
+
node.setBoundVariable('topRightRadius', variable);
|
|
949
|
+
node.setBoundVariable('bottomLeftRadius', variable);
|
|
950
|
+
node.setBoundVariable('bottomRightRadius', variable);
|
|
951
|
+
return true;
|
|
952
|
+
} catch (e: any) {
|
|
953
|
+
debug('Failed to bind radius variable ' + radiusKey + ': ' + (e.message || e));
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
export function createOrUpdateStyles(): void {
|
|
959
|
+
// Remove stale paint styles (LIGHT/DARK/PRIMARY/SECONDARY)
|
|
960
|
+
removeStaleColorStyles();
|
|
961
|
+
// Create Figma Variables with dynamic theme modes/collections.
|
|
962
|
+
createOrUpdateVariables();
|
|
963
|
+
// Create Figma Effect Styles for shadow tokens
|
|
964
|
+
const shadowTokens = getThemeGroup(_defaultThemeName, 'shadow');
|
|
965
|
+
if (Object.keys(shadowTokens).length) {
|
|
966
|
+
populateShadowStyles(shadowTokens as Record<string, string>);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
export function ensureTokensPage(): any {
|
|
971
|
+
let page = figma.root.children.find((p: any) => p.name === 'Design Tokens');
|
|
972
|
+
if (!page) {
|
|
973
|
+
page = figma.createPage();
|
|
974
|
+
page.name = 'Design Tokens';
|
|
975
|
+
}
|
|
976
|
+
figma.currentPage = page;
|
|
977
|
+
return page;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
export function demoFrameColors(theme: string): any {
|
|
981
|
+
const frame = figma.createFrame();
|
|
982
|
+
frame.name = theme.toUpperCase() + ' Colors';
|
|
983
|
+
frame.layoutMode = 'VERTICAL';
|
|
984
|
+
frame.primaryAxisSizingMode = 'AUTO';
|
|
985
|
+
frame.counterAxisSizingMode = 'AUTO';
|
|
986
|
+
frame.itemSpacing = 12;
|
|
987
|
+
frame.paddingLeft = frame.paddingRight = frame.paddingTop = frame.paddingBottom = 16;
|
|
988
|
+
frame.fills = [];
|
|
989
|
+
frame.strokes = [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } }];
|
|
990
|
+
const col: Record<string, any> = ((TOKENS as any)[theme] && (TOKENS as any)[theme].color) ? (TOKENS as any)[theme].color : {};
|
|
991
|
+
let idx = 0;
|
|
992
|
+
for (const [key, val] of Object.entries(col)) {
|
|
993
|
+
const row = figma.createFrame();
|
|
994
|
+
row.layoutMode = 'HORIZONTAL';
|
|
995
|
+
row.primaryAxisSizingMode = 'AUTO';
|
|
996
|
+
row.counterAxisSizingMode = 'AUTO';
|
|
997
|
+
row.itemSpacing = 12;
|
|
998
|
+
row.name = key;
|
|
999
|
+
|
|
1000
|
+
const swatch = figma.createRectangle();
|
|
1001
|
+
swatch.resize(48, 32);
|
|
1002
|
+
const rgb = parseColor(val);
|
|
1003
|
+
swatch.fills = [{ type: 'SOLID', color: { r: rgb.r, g: rgb.g, b: rgb.b }, opacity: (rgb.a == null ? 1 : rgb.a) }];
|
|
1004
|
+
// Bind to Figma Variable so swatch updates when variable changes
|
|
1005
|
+
bindColorVariable(swatch, key, 'fill', theme);
|
|
1006
|
+
swatch.strokes = [];
|
|
1007
|
+
|
|
1008
|
+
const label = figma.createText();
|
|
1009
|
+
label.characters = key + ' \u2014 ' + colorToLabel(val);
|
|
1010
|
+
// Load default font for text placement
|
|
1011
|
+
// Note: plugin will await font load asynchronously below
|
|
1012
|
+
row.appendChild(swatch);
|
|
1013
|
+
row.appendChild(label);
|
|
1014
|
+
frame.appendChild(row);
|
|
1015
|
+
idx++;
|
|
1016
|
+
}
|
|
1017
|
+
return frame;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
export function demoFrameRadii(): any {
|
|
1021
|
+
const frame = figma.createFrame();
|
|
1022
|
+
frame.name = 'Radii';
|
|
1023
|
+
frame.layoutMode = 'HORIZONTAL';
|
|
1024
|
+
frame.primaryAxisSizingMode = 'AUTO';
|
|
1025
|
+
frame.counterAxisSizingMode = 'AUTO';
|
|
1026
|
+
frame.itemSpacing = 16;
|
|
1027
|
+
frame.paddingLeft = frame.paddingRight = frame.paddingTop = frame.paddingBottom = 16;
|
|
1028
|
+
frame.fills = [];
|
|
1029
|
+
frame.strokes = [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } }];
|
|
1030
|
+
const r: Record<string, any> = getThemeGroup(_defaultThemeName, 'radius');
|
|
1031
|
+
const keys = Object.keys(r);
|
|
1032
|
+
for (const k of keys) {
|
|
1033
|
+
const rect = figma.createRectangle();
|
|
1034
|
+
rect.resize(96, 64);
|
|
1035
|
+
rect.cornerRadius = pxFromSizeToken(r[k]);
|
|
1036
|
+
rect.fills = [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9 } }];
|
|
1037
|
+
rect.strokes = [{ type: 'SOLID', color: { r: 0.75, g: 0.75, b: 0.75 } }];
|
|
1038
|
+
rect.name = 'radius/' + k;
|
|
1039
|
+
frame.appendChild(rect);
|
|
1040
|
+
}
|
|
1041
|
+
return frame;
|
|
1042
|
+
}
|