@scattered-light/loomiere 0.5.1 → 0.5.3

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.
@@ -285,6 +285,34 @@ const inLook = (selectors, name) =>
285
285
  .map((s) => `${s.trim()}[data-look='${name}']`)
286
286
  .join(', ');
287
287
 
288
+ /** Pair class name → Map of variable → value, read from pairBlocks' text. */
289
+ const pairDeclarations = (text) =>
290
+ new Map([...(text ?? '').matchAll(/^\.[\w-]+-pair-([\w-]+) \{\n([\s\S]*?)\n\}/gm)].map(([, name, body]) => [name, declarationsIn(body)]));
291
+
292
+ /**
293
+ * What a look's pair classes need beyond the brand's. The pairs and their re-pointed parts are
294
+ * the brand's, but a look's `tokens` can change the tokens that lead to them: a token the look
295
+ * points elsewhere (or newly at a re-pointed one) gets the look's value inside each pair, under
296
+ * [data-look='<name>'] .<prefix>-pair-<pair>, which outranks the brand's pair class.
297
+ */
298
+ function lookPairBlocks(name, parts, base) {
299
+ const own = pairDeclarations(parts.rules.pairs);
300
+ const brand = pairDeclarations(base.rules.pairs);
301
+ const names = (m) => [...m.keys()].sort().join(',');
302
+ if (names(own) !== names(brand)) throw new Error(`Look "${name}" changes the pairs rules, not only values. A look can change colors, fonts and sizes, but it has to keep the brand's palettes, pairs, statuses and emphasis levels.`);
303
+ const blocks = [];
304
+ for (const [pair, decls] of brand) {
305
+ const mine = own.get(pair);
306
+ const lines = [];
307
+ for (const [v, value] of mine) if (decls.get(v) !== value) lines.push(`${v}: ${value};`);
308
+ // No longer leads to a re-pointed token in this look: restate the look's own value so the
309
+ // brand's declaration in the pair class doesn't win.
310
+ for (const v of decls.keys()) if (!mine.has(v) && parts.root[v] !== undefined) lines.push(`${v}: ${parts.root[v]};`);
311
+ if (lines.length) blocks.push(`[data-look='${name}'] .${prefix}-pair-${pair} {\n${indent(lines.join('\n'), 2)}\n}`);
312
+ }
313
+ return blocks.join('\n\n');
314
+ }
315
+
288
316
  /** [rootBlocks, modeBlocks] for every look against the brand's own parts. */
289
317
  function lookBlocks(dictionary, base) {
290
318
  const roots = [];
@@ -293,8 +321,12 @@ function lookBlocks(dictionary, base) {
293
321
  for (const [rule, text] of Object.entries(parts.rules)) {
294
322
  // Color vision blocks list the data colors by name, so a look with its own names has its own.
295
323
  if (rule === 'vision') continue;
324
+ // A look's `tokens` can change what leads to a re-pointed token; lookPairBlocks restates those.
325
+ if (rule === 'pairs') continue;
296
326
  if (text !== base.rules[rule]) throw new Error(`Look "${name}" changes the ${rule} rules, not only values. A look can change colors, fonts and sizes, but it has to keep the brand's palettes, pairs, statuses and emphasis levels.`);
297
327
  }
328
+ const pairRules = lookPairBlocks(name, parts, base);
329
+ if (pairRules) modeRules.push(pairRules);
298
330
  const changed = Object.entries(parts.root).filter(([v, value]) => base.root[v] !== value);
299
331
  if (changed.length) roots.push(`/* Look: ${label} (data-look='${name}'). */\n[data-look='${name}'] {\n${indent(changed.map(([v, value]) => `${v}: ${value};`).join('\n'), 2)}\n}`);
300
332
  if (parts.rules.vision !== base.rules.vision) {
package/figma/engine.js CHANGED
@@ -345,6 +345,25 @@ async function loomiereMigrate(dryRun) {
345
345
  return renamed;
346
346
  }
347
347
 
348
+ async function loomiereLoadFonts(data) {
349
+ var families = {};
350
+ (data.textStyles || []).forEach(function (t) {
351
+ if (t.family) families[t.family] = true;
352
+ });
353
+ var want = [];
354
+ (await figma.getLocalTextStylesAsync()).forEach(function (s) {
355
+ want.push(s.fontName);
356
+ });
357
+ (await figma.listAvailableFontsAsync()).forEach(function (f) {
358
+ if (families[f.fontName.family]) want.push(f.fontName);
359
+ });
360
+ for (var i = 0; i < want.length; i++) {
361
+ try {
362
+ await figma.loadFontAsync(want[i]);
363
+ } catch (e) {}
364
+ }
365
+ }
366
+
348
367
  async function loomiereSync(data, options) {
349
368
  options = options || {};
350
369
  if (!data || data.format !== "loomiere-figma")
@@ -353,6 +372,9 @@ async function loomiereSync(data, options) {
353
372
  );
354
373
  var dryRun = !!options.dryRun;
355
374
  var warnings = [];
375
+ // Text styles bind fontFamily to a variable, so changing that variable needs the
376
+ // new family loaded in every style the file's text styles use, and the old ones too.
377
+ if (!dryRun) await loomiereLoadFonts(data);
356
378
  var report = {
357
379
  dryRun: dryRun,
358
380
  brand: data.brand,
@@ -1,8 +1,8 @@
1
1
  # Figma library pages
2
2
 
3
3
  The scripts that draw the library's pages in a Figma file: the foundation pages (Palettes, Color,
4
- Type, Scale, Size, Elevation, Themes), every component set with its labels, each page's doc
5
- frame, and the Cover. They read the file's variables, text styles and effect styles by name, so
4
+ Type, Scale, Size, Elevation, Themes), every component set with its labels, the mockup pages
5
+ (Placeholders and Annotations), each page's doc frame, and the Cover. They read the file's variables, text styles and effect styles by name, so
6
6
  the same scripts build the Mojave file or any copy synced with another brand.
7
7
 
8
8
  They used to live only in the Mojave file. Here they can rebuild a page in any copy, or the whole
@@ -3,7 +3,7 @@
3
3
  "taken": "2026-10-23",
4
4
  "pages": {
5
5
  "Cover": {
6
- "frame:Cover": "12ef885a"
6
+ "frame:Cover": "cec8276d"
7
7
  },
8
8
  "Palettes": {
9
9
  "doc:Palettes(frame)": "770c0cb8",
@@ -241,6 +241,46 @@
241
241
  "wrap:Table": "c3b9a6c7",
242
242
  "wrap:Separator": "4187560c",
243
243
  "wrap:Scroll area": "d9c44942"
244
+ },
245
+ "Placeholders": {
246
+ "set:Image": "6b5d6656",
247
+ "set:Logo": "47c823c8",
248
+ "set:Chart": "28776d88",
249
+ "set:Map": "4f92f366",
250
+ "set:Video": "a65cf8d7",
251
+ "set:Icon slot": "b77e9819",
252
+ "frame:Components(shell)": "32cad7bf",
253
+ "wrap:Image": "56eca3f2",
254
+ "wrap:Logo": "3e10c93a",
255
+ "wrap:Chart": "c3dc3c8d",
256
+ "wrap:Map": "f5824235",
257
+ "wrap:Video": "89f27b8f",
258
+ "wrap:Icon slot": "58f36772",
259
+ "doc:Placeholders(frame)": "281f55e4",
260
+ "doc:Placeholders/Header": "81e6daf5",
261
+ "doc:Placeholders/Choosing": "6ec814fe",
262
+ "doc:Placeholders/Media": "9950079c",
263
+ "doc:Placeholders/Charts, logos and icons": "702641d2",
264
+ "doc:Placeholders/Usage": "8edc5116",
265
+ "doc:Placeholders/Spec": "79eb1b4c"
266
+ },
267
+ "Annotations": {
268
+ "set:Callout": "f16d1f59",
269
+ "set:Sticky note": "6cfb774e",
270
+ "set:Tag": "ae26bcd4",
271
+ "set:Device": "09608176",
272
+ "frame:Components(shell)": "f88f8da8",
273
+ "wrap:Callout": "e712b7b4",
274
+ "wrap:Sticky note": "80097e51",
275
+ "wrap:Tag": "522f2bdc",
276
+ "wrap:Device": "786ca9fb",
277
+ "doc:Annotations(frame)": "94fe33c1",
278
+ "doc:Annotations/Header": "65ff052c",
279
+ "doc:Annotations/Callouts and tags": "966065c9",
280
+ "doc:Annotations/Sticky notes": "297719ff",
281
+ "doc:Annotations/Devices": "72832fc3",
282
+ "doc:Annotations/Usage": "4bcc02ea",
283
+ "doc:Annotations/Spec": "344c308c"
244
284
  }
245
285
  }
246
286
  }
@@ -15,7 +15,7 @@ Object.keys(TS).forEach(function (k) { var f = TS[k].fontName; FONTS[f.family +
15
15
  for (var fk in FONTS) await figma.loadFontAsync(FONTS[fk]);
16
16
  var PREFIX = figma.root.getSharedPluginData('loomiere', 'pagePrefix') || '';
17
17
  // The library's pages, in file order.
18
- var PAGES = ['Cover', 'Palettes', 'Color', 'Type', 'Scale', 'Size', 'Elevation, focus and motion', 'Themes', 'Fields', 'Buttons', 'Popups', 'Overlays', 'Disclosure and navigation', 'Display'];
18
+ var PAGES = ['Cover', 'Palettes', 'Color', 'Type', 'Scale', 'Size', 'Elevation, focus and motion', 'Themes', 'Fields', 'Buttons', 'Popups', 'Overlays', 'Disclosure and navigation', 'Display', 'Placeholders', 'Annotations'];
19
19
  function v(k) { if (!V[k]) throw new Error('No variable ' + k); return V[k]; }
20
20
  function paint(k) { return figma.variables.setBoundVariableForPaint({ type: 'SOLID', color: { r: 0, g: 0, b: 0 } }, 'color', v(k)); }
21
21
  function fill(n, k) { n.fills = k ? [paint(k)] : []; return n; }
@@ -6,6 +6,7 @@ var LEAD = 'A desert theme with a violet lead. Every color, size and type style
6
6
  var BASE_UI = '1.9';
7
7
  var FOUNDATIONS = ['Palettes', 'Color', 'Type', 'Scale', 'Size', 'Elevation, focus and motion', 'Themes'];
8
8
  var COMPONENTS = ['Fields', 'Buttons', 'Popups', 'Overlays', 'Disclosure and navigation', 'Display'];
9
+ var MOCKUPS = ['Placeholders', 'Annotations'];
9
10
  var PALETTES = ['primary', 'secondary', 'tertiary', 'quaternary', 'neutral'];
10
11
  var AF = Object.getPrototypeOf(async function () {}).constructor;
11
12
  var L = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docHelpers'))(figma);
@@ -63,7 +64,7 @@ cov.appendChild(tb); tb.x = 96; tb.y = 120;
63
64
 
64
65
  // Contents: the page names, by group.
65
66
  var ct = AL('HORIZONTAL', { name: 'Contents', itemSpacing: 96 });
66
- for (var g of [['Foundations', FOUNDATIONS], ['Components', COMPONENTS]]) {
67
+ for (var g of [['Foundations', FOUNDATIONS], ['Components', COMPONENTS], ['Mockups', MOCKUPS]]) {
67
68
  var col = AL('VERTICAL', { name: g[0], itemSpacing: 8 }); add(col, [await T(g[0].toUpperCase(), 'eyebrow-condense', 'Theme:text/tertiary')]);
68
69
  for (var n of g[1]) add(col, [await T(n, 'subtitle-condense')]);
69
70
  add(ct, [col]);
@@ -0,0 +1,108 @@
1
+ // Placeholders: Image, Logo, Chart, Map, Video, Icon slot. Stand-ins for content a mockup doesn't
2
+ // have yet. Built only from Theme and Size tokens, so they follow the brand, look and theme.
3
+ // Rebuilds the page's Components column.
4
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
5
+ var C = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'compHelpers'))(figma);
6
+ var v = C.v, paint = C.paint, fill = C.fill, stroke = C.stroke, bind = C.bind, radius = C.radius, AL = C.AL, add = C.add, T = C.T;
7
+ var icon = C.icon, comp = C.comp, textProp = C.textProp, place = C.place, removeOld = C.removeOld;
8
+ await C.page('Placeholders');
9
+ function frameSet(set) { fill(set, 'Theme:surface/canvas'); set.strokes = [{ type: 'SOLID', color: { r: 0.592, g: 0.278, b: 1 } }]; set.dashPattern = [6, 4]; set.strokeWeight = 1; set.cornerRadius = 8; set.clipsContent = false; }
10
+ // Variants in a row, centered on one line; top aligns them instead, so labelSets sees one row.
11
+ function row(set, gap, top) {
12
+ set.layoutMode = 'NONE'; var x = 32, h = 0;
13
+ set.children.forEach(function (k) { k.x = x; k.y = 48; x += Math.max(k.width, 64) + gap; h = Math.max(h, k.height); });
14
+ set.children.forEach(function (k) { k.y = top ? 48 : 48 + Math.round((h - k.height) / 2); });
15
+ set.resize(x - gap + 32, h + 96); frameSet(set);
16
+ }
17
+ function box(name, w, h) { var c = comp(name); c.layoutMode = 'NONE'; c.resize(w, h); c.clipsContent = true; return c; }
18
+ function center(n, parent) { parent.appendChild(n); n.x = Math.round((parent.width - n.width) / 2); n.y = Math.round((parent.height - n.height) / 2); n.constraints = { horizontal: 'CENTER', vertical: 'CENTER' }; return n; }
19
+ function line(name, d, w, h, colorKey, weight) {
20
+ var vec = figma.createVector(); vec.name = name; vec.vectorPaths = [{ windingRule: 'NONZERO', data: d }];
21
+ vec.fills = []; vec.strokes = [paint(colorKey)]; vec.strokeWeight = weight || 1; vec.strokeCap = 'ROUND'; vec.strokeJoin = 'ROUND';
22
+ return vec;
23
+ }
24
+ var IMG = 'M 2 3 L 14 3 L 14 13 L 2 13 Z M 2 11 L 6 7 L 9 10 L 11 8 L 14 11 M 10.5 5.5 L 11.5 5.5';
25
+ async function chip(d) {
26
+ var c = figma.createFrame(); c.name = 'Glyph'; c.resize(36, 36); c.cornerRadius = 18; fill(c, 'Theme:surface/canvas');
27
+ var g = icon('Icon', d, 20, 'Theme:text/tertiary'); c.appendChild(g); g.x = 8; g.y = 8; return c;
28
+ }
29
+ figma.currentPage.children.filter(function (n) { return n.name === 'Components'; }).forEach(function (n) { n.remove(); });
30
+
31
+ // Image: a crossed box, the oldest wireframe sign for "a picture goes here". Lines scale with the box.
32
+ var RATIOS = [['1:1', 240, 240], ['4:3', 240, 180], ['3:2', 240, 160], ['16:9', 240, 135], ['3:1', 240, 80]], imgs = [];
33
+ for (var r of RATIOS) {
34
+ var c = box('Ratio=' + r[0], r[1], r[2]); fill(c, 'Theme:fill/subtle'); stroke(c, 'Theme:border/default', 'Size:stroke/default'); radius(c, 'Size:radius/box');
35
+ for (var d of ['M 0 0 L ' + r[1] + ' ' + r[2], 'M ' + r[1] + ' 0 L 0 ' + r[2]]) { var l = line('Cross', d, r[1], r[2], 'Theme:border/strong'); c.appendChild(l); l.x = 0; l.y = 0; l.constraints = { horizontal: 'SCALE', vertical: 'SCALE' }; }
36
+ center(await chip(IMG), c);
37
+ imgs.push(c);
38
+ }
39
+ var imgSet = figma.combineAsVariants(imgs, figma.currentPage); imgSet.name = 'Image'; row(imgSet, 40, true);
40
+ imgSet.description = 'A picture goes here: a crossed box at five ratios. Resize freely; the cross scales and the glyph stays centered. fill/subtle, border/default, radius/box.';
41
+ place(imgSet);
42
+
43
+ // Logo
44
+ var logos = [];
45
+ for (var s of [['Wordmark', 160, 40], ['Mark', 40, 40]]) {
46
+ var c = comp('Shape=' + s[0]); c.primaryAxisAlignItems = 'CENTER'; c.counterAxisAlignItems = 'CENTER';
47
+ c.resize(s[1], s[2]); c.primaryAxisSizingMode = 'FIXED'; c.counterAxisSizingMode = 'FIXED';
48
+ stroke(c, 'Theme:border/strong', 'Size:stroke/default'); c.dashPattern = [4, 3]; radius(c, 'Size:radius/minimal');
49
+ var t = await T(s[0] === 'Mark' ? 'L' : 'LOGO', 'eyebrow-condense', 'Theme:text/tertiary'); t.name = 'Label'; add(c, [t]);
50
+ logos.push(c);
51
+ }
52
+ var logoSet = figma.combineAsVariants(logos, figma.currentPage); logoSet.name = 'Logo'; row(logoSet, 40);
53
+ logoSet.description = 'Where a logo goes: a dashed outline so it never reads as a real mark. border/strong, radius/minimal, eyebrow-condense.';
54
+ place(logoSet);
55
+
56
+ // Chart
57
+ var charts = [];
58
+ for (var kind of ['Bar', 'Line', 'Donut']) {
59
+ var c = box('Type=' + kind, 240, 160); fill(c, 'Theme:fill/subtle'); radius(c, 'Size:radius/box');
60
+ if (kind === 'Bar') {
61
+ var H = [52, 88, 70, 112, 64, 96];
62
+ H.forEach(function (h, i) { var b = figma.createRectangle(); b.name = 'Bar'; b.resize(20, h); b.x = 24 + i * 34; b.y = 136 - h; fill(b, i === 3 ? 'Theme:fill/stronger' : 'Theme:fill/strong'); b.topLeftRadius = b.topRightRadius = 3; b.constraints = { horizontal: 'SCALE', vertical: 'SCALE' }; c.appendChild(b); });
63
+ } else if (kind === 'Line') {
64
+ for (var y of [40, 72, 104, 136]) { var g = line('Grid', 'M 0 0 L 192 0', 192, 0, 'Theme:border/subtle'); c.appendChild(g); g.x = 24; g.y = y; g.constraints = { horizontal: 'SCALE', vertical: 'SCALE' }; }
65
+ var p = line('Series', 'M 0 70 L 38 52 L 76 64 L 114 30 L 152 42 L 192 8', 192, 70, 'Theme:text/secondary', 2); c.appendChild(p); p.x = 24; p.y = 48; p.constraints = { horizontal: 'SCALE', vertical: 'SCALE' };
66
+ } else {
67
+ var seg = [['Theme:fill/stronger', 0, 2.4], ['Theme:fill/strong', 2.4, 4.4], ['Theme:fill/default', 4.4, 2 * Math.PI]];
68
+ seg.forEach(function (s) { var e = figma.createEllipse(); e.name = 'Segment'; e.resize(112, 112); e.x = 64; e.y = 24; e.arcData = { startingAngle: s[1] - Math.PI / 2, endingAngle: s[2] - Math.PI / 2, innerRadius: 0.6 }; fill(e, s[0]); e.constraints = { horizontal: 'CENTER', vertical: 'CENTER' }; c.appendChild(e); });
69
+ }
70
+ charts.push(c);
71
+ }
72
+ var chartSet = figma.combineAsVariants(charts, figma.currentPage); chartSet.name = 'Chart'; row(chartSet, 40);
73
+ chartSet.description = 'A chart goes here: bars, a line or a donut in grays, so nobody mistakes it for real data. fill/subtle background, fill/default to fill/stronger marks.';
74
+ place(chartSet);
75
+
76
+ // Map
77
+ var m = box('Style=Default', 240, 160); fill(m, 'Theme:fill/subtle'); radius(m, 'Size:radius/box');
78
+ var roads = ['M 0 110 L 90 96 L 150 120 L 240 100', 'M 70 0 L 92 96 L 104 160', 'M 150 120 L 176 0', 'M 0 40 L 240 56'];
79
+ roads.forEach(function (d, i) { var r = line('Road', d, 240, 160, 'Theme:surface/canvas', i < 2 ? 6 : 3); m.appendChild(r); var xs = [], ys = []; d.replace(/(-?[\d.]+) (-?[\d.]+)/g, function (q, x, y) { xs.push(+x); ys.push(+y); }); r.x = Math.min.apply(null, xs); r.y = Math.min.apply(null, ys); r.constraints = { horizontal: 'SCALE', vertical: 'SCALE' }; });
80
+ var pin = icon('Pin', 'M 8 14.5 C 8 14.5 3 9.5 3 6.2 C 3 3.3 5.2 1.2 8 1.2 C 10.8 1.2 13 3.3 13 6.2 C 13 9.5 8 14.5 8 14.5 Z M 8 4.7 L 8 7.3', 28, 'Theme:text/primary', 1.6);
81
+ m.appendChild(pin); pin.x = 106; pin.y = 58; pin.constraints = { horizontal: 'CENTER', vertical: 'CENTER' };
82
+ var mapSet = figma.combineAsVariants([m], figma.currentPage); mapSet.name = 'Map'; row(mapSet, 40);
83
+ mapSet.description = 'A map goes here: roads on a gray ground and a pin. fill/subtle, surface/canvas roads, text/primary pin.';
84
+ place(mapSet);
85
+
86
+ // Video
87
+ var vid = box('Style=Default', 240, 135); fill(vid, 'Theme:surface/inverse'); radius(vid, 'Size:radius/box');
88
+ var play = figma.createFrame(); play.name = 'Play'; play.resize(44, 44); play.cornerRadius = 22; fill(play, 'Theme:surface/canvas');
89
+ var tri = figma.createVector(); tri.name = 'Triangle'; tri.vectorPaths = [{ windingRule: 'NONZERO', data: 'M 0 0 L 14 8 L 0 16 Z' }]; fill(tri, 'Theme:text/primary'); play.appendChild(tri); tri.x = 17; tri.y = 14;
90
+ center(play, vid);
91
+ var track = figma.createRectangle(); track.name = 'Track'; track.resize(216, 4); track.x = 12; track.y = 119; track.cornerRadius = 2; fill(track, 'Theme:text/tertiary'); track.constraints = { horizontal: 'STRETCH', vertical: 'MAX' }; vid.appendChild(track);
92
+ var prog = figma.createRectangle(); prog.name = 'Played'; prog.resize(72, 4); prog.x = 12; prog.y = 119; prog.cornerRadius = 2; fill(prog, 'Theme:text/on-inverse'); prog.constraints = { horizontal: 'SCALE', vertical: 'MAX' }; vid.appendChild(prog);
93
+ var vidSet = figma.combineAsVariants([vid], figma.currentPage); vidSet.name = 'Video'; row(vidSet, 40);
94
+ vidSet.description = 'A video goes here: the inverse surface, a play button and a progress track. surface/inverse, surface/canvas, text/on-inverse.';
95
+ place(vidSet);
96
+
97
+ // Icon slot
98
+ var slots = [];
99
+ for (var z of ['minimize', 'condense', 'base', 'magnify', 'maximize']) {
100
+ var c = box('Size=' + z, 16, 16); bind(c, ['width', 'height'], 'Size:icon-size/' + z);
101
+ stroke(c, 'Theme:border/strong', 1); c.dashPattern = [2, 2]; radius(c, 'Size:radius/minimal'); c.clipsContent = false;
102
+ var x = line('Cross', 'M 0 0 L 16 16 M 16 0 L 0 16', 16, 16, 'Theme:border/default'); c.appendChild(x); x.x = 0; x.y = 0; x.constraints = { horizontal: 'SCALE', vertical: 'SCALE' };
103
+ slots.push(c);
104
+ }
105
+ var slotSet = figma.combineAsVariants(slots, figma.currentPage); slotSet.name = 'Icon slot'; row(slotSet, 32);
106
+ slotSet.description = 'An icon goes here, at the icon-size steps. Swap it for a real icon later. border/strong dashed, radius/minimal.';
107
+ place(slotSet);
108
+ return { built: ['Image', 'Logo', 'Chart', 'Map', 'Video', 'Icon slot'], page: figma.currentPage.id };
@@ -0,0 +1,12 @@
1
+ // Placeholders: a header and column labels for each set. Run after 01.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var C = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'compHelpers'))(figma);
4
+ await C.page('Placeholders');
5
+ return await C.labelSets({
6
+ Image: 'A crossed box at five ratios: a picture goes here. Resize freely; the glyph stays centered.',
7
+ Logo: 'A dashed outline where a logo goes, so it never reads as a real mark.',
8
+ Chart: 'Bars, a line or a donut in grays, so nobody mistakes it for real data.',
9
+ Map: 'Roads on a gray ground and a pin: a map goes here.',
10
+ Video: 'The inverse surface, a play button and a progress track: a video goes here.',
11
+ 'Icon slot': 'An icon goes here, at the five icon-size steps.'
12
+ });
@@ -0,0 +1,28 @@
1
+ // Placeholders doc frame. Run after every Placeholders set is built and labelled.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var L = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docHelpers'))(figma);
4
+ await L.page('Placeholders');
5
+ var K = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docKit'))(figma);
6
+ var AL = L.AL, add = L.add, T = L.T, section = L.section, hrow = L.hrow, divider = L.divider, inst = K.inst, stage = K.stage, captioned = K.captioned, notes = K.notes, usage = K.usage;
7
+ var doc = await L.docFrame('Placeholders', 'MOCKUPS', 'Placeholders', 'Stand-ins for content a mockup does not have yet: pictures, logos, charts, maps, video and icons. They are drawn only with theme tokens, so they follow the brand, the look and light or dark. Components sit to the right.');
8
+ doc.x = 0; doc.y = 400;
9
+ var pick = await section(doc, 'Choosing', 'Pick the one that says what will go there, so reviewers read the layout, not the art.');
10
+ var picks = [['A photo or illustration', 'Image', 'Pick the ratio the real picture will have.'], ['A brand or partner mark', 'Logo', 'Wordmark for a header, Mark for tight spots.'], ['Numbers shown as a graphic', 'Chart', 'Bar, Line or Donut, in grays so nobody reads the data.'], ['A place', 'Map', 'Stretch it; the roads scale with the box.'], ['Something that plays', 'Video', 'Sits on the inverse surface like a real player.'], ['An icon not chosen yet', 'Icon slot', 'At the icon-size step the real icon will use.']];
11
+ var pt = AL('VERTICAL', { name: 'Picker' }); add(pick, [pt]); pt.layoutSizingHorizontal = 'FILL';
12
+ for (var i = 0; i < picks.length; i++) { var r = hrow(pt); divider(r); add(r, [await T(picks[i][0], 'body-base', 'Theme:text/primary', 380), await T(picks[i][1], 'subtitle-base', 'Theme:text/accent', 200), await T(picks[i][2], 'body-condense', 'Theme:text/secondary', 520)]); }
13
+ // Placeholders sit on fill/subtle themselves, so their stages use the page color with an edge.
14
+ function plain(parent, name) { var st = stage(parent, name); L.fill(st, 'Theme:surface/canvas'); L.stroke(st, 'Theme:border/subtle', 1); return st; }
15
+ var media = await section(doc, 'Media', 'A crossed box is the oldest sign for "a picture goes here". Image comes in five ratios; resize any of them and the cross scales while the glyph stays centered. Map and Video stretch the same way.');
16
+ var s1 = plain(media, 'Images');
17
+ for (var q of ['1:1', '4:3', '16:9', '3:1']) add(s1, [await captioned(inst('Image', 'Ratio=' + q), q)]);
18
+ var s1b = plain(media, 'Map and video');
19
+ add(s1b, [await captioned(inst('Map', 'Style=Default'), 'Map'), await captioned(inst('Video', 'Style=Default'), 'Video')]);
20
+ var marks = await section(doc, 'Charts, logos and icons', 'Charts use the fill steps so they read as shapes, never as real numbers. Logos are dashed outlines so they never pass for a real mark. Icon slots hold the place at each icon size.');
21
+ var s2 = plain(marks, 'Charts');
22
+ for (var c of ['Bar', 'Line', 'Donut']) add(s2, [await captioned(inst('Chart', 'Type=' + c), c)]);
23
+ var s2b = plain(marks, 'Logos and icons'); s2b.counterAxisAlignItems = 'CENTER';
24
+ add(s2b, [await captioned(inst('Logo', 'Shape=Wordmark'), 'Wordmark'), await captioned(inst('Logo', 'Shape=Mark'), 'Mark')]);
25
+ for (var z of ['minimize', 'condense', 'base', 'magnify', 'maximize']) add(s2b, [await captioned(inst('Icon slot', 'Size=' + z), z)]);
26
+ await usage(doc, ['Mockups and wireframes before the real content exists.', 'Showing the size and ratio the real content will need.'], ['Shipping them in a product. They are for design files only.', 'Lorem ipsum text. Write real words, or use skeleton lines.']);
27
+ await notes(doc, [['Ground', 'fill/subtle · radius/box', 'Quiet, so the layout leads.'], ['Edge', 'border/default · stroke/default', 'Shows the box without weight.'], ['Cross and outlines', 'border/strong', 'Dashed for logos and icon slots.'], ['Marks', 'fill/default · fill/strong · fill/stronger', 'Chart bars and segments.'], ['Video', 'surface/inverse · text/on-inverse', 'Reads as a player in any theme.'], ['Icon slot', 'icon-size/*', 'The same steps as real icons.']]);
28
+ return { id: doc.id, h: Math.round(doc.height), overflow: K.overflow(doc) };
@@ -0,0 +1,93 @@
1
+ // Annotations: Callout, Sticky note, Tag, Device. Marks for reviews and handoff on any mockup.
2
+ // Callouts, tags and notes use fixed colors on purpose, so they never read as part of the design.
3
+ // Device frames use theme tokens, so they follow the look. Rebuilds the page's Components column.
4
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
5
+ var C = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'compHelpers'))(figma);
6
+ var v = C.v, paint = C.paint, fill = C.fill, stroke = C.stroke, bind = C.bind, radius = C.radius, AL = C.AL, add = C.add, T = C.T;
7
+ var comp = C.comp, textProp = C.textProp, place = C.place, removeOld = C.removeOld;
8
+ await C.page('Annotations');
9
+ function hex(h) { return { r: parseInt(h.slice(1, 3), 16) / 255, g: parseInt(h.slice(3, 5), 16) / 255, b: parseInt(h.slice(5, 7), 16) / 255 }; }
10
+ function solid(n, h) { n.fills = [{ type: 'SOLID', color: hex(h) }]; return n; }
11
+ var MARK = '#2F6BFF', MARK_TEXT = '#1F4FD1', MARK_TINT = '#EAF0FF', NOTE_TEXT = '#26231F';
12
+ function frameSet(set) { fill(set, 'Theme:surface/canvas'); set.strokes = [{ type: 'SOLID', color: { r: 0.592, g: 0.278, b: 1 } }]; set.dashPattern = [6, 4]; set.strokeWeight = 1; set.cornerRadius = 8; set.clipsContent = false; }
13
+ function row(set, gap) {
14
+ set.layoutMode = 'NONE'; var x = 32, h = 0;
15
+ set.children.forEach(function (k) { k.x = x; k.y = 48; x += k.width + gap; h = Math.max(h, k.height); });
16
+ set.children.forEach(function (k) { k.y = 48; });
17
+ set.resize(x - gap + 32, h + 96); frameSet(set);
18
+ }
19
+ figma.currentPage.children.filter(function (n) { return n.name === 'Components'; }).forEach(function (n) { n.remove(); });
20
+
21
+ // Callout: a numbered dot, or a dot with a short label
22
+ var calls = [];
23
+ for (var kind of ['Number', 'Label']) {
24
+ var c = comp('Type=' + kind); c.counterAxisAlignItems = 'CENTER'; c.itemSpacing = 8;
25
+ if (kind === 'Label') { solid(c, MARK); c.cornerRadius = 14; c.paddingLeft = 2; c.paddingRight = 12; c.paddingTop = c.paddingBottom = 2; }
26
+ var dot = AL('HORIZONTAL', { name: 'Dot', primaryAxisAlignItems: 'CENTER', counterAxisAlignItems: 'CENTER' });
27
+ dot.resize(24, 24); dot.primaryAxisSizingMode = 'FIXED'; dot.counterAxisSizingMode = 'FIXED'; dot.cornerRadius = 12;
28
+ solid(dot, kind === 'Label' ? '#FFFFFF' : MARK);
29
+ var n = await T('1', 'subtitle-condense'); n.name = 'Number'; solid(n, kind === 'Label' ? MARK_TEXT : '#FFFFFF'); add(dot, [n]);
30
+ add(c, [dot]);
31
+ if (kind === 'Label') { var l = await T('Opens the date picker', 'label-base'); l.name = 'Label'; solid(l, '#FFFFFF'); add(c, [l]); }
32
+ calls.push(c);
33
+ }
34
+ var callSet = figma.combineAsVariants(calls, figma.currentPage); callSet.name = 'Callout'; row(callSet, 40);
35
+ textProp(callSet, 'Number', '1', 'Number'); textProp(callSet, 'Label', 'Opens the date picker', 'Label');
36
+ callSet.description = 'Number a spot in a wireframe and explain it in the notes, or label it right there. A fixed blue that no theme uses, so it never reads as part of the design.';
37
+ place(callSet);
38
+
39
+ // Sticky note
40
+ var NOTES = { Yellow: '#FFF0A6', Pink: '#FFD5E5', Blue: '#D6E6FF' }, stickies = [];
41
+ for (var col in NOTES) {
42
+ var c = comp('Color=' + col, 'VERTICAL'); c.itemSpacing = 6; c.paddingTop = c.paddingBottom = c.paddingLeft = c.paddingRight = 16;
43
+ c.resize(200, 120); c.counterAxisSizingMode = 'FIXED'; c.primaryAxisSizingMode = 'AUTO'; solid(c, NOTES[col]); c.cornerRadius = 2;
44
+ c.effects = [{ type: 'DROP_SHADOW', color: { r: 0, g: 0, b: 0, a: 0.12 }, offset: { x: 0, y: 2 }, radius: 6, spread: 0, visible: true, blendMode: 'NORMAL' }];
45
+ var t = await T('Question', 'subtitle-base'); t.name = 'Title'; solid(t, NOTE_TEXT);
46
+ var b = await T('Do we need a guest checkout here, or can people book without an account?', 'body-condense'); b.name = 'Note'; solid(b, NOTE_TEXT);
47
+ add(c, [t, b]); t.layoutSizingHorizontal = 'FILL'; b.layoutSizingHorizontal = 'FILL'; b.textAutoResize = 'HEIGHT';
48
+ stickies.push(c);
49
+ }
50
+ var stickySet = figma.combineAsVariants(stickies, figma.currentPage); stickySet.name = 'Sticky note'; row(stickySet, 32);
51
+ textProp(stickySet, 'Title', 'Question', 'Title'); textProp(stickySet, 'Note', 'Do we need a guest checkout here, or can people book without an account?', 'Note');
52
+ stickySet.description = 'A note for reviewers: questions, ideas, decisions. Pick a color per person or per kind of note. Fixed colors, outside the theme.';
53
+ place(stickySet);
54
+
55
+ // Tag
56
+ var tag = comp('Style=Default'); tag.counterAxisAlignItems = 'CENTER'; tag.paddingLeft = tag.paddingRight = 8; tag.paddingTop = tag.paddingBottom = 2; tag.cornerRadius = 4;
57
+ solid(tag, MARK_TINT); tag.strokes = [{ type: 'SOLID', color: hex(MARK) }]; tag.strokeWeight = 1; tag.strokeAlign = 'INSIDE';
58
+ var tl = await T('TBD', 'eyebrow-condense'); tl.name = 'Label'; solid(tl, MARK_TEXT); add(tag, [tl]);
59
+ var tagSet = figma.combineAsVariants([tag], figma.currentPage); tagSet.name = 'Tag'; row(tagSet, 40);
60
+ var tk = tagSet.addComponentProperty('Label', 'TEXT', 'TBD'); tl.componentPropertyReferences = { characters: tk };
61
+ tagSet.description = 'Mark something as not settled: TBD, Question, Changed, Idea. Same fixed blue as callouts.';
62
+ place(tagSet);
63
+
64
+ // Device: phone, tablet and desktop frames with a Content area to design in
65
+ var DEV = [['Phone', 390, 844, 44, 40], ['Tablet', 834, 1194, 28, 24], ['Desktop', 1440, 900, 48, 10]], devs = [];
66
+ for (var d of DEV) {
67
+ var c = comp('Device=' + d[0], 'VERTICAL'); c.resize(d[1], d[2]); c.primaryAxisSizingMode = 'FIXED'; c.counterAxisSizingMode = 'FIXED'; c.clipsContent = true;
68
+ fill(c, 'Theme:surface/canvas'); stroke(c, 'Theme:border/strong', 'Size:stroke/default'); c.cornerRadius = d[4];
69
+ var bar = AL('HORIZONTAL', { name: d[0] === 'Desktop' ? 'Browser bar' : 'Status bar', counterAxisAlignItems: 'CENTER', itemSpacing: 8, paddingLeft: d[0] === 'Desktop' ? 16 : 28, paddingRight: d[0] === 'Desktop' ? 16 : 28 });
70
+ bar.resize(d[1], d[3]); bar.counterAxisSizingMode = 'FIXED'; add(c, [bar]); bar.layoutSizingHorizontal = 'FILL';
71
+ if (d[0] === 'Desktop') {
72
+ fill(bar, 'Theme:surface/tray'); bar.strokes = [paint('Theme:border/default')]; bar.strokeTopWeight = bar.strokeLeftWeight = bar.strokeRightWeight = 0; bar.strokeBottomWeight = 1;
73
+ for (var q = 0; q < 3; q++) { var e = figma.createEllipse(); e.name = 'Dot'; e.resize(12, 12); fill(e, 'Theme:fill/strong'); add(bar, [e]); }
74
+ var url = AL('HORIZONTAL', { name: 'Address', counterAxisAlignItems: 'CENTER', paddingLeft: 12, paddingRight: 12 }); url.resize(560, 28); url.counterAxisSizingMode = 'FIXED'; url.primaryAxisSizingMode = 'FIXED';
75
+ fill(url, 'Theme:surface/canvas'); stroke(url, 'Theme:border/default', 1); radius(url, 'Size:radius/control');
76
+ add(url, [await T('example.com', 'body-condense', 'Theme:text/tertiary')]); var sp = AL('HORIZONTAL', { name: 'Spacer' }); add(bar, [sp, url]); sp.resize(160, 1);
77
+ } else {
78
+ bar.primaryAxisAlignItems = 'SPACE_BETWEEN';
79
+ var time = await T('9:41', 'subtitle-condense'); var ind = AL('HORIZONTAL', { name: 'Indicators', itemSpacing: 4, counterAxisAlignItems: 'CENTER' });
80
+ [[16, 10], [14, 10], [24, 11]].forEach(function (s) { var r = figma.createRectangle(); r.resize(s[0], s[1]); r.cornerRadius = 3; fill(r, 'Theme:text/primary'); add(ind, [r]); });
81
+ add(bar, [time, ind]);
82
+ }
83
+ var content = AL('VERTICAL', { name: 'Content' }); add(c, [content]); content.layoutSizingHorizontal = 'FILL'; content.layoutSizingVertical = 'FILL';
84
+ if (d[0] !== 'Desktop') {
85
+ var home = AL('HORIZONTAL', { name: 'Home indicator', primaryAxisAlignItems: 'CENTER', counterAxisAlignItems: 'CENTER' }); home.resize(d[1], 34); home.counterAxisSizingMode = 'FIXED';
86
+ var hb = figma.createRectangle(); hb.name = 'Bar'; hb.resize(134, 5); hb.cornerRadius = 3; fill(hb, 'Theme:text/primary'); add(home, [hb]); add(c, [home]); home.layoutSizingHorizontal = 'FILL';
87
+ }
88
+ devs.push(c);
89
+ }
90
+ var devSet = figma.combineAsVariants(devs, figma.currentPage); devSet.name = 'Device'; row(devSet, 80);
91
+ devSet.description = 'A screen to sketch in: phone (390×844), tablet (834×1194) and desktop browser (1440×900). Put your layout in Content. Chrome uses surface/canvas, surface/tray and border/strong, so it follows the look.';
92
+ place(devSet);
93
+ return { built: ['Callout', 'Sticky note', 'Tag', 'Device'], page: figma.currentPage.id };
@@ -0,0 +1,10 @@
1
+ // Annotations: a header and column labels for each set. Run after 01.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var C = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'compHelpers'))(figma);
4
+ await C.page('Annotations');
5
+ return await C.labelSets({
6
+ Callout: 'Number a spot and explain it in the notes, or label it in place. Fixed blue, outside the theme.',
7
+ 'Sticky note': 'Questions and ideas for reviewers. A color per person or per kind of note.',
8
+ Tag: 'Marks something as not settled. Change the label: TBD, Question, Changed, Idea.',
9
+ Device: 'Phone, tablet and desktop browser frames. Put the layout in Content.'
10
+ });
@@ -0,0 +1,22 @@
1
+ // Annotations doc frame. Run after every Annotations set is built and labelled.
2
+ var AF = Object.getPrototypeOf(async function () {}).constructor;
3
+ var L = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docHelpers'))(figma);
4
+ await L.page('Annotations');
5
+ var K = await new AF('figma', figma.root.getSharedPluginData('loomiere', 'docKit'))(figma);
6
+ var add = L.add, section = L.section, inst = K.inst, stage = K.stage, captioned = K.captioned, notes = K.notes, usage = K.usage;
7
+ var doc = await L.docFrame('Annotations', 'MOCKUPS', 'Annotations', 'Marks for reviews and handoff: numbered callouts, sticky notes, tags for open questions, and device frames to design in. Callouts, notes and tags use fixed colors that no theme uses, so markup never reads as part of the design. Components sit to the right.');
8
+ doc.x = 0; doc.y = 400;
9
+ var mk = await section(doc, 'Callouts and tags', 'Number a spot and explain it in notes beside the frame, or label it in place. Tag anything not settled yet.');
10
+ var s1 = stage(mk, 'Marks'); s1.counterAxisAlignItems = 'CENTER';
11
+ add(s1, [await captioned(inst('Callout', 'Type=Number'), 'Number'), await captioned(inst('Callout', 'Type=Label'), 'Label')]);
12
+ for (var w of ['TBD', 'Question', 'Changed']) add(s1, [await captioned(inst('Tag', 'Style=Default', { Label: w }), w)]);
13
+ var st = await section(doc, 'Sticky notes', 'Questions, ideas and decisions for reviewers. Give each person or each kind of note its own color.');
14
+ var s2 = stage(st, 'Notes');
15
+ for (var c of ['Yellow', 'Pink', 'Blue']) add(s2, [await captioned(inst('Sticky note', 'Color=' + c), c)]);
16
+ var dv = await section(doc, 'Devices', 'Phone (390×844), tablet (834×1194) and desktop browser (1440×900). Put the layout in the Content frame. The chrome uses theme tokens, so it follows the look.');
17
+ var s3 = stage(dv, 'Devices'); s3.counterAxisAlignItems = 'MAX';
18
+ var ph = inst('Device', 'Device=Phone'); ph.rescale(0.5); var tb = inst('Device', 'Device=Tablet'); tb.rescale(0.35); var dk = inst('Device', 'Device=Desktop'); dk.rescale(0.4);
19
+ add(s3, [await captioned(ph, 'Phone, at 50%'), await captioned(tb, 'Tablet, at 35%'), await captioned(dk, 'Desktop, at 40%')]);
20
+ await usage(doc, ['Reviews, handoff notes and open questions.', 'Numbered callouts with the explanation beside the frame.'], ['Theme colors for markup. It would read as part of the design.', 'Notes left in a file after the decision is made.']);
21
+ await notes(doc, [['Mark', '#2F6BFF fill · #1F4FD1 text', 'A fixed blue, outside every theme.'], ['Tag', '#EAF0FF ground · #2F6BFF edge', 'Same blue, quieter.'], ['Notes', '#FFF0A6 · #FFD5E5 · #D6E6FF', 'Paper colors with #26231F text.'], ['Device', 'surface/canvas · surface/tray · border/strong', 'Follows the look and theme.']]);
22
+ return { id: doc.id, h: Math.round(doc.height), overflow: K.overflow(doc) };
@@ -16,6 +16,8 @@
16
16
  { "page": "Overlays", "files": ["pages/12-overlays/01-overlays.js", "pages/12-overlays/02-labels.js", "pages/12-overlays/03-doc.js"] },
17
17
  { "page": "Disclosure and navigation", "files": ["pages/13-disclosure/01-disclosure.js", "pages/13-disclosure/02-collapsible.js", "pages/13-disclosure/03-labels.js", "pages/13-disclosure/04-doc.js"] },
18
18
  { "page": "Display", "files": ["pages/14-display/01-display.js", "pages/14-display/02-more-display.js", "pages/14-display/03-labels.js", "pages/14-display/04-doc.js"] },
19
+ { "page": "Placeholders", "files": ["pages/16-placeholders/01-placeholders.js", "pages/16-placeholders/02-labels.js", "pages/16-placeholders/03-doc.js"] },
20
+ { "page": "Annotations", "files": ["pages/17-annotations/01-annotations.js", "pages/17-annotations/02-labels.js", "pages/17-annotations/03-doc.js"] },
19
21
  { "page": "Cover", "files": ["pages/15-cover.js"] }
20
22
  ]
21
23
  }
@@ -346,6 +346,25 @@ async function loomiereMigrate(dryRun) {
346
346
  return renamed;
347
347
  }
348
348
 
349
+ async function loomiereLoadFonts(data) {
350
+ var families = {};
351
+ (data.textStyles || []).forEach(function (t) {
352
+ if (t.family) families[t.family] = true;
353
+ });
354
+ var want = [];
355
+ (await figma.getLocalTextStylesAsync()).forEach(function (s) {
356
+ want.push(s.fontName);
357
+ });
358
+ (await figma.listAvailableFontsAsync()).forEach(function (f) {
359
+ if (families[f.fontName.family]) want.push(f.fontName);
360
+ });
361
+ for (var i = 0; i < want.length; i++) {
362
+ try {
363
+ await figma.loadFontAsync(want[i]);
364
+ } catch (e) {}
365
+ }
366
+ }
367
+
349
368
  async function loomiereSync(data, options) {
350
369
  options = options || {};
351
370
  if (!data || data.format !== "loomiere-figma")
@@ -354,6 +373,9 @@ async function loomiereSync(data, options) {
354
373
  );
355
374
  var dryRun = !!options.dryRun;
356
375
  var warnings = [];
376
+ // Text styles bind fontFamily to a variable, so changing that variable needs the
377
+ // new family loaded in every style the file's text styles use, and the old ones too.
378
+ if (!dryRun) await loomiereLoadFonts(data);
357
379
  var report = {
358
380
  dryRun: dryRun,
359
381
  brand: data.brand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scattered-light/loomiere",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Turns a small brand file into a full design token theme: OKLCH color ramps, a type scale, light, dark and high-contrast modes, plus Tailwind, MUI, Chakra, AG Grid and Figma exports.",
5
5
  "keywords": ["design-tokens", "theme", "style-dictionary", "dtcg", "oklch", "base-ui", "ag-grid", "tailwind"],
6
6
  "repository": {
package/tokens/README.md CHANGED
@@ -66,6 +66,7 @@ A look is another way the same brand can look: an old and a new brand, a quiet a
66
66
  - It is merged over the brand: objects merge key by key and lists replace. A palette, a pair, a concept, an emphasis or status entry, or a font's metrics replaces the brand's whole entry, so a look's palette never keeps keys from the brand's.
67
67
  - `tokens` picks a value for any semantic or component token, for what the brand inputs can't say (square buttons, a thicker focus ring). It works at the top of the brand file too.
68
68
  - Every look has the same tokens; only their values change. Components, Figma bindings and code written against one look work in all of them. Pair names and role rules must match across looks; the build says so if they don't.
69
+ - A look's `tokens` can point a token at something else in that look only, such as Highlighter's selected table rows at the highlighter yellow: `"tokens": { "table.row-selected": "{scope.secondary.soft-hover}" }`. Point at a scope slot (`{scope.<palette>.<slot>}`) or a semantic token rather than a palette step, so the value still changes with light, dark and high contrast. Brand pair classes pick up the look's value too.
69
70
 
70
71
  In CSS a look is `data-look="<name>"` on the root element, next to `data-theme`. With no `data-look` the brand's own look applies. `tokens.css` holds one `[data-look]` block with only the variables that change, plus that look's dark, high contrast and color vision blocks. The contrast check runs on every look, and `tokens.json` lists the looks under `looks` with what each one changes. In Figma each look is a mode of the Brand collection (see [figma/README.md](../figma/README.md)), and Storybook has a Look toolbar.
71
72