@scattered-light/loomiere 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +6 -1
  2. package/bin/loomiere.mjs +24 -0
  3. package/build/build-tokens.mjs +93 -49
  4. package/exporters/README.md +1 -1
  5. package/exporters/ag-grid-settings.mjs +7 -7
  6. package/exporters/ag-grid.mjs +6 -6
  7. package/exporters/chakra.mjs +43 -43
  8. package/exporters/component-styles.mjs +4 -4
  9. package/exporters/design-system.mjs +17 -12
  10. package/exporters/figma.mjs +774 -68
  11. package/exporters/mui.mjs +40 -40
  12. package/exporters/shadcn.mjs +5 -13
  13. package/exporters/tailwind.mjs +14 -5
  14. package/figma/README.md +136 -0
  15. package/figma/build-plugin.mjs +25 -0
  16. package/figma/engine.js +730 -0
  17. package/figma/library/README.md +92 -0
  18. package/figma/library/check/known-differences.md +20 -0
  19. package/figma/library/check/mojave.json +246 -0
  20. package/figma/library/kit/base.js +89 -0
  21. package/figma/library/kit/components.js +95 -0
  22. package/figma/library/kit/docs.js +37 -0
  23. package/figma/library/kit/fingerprint.js +143 -0
  24. package/figma/library/pages/02-palettes.js +40 -0
  25. package/figma/library/pages/03-color.js +126 -0
  26. package/figma/library/pages/04-type.js +56 -0
  27. package/figma/library/pages/05-scale.js +58 -0
  28. package/figma/library/pages/06-size.js +111 -0
  29. package/figma/library/pages/07-elevation-focus-motion.js +108 -0
  30. package/figma/library/pages/08-themes.js +36 -0
  31. package/figma/library/pages/09-fields/01-input.js +55 -0
  32. package/figma/library/pages/09-fields/02-checks.js +82 -0
  33. package/figma/library/pages/09-fields/03-field.js +32 -0
  34. package/figma/library/pages/09-fields/04-more-fields.js +144 -0
  35. package/figma/library/pages/09-fields/05-labels.js +14 -0
  36. package/figma/library/pages/09-fields/06-doc.js +75 -0
  37. package/figma/library/pages/10-buttons/01-buttons.js +97 -0
  38. package/figma/library/pages/10-buttons/02-doc.js +55 -0
  39. package/figma/library/pages/11-popups/01-popups.js +166 -0
  40. package/figma/library/pages/11-popups/02-more-popups.js +90 -0
  41. package/figma/library/pages/11-popups/03-labels.js +14 -0
  42. package/figma/library/pages/11-popups/04-doc.js +89 -0
  43. package/figma/library/pages/12-overlays/01-overlays.js +135 -0
  44. package/figma/library/pages/12-overlays/02-labels.js +10 -0
  45. package/figma/library/pages/12-overlays/03-doc.js +80 -0
  46. package/figma/library/pages/13-disclosure/01-disclosure.js +205 -0
  47. package/figma/library/pages/13-disclosure/02-collapsible.js +39 -0
  48. package/figma/library/pages/13-disclosure/03-labels.js +16 -0
  49. package/figma/library/pages/13-disclosure/04-doc.js +91 -0
  50. package/figma/library/pages/14-display/01-display.js +165 -0
  51. package/figma/library/pages/14-display/02-more-display.js +55 -0
  52. package/figma/library/pages/14-display/03-labels.js +14 -0
  53. package/figma/library/pages/14-display/04-doc.js +96 -0
  54. package/figma/library/pages/15-cover.js +87 -0
  55. package/figma/library/run-order.json +21 -0
  56. package/figma/library/tools/compare.mjs +11 -0
  57. package/figma/library/tools/diff-snippet.js +15 -0
  58. package/figma/library/tools/kit-script.mjs +21 -0
  59. package/figma/library/tools/run.mjs +27 -0
  60. package/figma/library/tools/runs.mjs +34 -0
  61. package/figma/plugin/code.js +746 -0
  62. package/figma/plugin/manifest.json +14 -0
  63. package/figma/plugin/ui.html +237 -0
  64. package/figma/plugin-main.js +14 -0
  65. package/generator/brand.mjs +16 -14
  66. package/generator/brand.schema.json +60 -0
  67. package/generator/generate.mjs +2 -2
  68. package/generator/pairs.mjs +6 -1
  69. package/generator/roles.mjs +55 -15
  70. package/generator/vision.mjs +7 -2
  71. package/lib/config.mjs +2 -0
  72. package/lib/contrast.mjs +8 -3
  73. package/lib/figma.mjs +338 -0
  74. package/lib/studio.mjs +4 -0
  75. package/lib/tool-server.mjs +7 -4
  76. package/package.json +5 -1
  77. package/tokens/README.md +10 -5
  78. package/tokens/component/avatar.json +41 -6
  79. package/tokens/semantic/color.json +1 -1
  80. package/tokens/semantic/elevation.json +8 -43
  81. package/tokens/semantic/typography.json +61 -94
  82. 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
+ };