@scattered-light/loomiere 0.3.0 → 0.4.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 +6 -1
- package/bin/loomiere.mjs +24 -0
- package/build/build-tokens.mjs +93 -49
- package/exporters/README.md +1 -1
- package/exporters/ag-grid-settings.mjs +7 -7
- package/exporters/ag-grid.mjs +6 -6
- package/exporters/chakra.mjs +43 -43
- package/exporters/component-styles.mjs +4 -4
- package/exporters/design-system.mjs +17 -12
- package/exporters/figma.mjs +774 -68
- package/exporters/mui.mjs +40 -40
- package/exporters/shadcn.mjs +5 -13
- package/exporters/tailwind.mjs +14 -5
- package/figma/README.md +135 -0
- package/figma/build-plugin.mjs +25 -0
- package/figma/engine.js +730 -0
- package/figma/plugin/code.js +746 -0
- package/figma/plugin/manifest.json +14 -0
- package/figma/plugin/ui.html +237 -0
- package/figma/plugin-main.js +14 -0
- package/generator/brand.mjs +16 -14
- package/generator/brand.schema.json +60 -0
- package/generator/generate.mjs +2 -2
- package/generator/pairs.mjs +6 -1
- package/generator/roles.mjs +55 -15
- package/generator/vision.mjs +7 -2
- package/lib/config.mjs +2 -0
- package/lib/contrast.mjs +8 -3
- package/lib/figma.mjs +338 -0
- package/lib/studio.mjs +4 -0
- package/lib/tool-server.mjs +7 -4
- package/package.json +5 -1
- package/tokens/README.md +10 -5
- package/tokens/component/avatar.json +41 -6
- package/tokens/semantic/color.json +1 -1
- package/tokens/semantic/elevation.json +8 -43
- package/tokens/semantic/typography.json +61 -94
- package/tools/brand-builder/builder.js +33 -4
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
// Generated by figma/build-plugin.mjs from engine.js and plugin-main.js. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* The Loomiere Figma sync engine: writes figma.json (exporters/figma.mjs) into a Figma file
|
|
4
|
+
* through the plugin API. Plain JavaScript with no imports, so the same text runs in three
|
|
5
|
+
* places: the Loomiere plugin (plugin/code.js), `loomiere figma push` scripts run through
|
|
6
|
+
* Claude's Figma connector, and Node (fingerprints only, lib/figma.mjs).
|
|
7
|
+
*
|
|
8
|
+
* await loomiereSync(data, options) → report
|
|
9
|
+
* options.dryRun plan only: nothing in the file changes
|
|
10
|
+
* options.layout 'auto' (default) uses modes when the plan allows them, else data.flat
|
|
11
|
+
* options.partial data holds only some variables (a push in parts): don't list the
|
|
12
|
+
* missing ones as removed
|
|
13
|
+
* options.complete with partial: groups ("Brand:default/color/primary") this part carries whole,
|
|
14
|
+
* so variables of those groups it doesn't carry are gone from the build
|
|
15
|
+
* options.removeGroups with partial: groups and collections the build no longer has,
|
|
16
|
+
* options.removeCollections worked out from a snapshot
|
|
17
|
+
* options.remove delete variables, modes, collections and text styles the build no longer has
|
|
18
|
+
* options.textStyles false skips text styles and effect styles
|
|
19
|
+
*
|
|
20
|
+
* Variables and styles are matched by name, so updating keeps their IDs and every layer bound
|
|
21
|
+
* to them stays bound. An alias to a variable that isn't there yet makes an empty one for a
|
|
22
|
+
* later part to fill.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/* global figma */
|
|
26
|
+
|
|
27
|
+
var LOOMIERE_FLAT_NAMES = ["Brand", "Color Pair", "Theme", "Theme dark"];
|
|
28
|
+
var LOOMIERE_MODE_NAMES = [
|
|
29
|
+
"Brand",
|
|
30
|
+
"Scale",
|
|
31
|
+
"Size",
|
|
32
|
+
"Theme",
|
|
33
|
+
"Status",
|
|
34
|
+
"Emphasis",
|
|
35
|
+
];
|
|
36
|
+
// Collections earlier builds made that no layout has now; a full sync with "remove" deletes them.
|
|
37
|
+
var LOOMIERE_RETIRED_NAMES = ["Color Pair", "Color Role"];
|
|
38
|
+
|
|
39
|
+
/** "#rrggbb" or "#rrggbbaa" → { r, g, b, a } in 0–1. */
|
|
40
|
+
function loomiereRgba(hex) {
|
|
41
|
+
var h = hex.replace("#", "");
|
|
42
|
+
var n = function (i) {
|
|
43
|
+
return parseInt(h.slice(i, i + 2), 16) / 255;
|
|
44
|
+
};
|
|
45
|
+
return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) : 1 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** { r, g, b, a } → "#rrggbbaa", the form fingerprints compare. */
|
|
49
|
+
function loomiereHex8(c) {
|
|
50
|
+
var p = function (v) {
|
|
51
|
+
return ("0" + Math.round(v * 255).toString(16)).slice(-2);
|
|
52
|
+
};
|
|
53
|
+
return "#" + p(c.r) + p(c.g) + p(c.b) + p(c.a === undefined ? 1 : c.a);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One value as text, so the build and the file can be compared. */
|
|
57
|
+
function loomiereCanon(type, v) {
|
|
58
|
+
if (v && typeof v === "object" && v.alias) return "@" + v.alias;
|
|
59
|
+
if (type === "COLOR")
|
|
60
|
+
return typeof v === "string"
|
|
61
|
+
? loomiereHex8(loomiereRgba(v))
|
|
62
|
+
: loomiereHex8(v);
|
|
63
|
+
if (type === "FLOAT") return String(Math.round(Number(v) * 1000) / 1000);
|
|
64
|
+
return String(v);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A description as the build wrote it: Figma hands descriptions back HTML-escaped (""", "'"). */
|
|
68
|
+
function loomiereText(text) {
|
|
69
|
+
return (text || "").replace(/&(quot|#39|lt|gt|amp);/g, function (m, e) {
|
|
70
|
+
return { quot: '"', "#39": "'", lt: "<", gt: ">", amp: "&" }[e];
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** FNV-1a, 32 bits, as 8 hex digits. */
|
|
75
|
+
function loomiereHash(text) {
|
|
76
|
+
var h = 0x811c9dc5;
|
|
77
|
+
for (var i = 0; i < text.length; i++) {
|
|
78
|
+
h ^= text.charCodeAt(i);
|
|
79
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
80
|
+
}
|
|
81
|
+
return ("0000000" + h.toString(16)).slice(-8);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A variable from figma.json → its fingerprint; the file side builds the same text. */
|
|
85
|
+
function loomiereFingerprint(collection, v) {
|
|
86
|
+
var parts = [v.type];
|
|
87
|
+
var modes = collection.modes.slice().sort();
|
|
88
|
+
for (var i = 0; i < modes.length; i++) {
|
|
89
|
+
var m = modes[i];
|
|
90
|
+
parts.push(
|
|
91
|
+
m +
|
|
92
|
+
"=" +
|
|
93
|
+
(v.values[m] === undefined ? "" : loomiereCanon(v.type, v.values[m])),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
parts.push(
|
|
97
|
+
(v.scopes || []).slice().sort().join(","),
|
|
98
|
+
v.code || "",
|
|
99
|
+
v.description || "",
|
|
100
|
+
collection.hidden ? "hidden" : "",
|
|
101
|
+
);
|
|
102
|
+
return loomiereHash(parts.join("|"));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function loomiereModeLimit() {
|
|
106
|
+
var probe = figma.variables.createVariableCollection("loomiere mode probe");
|
|
107
|
+
var limit = 1;
|
|
108
|
+
try {
|
|
109
|
+
for (; limit < 40; limit++) probe.addMode("m" + limit);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// The plan's limit: addMode refused the next one.
|
|
112
|
+
}
|
|
113
|
+
probe.remove();
|
|
114
|
+
return limit;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The modes layout trimmed to `limit` modes per collection, or the flat layout. */
|
|
118
|
+
function loomiereLayout(data, limit, layout, warnings) {
|
|
119
|
+
if (
|
|
120
|
+
layout === "flat" ||
|
|
121
|
+
(layout !== "modes" && limit < 2) ||
|
|
122
|
+
!data.collections
|
|
123
|
+
) {
|
|
124
|
+
if (data.flat) return { name: "flat", collections: data.flat.collections };
|
|
125
|
+
return { name: "modes", collections: data.collections };
|
|
126
|
+
}
|
|
127
|
+
var collections = data.collections.map(function (c) {
|
|
128
|
+
if (c.modes.length <= limit) return c;
|
|
129
|
+
warnings.push(
|
|
130
|
+
c.name +
|
|
131
|
+
" has " +
|
|
132
|
+
c.modes.length +
|
|
133
|
+
" modes and this plan allows " +
|
|
134
|
+
limit +
|
|
135
|
+
", so " +
|
|
136
|
+
c.modes.slice(limit).join(", ") +
|
|
137
|
+
" were left out.",
|
|
138
|
+
);
|
|
139
|
+
return Object.assign({}, c, { modes: c.modes.slice(0, limit) });
|
|
140
|
+
});
|
|
141
|
+
return { name: "modes", collections: collections };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function loomiereReadFile() {
|
|
145
|
+
var collections = await figma.variables.getLocalVariableCollectionsAsync();
|
|
146
|
+
var variables = await figma.variables.getLocalVariablesAsync();
|
|
147
|
+
var byId = {};
|
|
148
|
+
var collectionById = {};
|
|
149
|
+
collections.forEach(function (c) {
|
|
150
|
+
collectionById[c.id] = c;
|
|
151
|
+
});
|
|
152
|
+
variables.forEach(function (v) {
|
|
153
|
+
byId[v.id] = v;
|
|
154
|
+
});
|
|
155
|
+
var key = function (v) {
|
|
156
|
+
var c = collectionById[v.variableCollectionId];
|
|
157
|
+
return (c ? c.name : "?") + ":" + v.name;
|
|
158
|
+
};
|
|
159
|
+
var byKey = {};
|
|
160
|
+
variables.forEach(function (v) {
|
|
161
|
+
byKey[key(v)] = v;
|
|
162
|
+
});
|
|
163
|
+
return {
|
|
164
|
+
collections: collections,
|
|
165
|
+
variables: variables,
|
|
166
|
+
byId: byId,
|
|
167
|
+
byKey: byKey,
|
|
168
|
+
key: key,
|
|
169
|
+
collectionById: collectionById,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The file's variable in the same text form loomiereFingerprint uses for the build. */
|
|
174
|
+
function loomiereFileFingerprint(file, collection, v, modeNames) {
|
|
175
|
+
var parts = [v.resolvedType];
|
|
176
|
+
var names = (
|
|
177
|
+
modeNames ||
|
|
178
|
+
collection.modes.map(function (m) {
|
|
179
|
+
return m.name;
|
|
180
|
+
})
|
|
181
|
+
)
|
|
182
|
+
.slice()
|
|
183
|
+
.sort();
|
|
184
|
+
for (var i = 0; i < names.length; i++) {
|
|
185
|
+
var mode = collection.modes.filter(function (m) {
|
|
186
|
+
return m.name === names[i];
|
|
187
|
+
})[0];
|
|
188
|
+
var raw = mode && v.valuesByMode[mode.modeId];
|
|
189
|
+
var value =
|
|
190
|
+
raw && raw.type === "VARIABLE_ALIAS"
|
|
191
|
+
? { alias: file.byId[raw.id] ? file.key(file.byId[raw.id]) : "?" }
|
|
192
|
+
: raw;
|
|
193
|
+
parts.push(
|
|
194
|
+
names[i] +
|
|
195
|
+
"=" +
|
|
196
|
+
(raw === undefined ? "" : loomiereCanon(v.resolvedType, value)),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
var code = v.codeSyntax && v.codeSyntax.WEB ? v.codeSyntax.WEB : "";
|
|
200
|
+
parts.push(
|
|
201
|
+
v.scopes.slice().sort().join(","),
|
|
202
|
+
code,
|
|
203
|
+
loomiereText(v.description),
|
|
204
|
+
collection.hiddenFromPublishing ? "hidden" : "",
|
|
205
|
+
);
|
|
206
|
+
return loomiereHash(parts.join("|"));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A variable's group: its name up to the last slash ("color/primary/600" → "color/primary"). */
|
|
210
|
+
function loomiereGroupOf(name) {
|
|
211
|
+
var i = name.lastIndexOf("/");
|
|
212
|
+
return i === -1 ? "" : name.slice(0, i);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** One hash for a group's variables: names and fingerprints, in name order. */
|
|
216
|
+
function loomiereGroupHash(entries) {
|
|
217
|
+
return loomiereHash(
|
|
218
|
+
entries
|
|
219
|
+
.sort(function (a, b) {
|
|
220
|
+
return a[0] < b[0] ? -1 : 1;
|
|
221
|
+
})
|
|
222
|
+
.map(function (e) {
|
|
223
|
+
return e[0] + "=" + e[1];
|
|
224
|
+
})
|
|
225
|
+
.join(";"),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* What the file has, small enough to pass back: the plan's mode limit, the Loomiere
|
|
231
|
+
* collections, and one hash per group of variables ("Brand:default/color/primary"). A push compares
|
|
232
|
+
* these with the build's and sends only the groups that differ.
|
|
233
|
+
*/
|
|
234
|
+
async function loomiereSnapshot() {
|
|
235
|
+
var file = await loomiereReadFile();
|
|
236
|
+
var names = LOOMIERE_MODE_NAMES.concat(LOOMIERE_FLAT_NAMES);
|
|
237
|
+
var groups = {};
|
|
238
|
+
file.variables.forEach(function (v) {
|
|
239
|
+
var c = file.collectionById[v.variableCollectionId];
|
|
240
|
+
if (!c || names.indexOf(c.name) === -1) return;
|
|
241
|
+
var g = c.name + ":" + loomiereGroupOf(v.name);
|
|
242
|
+
(groups[g] = groups[g] || []).push([
|
|
243
|
+
v.name,
|
|
244
|
+
loomiereFileFingerprint(file, c, v),
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
var out = {};
|
|
248
|
+
Object.keys(groups).forEach(function (g) {
|
|
249
|
+
out[g] = loomiereGroupHash(groups[g]);
|
|
250
|
+
});
|
|
251
|
+
var present = file.collections
|
|
252
|
+
.map(function (c) {
|
|
253
|
+
return c.name;
|
|
254
|
+
})
|
|
255
|
+
.filter(function (n) {
|
|
256
|
+
return names.indexOf(n) !== -1;
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
modeLimit: await loomiereModeLimit(),
|
|
260
|
+
collections: present,
|
|
261
|
+
groups: out,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
var LOOMIERE_WEIGHTS = {
|
|
266
|
+
100: ["Thin", "Hairline"],
|
|
267
|
+
200: ["ExtraLight", "Extra Light", "UltraLight", "Ultra Light"],
|
|
268
|
+
300: ["Light"],
|
|
269
|
+
400: ["Regular", "Normal", "Book", "Roman"],
|
|
270
|
+
500: ["Medium"],
|
|
271
|
+
600: ["SemiBold", "Semi Bold", "Semibold", "DemiBold", "Demi Bold"],
|
|
272
|
+
700: ["Bold"],
|
|
273
|
+
800: ["ExtraBold", "Extra Bold", "UltraBold", "Ultra Bold", "Heavy"],
|
|
274
|
+
900: ["Black", "Heavy"],
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** The installed style of `family` closest to `weight`, upright. */
|
|
278
|
+
function loomierePickFont(fonts, family, weight) {
|
|
279
|
+
var styles = fonts
|
|
280
|
+
.filter(function (f) {
|
|
281
|
+
return (
|
|
282
|
+
f.fontName.family === family &&
|
|
283
|
+
!/italic|oblique/i.test(f.fontName.style)
|
|
284
|
+
);
|
|
285
|
+
})
|
|
286
|
+
.map(function (f) {
|
|
287
|
+
return f.fontName.style;
|
|
288
|
+
});
|
|
289
|
+
if (!styles.length) return null;
|
|
290
|
+
var tries = [
|
|
291
|
+
weight,
|
|
292
|
+
weight + 100,
|
|
293
|
+
weight - 100,
|
|
294
|
+
weight + 200,
|
|
295
|
+
weight - 200,
|
|
296
|
+
400,
|
|
297
|
+
];
|
|
298
|
+
for (var i = 0; i < tries.length; i++) {
|
|
299
|
+
var names = LOOMIERE_WEIGHTS[tries[i]] || [];
|
|
300
|
+
for (var j = 0; j < names.length; j++)
|
|
301
|
+
if (styles.indexOf(names[j]) !== -1)
|
|
302
|
+
return { family: family, style: names[j] };
|
|
303
|
+
}
|
|
304
|
+
return { family: family, style: styles[0] };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Files synced before the collections were renamed (Scheme, Theme for pairs, Color, Pairs, Size
|
|
309
|
+
* base) get them renamed in place, and Brand's colors moved into their theme groups, so their IDs
|
|
310
|
+
* and every layer bound to them stay. Returns what it renamed.
|
|
311
|
+
*/
|
|
312
|
+
async function loomiereMigrate(dryRun) {
|
|
313
|
+
var collections = await figma.variables.getLocalVariableCollectionsAsync();
|
|
314
|
+
var byName = {};
|
|
315
|
+
collections.forEach(function (c) {
|
|
316
|
+
byName[c.name] = c;
|
|
317
|
+
});
|
|
318
|
+
var renamed = [];
|
|
319
|
+
var rename = function (from, to) {
|
|
320
|
+
if (!byName[from] || byName[to]) return;
|
|
321
|
+
renamed.push("collection " + from + " → " + to);
|
|
322
|
+
if (!dryRun) byName[from].name = to;
|
|
323
|
+
byName[to] = byName[from];
|
|
324
|
+
delete byName[from];
|
|
325
|
+
};
|
|
326
|
+
if (byName.Scheme) {
|
|
327
|
+
rename("Theme", "Color Pair");
|
|
328
|
+
rename("Scheme", "Theme");
|
|
329
|
+
rename("Color", "Color Role");
|
|
330
|
+
var brand = byName.Brand;
|
|
331
|
+
if (brand) {
|
|
332
|
+
var variables = await figma.variables.getLocalVariablesAsync();
|
|
333
|
+
variables.forEach(function (v) {
|
|
334
|
+
if (v.variableCollectionId !== brand.id) return;
|
|
335
|
+
var to = /^color\//.test(v.name)
|
|
336
|
+
? "default/" + v.name
|
|
337
|
+
: v.name.replace(/^vision\/([^/]+)\//, "$1/");
|
|
338
|
+
if (to === v.name) return;
|
|
339
|
+
renamed.push("Brand:" + v.name + " → " + to);
|
|
340
|
+
if (!dryRun) v.name = to;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
rename("Pairs", "Color Pair");
|
|
345
|
+
rename("Size base", "Scale");
|
|
346
|
+
return renamed;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function loomiereSync(data, options) {
|
|
350
|
+
options = options || {};
|
|
351
|
+
if (!data || data.format !== "loomiere-figma")
|
|
352
|
+
throw new Error(
|
|
353
|
+
"This is not a Loomiere figma.json (loomiere build writes it to exports/figma.json).",
|
|
354
|
+
);
|
|
355
|
+
var dryRun = !!options.dryRun;
|
|
356
|
+
var warnings = [];
|
|
357
|
+
var report = {
|
|
358
|
+
dryRun: dryRun,
|
|
359
|
+
brand: data.brand,
|
|
360
|
+
created: [],
|
|
361
|
+
updated: [],
|
|
362
|
+
unchanged: 0,
|
|
363
|
+
removed: [],
|
|
364
|
+
modesAdded: [],
|
|
365
|
+
styles: { created: [], updated: [], unchanged: 0 },
|
|
366
|
+
warnings: warnings,
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
var limit = options.modeLimit || (await loomiereModeLimit());
|
|
370
|
+
var layout = loomiereLayout(data, limit, options.layout || "auto", warnings);
|
|
371
|
+
report.layout = layout.name;
|
|
372
|
+
report.modeLimit = limit;
|
|
373
|
+
|
|
374
|
+
report.renamed = await loomiereMigrate(dryRun);
|
|
375
|
+
var file = await loomiereReadFile();
|
|
376
|
+
var collectionsByName = {};
|
|
377
|
+
file.collections.forEach(function (c) {
|
|
378
|
+
collectionsByName[c.name] = c;
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Collections and modes.
|
|
382
|
+
var live = {};
|
|
383
|
+
layout.collections.forEach(function (c) {
|
|
384
|
+
var col = collectionsByName[c.name];
|
|
385
|
+
var renamed = null;
|
|
386
|
+
if (!col) {
|
|
387
|
+
report.created.push("collection " + c.name);
|
|
388
|
+
if (dryRun) return;
|
|
389
|
+
col = figma.variables.createVariableCollection(c.name);
|
|
390
|
+
col.renameMode(col.modes[0].modeId, c.modes[0]);
|
|
391
|
+
} else if (
|
|
392
|
+
!col.modes.some(function (x) {
|
|
393
|
+
return x.name === c.modes[0];
|
|
394
|
+
}) &&
|
|
395
|
+
c.modes.indexOf(col.modes[0].name) === -1
|
|
396
|
+
) {
|
|
397
|
+
// A file moving between layouts (Starter's "Value" or "Light" to the modes' "Default"):
|
|
398
|
+
// the first mode takes the new name so its values, and what is bound to them, carry over.
|
|
399
|
+
renamed = col.modes[0].modeId;
|
|
400
|
+
report.modesAdded.push(
|
|
401
|
+
c.name + ": " + col.modes[0].name + " renamed " + c.modes[0],
|
|
402
|
+
);
|
|
403
|
+
if (!dryRun) col.renameMode(col.modes[0].modeId, c.modes[0]);
|
|
404
|
+
}
|
|
405
|
+
c.modes.forEach(function (m) {
|
|
406
|
+
if (
|
|
407
|
+
col.modes.some(function (x) {
|
|
408
|
+
return x.name === m;
|
|
409
|
+
})
|
|
410
|
+
)
|
|
411
|
+
return;
|
|
412
|
+
report.modesAdded.push(c.name + ": " + m);
|
|
413
|
+
if (!dryRun) col.addMode(m);
|
|
414
|
+
});
|
|
415
|
+
col.modes.slice().forEach(function (x) {
|
|
416
|
+
if (x.modeId === renamed || c.modes.indexOf(x.name) !== -1) return;
|
|
417
|
+
report.removed.push(c.name + " mode " + x.name);
|
|
418
|
+
if (options.remove && !dryRun) col.removeMode(x.modeId);
|
|
419
|
+
});
|
|
420
|
+
if (!dryRun && !!c.hidden !== col.hiddenFromPublishing)
|
|
421
|
+
col.hiddenFromPublishing = !!c.hidden;
|
|
422
|
+
live[c.name] = col;
|
|
423
|
+
});
|
|
424
|
+
if (!dryRun) file = await loomiereReadFile();
|
|
425
|
+
|
|
426
|
+
var modeId = function (col, name) {
|
|
427
|
+
var m = col.modes.filter(function (x) {
|
|
428
|
+
return x.name === name;
|
|
429
|
+
})[0];
|
|
430
|
+
return m && m.modeId;
|
|
431
|
+
};
|
|
432
|
+
var typeOf = {};
|
|
433
|
+
layout.collections.forEach(function (c) {
|
|
434
|
+
c.variables.forEach(function (v) {
|
|
435
|
+
typeOf[c.name + ":" + v.name] = v.type;
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// Make every variable first (and any alias target a later part fills), then set values.
|
|
440
|
+
var ensure = function (key, type) {
|
|
441
|
+
if (file.byKey[key]) return file.byKey[key];
|
|
442
|
+
var i = key.indexOf(":");
|
|
443
|
+
var col =
|
|
444
|
+
live[key.slice(0, i)] ||
|
|
445
|
+
file.collections.filter(function (c) {
|
|
446
|
+
return c.name === key.slice(0, i);
|
|
447
|
+
})[0];
|
|
448
|
+
if (!col) throw new Error("No collection for " + key);
|
|
449
|
+
var v = figma.variables.createVariable(key.slice(i + 1), col, type);
|
|
450
|
+
file.byKey[key] = v;
|
|
451
|
+
file.byId[v.id] = v;
|
|
452
|
+
return v;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
var wanted = {};
|
|
456
|
+
layout.collections.forEach(function (c) {
|
|
457
|
+
var col = live[c.name];
|
|
458
|
+
c.variables.forEach(function (v) {
|
|
459
|
+
var key = c.name + ":" + v.name;
|
|
460
|
+
wanted[key] = true;
|
|
461
|
+
var existing = file.byKey[key];
|
|
462
|
+
if (existing && existing.resolvedType !== v.type) {
|
|
463
|
+
warnings.push(
|
|
464
|
+
key +
|
|
465
|
+
" is " +
|
|
466
|
+
existing.resolvedType +
|
|
467
|
+
" in the file and " +
|
|
468
|
+
v.type +
|
|
469
|
+
" in the build; it was replaced.",
|
|
470
|
+
);
|
|
471
|
+
if (!dryRun) existing.remove();
|
|
472
|
+
delete file.byKey[key];
|
|
473
|
+
existing = null;
|
|
474
|
+
}
|
|
475
|
+
var colNode =
|
|
476
|
+
col && file.collectionById[col.id] ? file.collectionById[col.id] : col;
|
|
477
|
+
var same =
|
|
478
|
+
existing &&
|
|
479
|
+
colNode &&
|
|
480
|
+
loomiereFileFingerprint(file, colNode, existing, c.modes) ===
|
|
481
|
+
loomiereFingerprint(c, v);
|
|
482
|
+
if (same) {
|
|
483
|
+
report.unchanged++;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
(existing ? report.updated : report.created).push(key);
|
|
487
|
+
if (dryRun) return;
|
|
488
|
+
var target = ensure(key, v.type);
|
|
489
|
+
c.modes.forEach(function (m) {
|
|
490
|
+
var value = v.values[m];
|
|
491
|
+
if (value === undefined || value === null) return;
|
|
492
|
+
var id = modeId(col, m);
|
|
493
|
+
if (value.alias) {
|
|
494
|
+
var to = ensure(value.alias, typeOf[value.alias] || v.type);
|
|
495
|
+
target.setValueForMode(id, figma.variables.createVariableAlias(to));
|
|
496
|
+
} else if (v.type === "COLOR")
|
|
497
|
+
target.setValueForMode(id, loomiereRgba(value));
|
|
498
|
+
else
|
|
499
|
+
target.setValueForMode(
|
|
500
|
+
id,
|
|
501
|
+
v.type === "FLOAT" ? Number(value) : value,
|
|
502
|
+
);
|
|
503
|
+
});
|
|
504
|
+
target.scopes = v.scopes || [];
|
|
505
|
+
if (v.code) target.setVariableCodeSyntax("WEB", v.code);
|
|
506
|
+
target.description = v.description || "";
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// What the build no longer has. A whole run works it out; a push in parts is told
|
|
511
|
+
// (options.removeKeys, options.removeCollections), since each part sees only its own variables.
|
|
512
|
+
var ours = layout.collections.map(function (c) {
|
|
513
|
+
return c.name;
|
|
514
|
+
});
|
|
515
|
+
var other = (
|
|
516
|
+
layout.name === "modes" ? LOOMIERE_FLAT_NAMES : LOOMIERE_MODE_NAMES
|
|
517
|
+
).concat(LOOMIERE_RETIRED_NAMES);
|
|
518
|
+
var goneVars = [];
|
|
519
|
+
var goneCollections = [];
|
|
520
|
+
if (options.partial) {
|
|
521
|
+
var complete = options.complete || [];
|
|
522
|
+
var dropped = options.removeGroups || [];
|
|
523
|
+
file.variables.forEach(function (v) {
|
|
524
|
+
var c = file.collectionById[v.variableCollectionId];
|
|
525
|
+
if (!c || ours.indexOf(c.name) === -1 || wanted[file.key(v)]) return;
|
|
526
|
+
var g = c.name + ":" + loomiereGroupOf(v.name);
|
|
527
|
+
if (complete.indexOf(g) !== -1 || dropped.indexOf(g) !== -1)
|
|
528
|
+
goneVars.push(v);
|
|
529
|
+
});
|
|
530
|
+
file.collections.forEach(function (c) {
|
|
531
|
+
if ((options.removeCollections || []).indexOf(c.name) !== -1)
|
|
532
|
+
goneCollections.push(c);
|
|
533
|
+
});
|
|
534
|
+
} else {
|
|
535
|
+
file.variables.forEach(function (v) {
|
|
536
|
+
var c = file.collectionById[v.variableCollectionId];
|
|
537
|
+
if (c && ours.indexOf(c.name) !== -1 && !wanted[file.key(v)])
|
|
538
|
+
goneVars.push(v);
|
|
539
|
+
});
|
|
540
|
+
file.collections.forEach(function (c) {
|
|
541
|
+
if (ours.indexOf(c.name) === -1 && other.indexOf(c.name) !== -1)
|
|
542
|
+
goneCollections.push(c);
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
goneVars.forEach(function (v) {
|
|
546
|
+
report.removed.push(file.key(v));
|
|
547
|
+
if (options.remove && !dryRun) v.remove();
|
|
548
|
+
});
|
|
549
|
+
goneCollections.forEach(function (c) {
|
|
550
|
+
report.removed.push(
|
|
551
|
+
"collection " +
|
|
552
|
+
c.name +
|
|
553
|
+
" (the " +
|
|
554
|
+
(layout.name === "modes" ? "one-mode" : "modes") +
|
|
555
|
+
" layout)",
|
|
556
|
+
);
|
|
557
|
+
if (options.remove && !dryRun) c.remove();
|
|
558
|
+
});
|
|
559
|
+
if (report.removed.length && !options.remove)
|
|
560
|
+
warnings.push(
|
|
561
|
+
report.removed.length +
|
|
562
|
+
' variables or collections are no longer in the build. Sync with "remove" to delete them.',
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
if (options.textStyles !== false && data.textStyles)
|
|
566
|
+
await loomiereTextStyles(data, layout, file, report, options);
|
|
567
|
+
if (options.textStyles !== false && data.effectStyles)
|
|
568
|
+
await loomiereEffectStyles(data, layout, file, report, options);
|
|
569
|
+
return report;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Effect styles: elevation steps and focus looks. A shadow color that is a Theme variable stays bound. */
|
|
573
|
+
async function loomiereEffectStyles(data, layout, file, report, options) {
|
|
574
|
+
var dryRun = !!options.dryRun;
|
|
575
|
+
var styles = await figma.getLocalEffectStylesAsync();
|
|
576
|
+
var byName = {};
|
|
577
|
+
styles.forEach(function (s) {
|
|
578
|
+
byName[s.name] = s;
|
|
579
|
+
});
|
|
580
|
+
var key = function (effects) {
|
|
581
|
+
return JSON.stringify(
|
|
582
|
+
effects.map(function (e) {
|
|
583
|
+
var bound = e.boundVariables && e.boundVariables.color;
|
|
584
|
+
return [
|
|
585
|
+
e.type,
|
|
586
|
+
e.offset.x,
|
|
587
|
+
e.offset.y,
|
|
588
|
+
e.radius,
|
|
589
|
+
e.spread || 0,
|
|
590
|
+
loomiereHex8(e.color),
|
|
591
|
+
bound ? bound.id : null,
|
|
592
|
+
];
|
|
593
|
+
}),
|
|
594
|
+
);
|
|
595
|
+
};
|
|
596
|
+
for (var i = 0; i < data.effectStyles.length; i++) {
|
|
597
|
+
var t = data.effectStyles[i];
|
|
598
|
+
var effects = t.effects.map(function (e) {
|
|
599
|
+
var effect = {
|
|
600
|
+
type: e.type,
|
|
601
|
+
color: loomiereRgba(e.color),
|
|
602
|
+
offset: { x: e.x, y: e.y },
|
|
603
|
+
radius: e.blur,
|
|
604
|
+
spread: e.spread,
|
|
605
|
+
visible: true,
|
|
606
|
+
blendMode: "NORMAL",
|
|
607
|
+
showShadowBehindNode: false,
|
|
608
|
+
};
|
|
609
|
+
var v =
|
|
610
|
+
layout.name === "modes" && e.colorVariable
|
|
611
|
+
? file.byKey[e.colorVariable]
|
|
612
|
+
: null;
|
|
613
|
+
return v
|
|
614
|
+
? figma.variables.setBoundVariableForEffect(effect, "color", v)
|
|
615
|
+
: effect;
|
|
616
|
+
});
|
|
617
|
+
var s = byName[t.name];
|
|
618
|
+
if (
|
|
619
|
+
s &&
|
|
620
|
+
key(s.effects) === key(effects) &&
|
|
621
|
+
loomiereText(s.description) === t.description
|
|
622
|
+
) {
|
|
623
|
+
report.styles.unchanged++;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
(s ? report.styles.updated : report.styles.created).push(t.name);
|
|
627
|
+
if (dryRun) continue;
|
|
628
|
+
if (!s) {
|
|
629
|
+
s = figma.createEffectStyle();
|
|
630
|
+
s.name = t.name;
|
|
631
|
+
}
|
|
632
|
+
s.effects = effects;
|
|
633
|
+
s.description = t.description;
|
|
634
|
+
}
|
|
635
|
+
var names = data.effectStyles.map(function (t) {
|
|
636
|
+
return t.name;
|
|
637
|
+
});
|
|
638
|
+
styles.forEach(function (s) {
|
|
639
|
+
if (
|
|
640
|
+
names.indexOf(s.name) !== -1 ||
|
|
641
|
+
!/\nCSS: /.test(loomiereText(s.description))
|
|
642
|
+
)
|
|
643
|
+
return;
|
|
644
|
+
report.removed.push("effect style " + s.name);
|
|
645
|
+
if (options.remove && !dryRun) s.remove();
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
async function loomiereTextStyles(data, layout, file, report, options) {
|
|
650
|
+
var dryRun = !!options.dryRun;
|
|
651
|
+
var styles = await figma.getLocalTextStylesAsync();
|
|
652
|
+
var byName = {};
|
|
653
|
+
styles.forEach(function (s) {
|
|
654
|
+
byName[s.name] = s;
|
|
655
|
+
});
|
|
656
|
+
var fonts = await figma.listAvailableFontsAsync();
|
|
657
|
+
var sizeMode = layout.name === "modes" ? "Desktop" : null;
|
|
658
|
+
for (var i = 0; i < data.textStyles.length; i++) {
|
|
659
|
+
var t = data.textStyles[i];
|
|
660
|
+
var font = loomierePickFont(fonts, t.family, t.weight);
|
|
661
|
+
if (!font) {
|
|
662
|
+
report.warnings.push(
|
|
663
|
+
"Text style " +
|
|
664
|
+
t.name +
|
|
665
|
+
": " +
|
|
666
|
+
t.family +
|
|
667
|
+
" is not installed in Figma, so it was skipped.",
|
|
668
|
+
);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
var size = t.size[sizeMode || "Desktop"];
|
|
672
|
+
var sizeVar =
|
|
673
|
+
layout.name === "modes" && t.sizeVariable
|
|
674
|
+
? file.byKey[t.sizeVariable]
|
|
675
|
+
: null;
|
|
676
|
+
var familyVar =
|
|
677
|
+
layout.name === "modes" && t.familyVariable
|
|
678
|
+
? file.byKey[t.familyVariable]
|
|
679
|
+
: null;
|
|
680
|
+
var s = byName[t.name];
|
|
681
|
+
var same =
|
|
682
|
+
s &&
|
|
683
|
+
s.fontName.family === font.family &&
|
|
684
|
+
s.fontName.style === font.style &&
|
|
685
|
+
Math.abs(s.fontSize - size) < 0.01 &&
|
|
686
|
+
s.lineHeight.unit === t.lineHeight.unit &&
|
|
687
|
+
Math.abs((s.lineHeight.value || 0) - (t.lineHeight.value || 0)) < 0.01 &&
|
|
688
|
+
s.letterSpacing.unit === t.letterSpacing.unit &&
|
|
689
|
+
Math.abs(s.letterSpacing.value - t.letterSpacing.value) < 0.01 &&
|
|
690
|
+
s.textCase === (t.textCase || "ORIGINAL") &&
|
|
691
|
+
loomiereText(s.description) === t.description &&
|
|
692
|
+
(!sizeVar ||
|
|
693
|
+
(s.boundVariables &&
|
|
694
|
+
s.boundVariables.fontSize &&
|
|
695
|
+
s.boundVariables.fontSize.id === sizeVar.id));
|
|
696
|
+
if (same) {
|
|
697
|
+
report.styles.unchanged++;
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
(s ? report.styles.updated : report.styles.created).push(t.name);
|
|
701
|
+
if (dryRun) continue;
|
|
702
|
+
await figma.loadFontAsync(font);
|
|
703
|
+
if (s) await figma.loadFontAsync(s.fontName);
|
|
704
|
+
if (!s) {
|
|
705
|
+
s = figma.createTextStyle();
|
|
706
|
+
s.name = t.name;
|
|
707
|
+
}
|
|
708
|
+
s.fontName = font;
|
|
709
|
+
s.fontSize = size;
|
|
710
|
+
s.lineHeight = t.lineHeight;
|
|
711
|
+
s.letterSpacing = t.letterSpacing;
|
|
712
|
+
s.textCase = t.textCase || "ORIGINAL";
|
|
713
|
+
s.description = t.description;
|
|
714
|
+
if (sizeVar) s.setBoundVariable("fontSize", sizeVar);
|
|
715
|
+
if (familyVar) s.setBoundVariable("fontFamily", familyVar);
|
|
716
|
+
}
|
|
717
|
+
{
|
|
718
|
+
var names = data.textStyles.map(function (t) {
|
|
719
|
+
return t.name;
|
|
720
|
+
});
|
|
721
|
+
styles.forEach(function (s) {
|
|
722
|
+
if (
|
|
723
|
+
names.indexOf(s.name) !== -1 ||
|
|
724
|
+
s.description.indexOf("CSS: .text-style-") === -1
|
|
725
|
+
)
|
|
726
|
+
return;
|
|
727
|
+
report.removed.push("text style " + s.name);
|
|
728
|
+
if (options.remove && !dryRun) s.remove();
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/* The Loomiere plugin: the sync engine above, run on a figma.json the UI hands it. */
|
|
734
|
+
/* global figma, __html__, loomiereSync */
|
|
735
|
+
|
|
736
|
+
figma.showUI(__html__, { width: 420, height: 600, themeColors: true });
|
|
737
|
+
|
|
738
|
+
figma.ui.onmessage = async function (msg) {
|
|
739
|
+
if (msg.type !== 'sync') return;
|
|
740
|
+
try {
|
|
741
|
+
var report = await loomiereSync(msg.data, msg.options);
|
|
742
|
+
figma.ui.postMessage({ type: 'report', report: report });
|
|
743
|
+
} catch (err) {
|
|
744
|
+
figma.ui.postMessage({ type: 'error', message: String((err && err.message) || err) });
|
|
745
|
+
}
|
|
746
|
+
};
|