@scattered-light/loomiere 0.4.1 → 0.5.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.
@@ -7,7 +7,8 @@
7
7
  * a hidden collection, and the published collection's modes point at the right group.
8
8
  *
9
9
  * Collections, in the order they alias each other:
10
- * Brand hidden, one mode. Fonts, and a complete group of the brand's colors per theme:
10
+ * Brand hidden, one mode (published with one mode per look when the brand has looks,
11
+ * so library users can switch it; no scopes either way). Fonts, and a complete group of the brand's colors per theme:
11
12
  * default/color/…, then deuteranopia/color/… and the other color vision themes,
12
13
  * where a color the theme doesn't change points at default's. The data colors are
13
14
  * here by name (color/data/violet…).
@@ -171,531 +172,597 @@ export default {
171
172
  file: "figma.json",
172
173
  for: "Figma variables, text styles and effect styles (elevation and focus shadows): hidden Brand and Scale collections, and Size, Theme, Status and Emphasis, with modes for desktop, tablet and mobile, light and dark, high contrast, color vision, statuses and emphasis. Ask Claude to sync, run loomiere figma push, or import it with the Loomiere Figma plugin.",
173
174
  build(theme) {
174
- const byPath = new Map(theme.tokens.map((t) => [t.path, t]));
175
+ const out = theme.looks?.length > 1 ? withLooks(theme) : single(theme);
176
+ return `${JSON.stringify(out)}\n`;
177
+ },
178
+ };
175
179
 
176
- // Which collection a token's variable lives in, if any.
177
- const isPaletteAlias = (p) => /^color\.(accent|chrome)\./.test(p);
178
- // The numbered data series are slots in Theme; the named data colors are Brand's.
179
- const isDataSlot = (p) => /^color\.data\.\d+$/.test(p);
180
- const home = (t) => {
181
- if (!t) return null;
182
- if (isDataSlot(t.path)) return null;
183
- if (/^(status-level|emphasis)\./.test(t.path)) return null;
184
- if (
185
- t.layer === "primitive" &&
186
- t.type === "color" &&
187
- !t.path.startsWith("vision.") &&
188
- !isPaletteAlias(t.path)
189
- )
190
- return "Brand";
191
- if (t.path.startsWith("font-family.")) return "Brand";
192
- if (t.layer === "semantic" && t.type === "color") return "Theme";
193
- if (
194
- t.type === "color" ||
195
- t.type === "shadow" ||
196
- NOT_SIZES.has(t.path.split(".")[0])
197
- )
198
- return null;
199
- if (t.layer === "primitive")
200
- return SIZE_TOPS.has(t.path.split(".")[0]) && sizes(t.value)
201
- ? "Scale"
202
- : null;
203
- return sizes(t.value) ? "Size" : null;
204
- };
205
- // color.accent.* and color.chrome.* aren't variables; follow them to the palette step.
206
- const follow = (p) => {
207
- let t = byPath.get(p);
208
- while (t && isPaletteAlias(t.path)) t = byPath.get(refOf(t.references));
209
- return t;
210
- };
180
+ /** figma.json for one look: every collection with a single Brand mode, Default. */
181
+ function single(theme) {
182
+ const byPath = new Map(theme.tokens.map((t) => [t.path, t]));
211
183
 
212
- // Color vision themes are Light with the brand's colors swapped for the theme's own.
213
- const visions = [
214
- ...new Set(
215
- theme.tokens
216
- .filter((t) => t.path.startsWith("vision."))
217
- .map((t) => t.path.split(".")[1]),
218
- ),
219
- ];
220
- const visionMode = (v) => v[0].toUpperCase() + v.slice(1);
221
- const themeModes = [...Object.keys(THEMES), ...visions.map(visionMode)];
222
- const visionOf = (mode) => visions.find((v) => visionMode(v) === mode);
223
- // The theme's own version of a brand color, when it differs from the brand's.
224
- const visionAlt = (t, mode) => {
225
- const v = visionOf(mode);
226
- const alt =
227
- v && home(t) === "Brand"
228
- ? byPath.get(`vision.${v}.${t.path}`)
229
- : undefined;
230
- return alt && solve(alt.value) !== solve(t.value) ? alt : undefined;
231
- };
184
+ // Which collection a token's variable lives in, if any.
185
+ const isPaletteAlias = (p) => /^color\.(accent|chrome)\./.test(p);
186
+ // The numbered data series are slots in Theme; the named data colors are Brand's.
187
+ const isDataSlot = (p) => /^color\.data\.\d+$/.test(p);
188
+ const home = (t) => {
189
+ if (!t) return null;
190
+ if (isDataSlot(t.path)) return null;
191
+ if (/^(status-level|emphasis)\./.test(t.path)) return null;
192
+ if (
193
+ t.layer === "primitive" &&
194
+ t.type === "color" &&
195
+ !t.path.startsWith("vision.") &&
196
+ !isPaletteAlias(t.path)
197
+ )
198
+ return "Brand";
199
+ if (t.path.startsWith("font-family.")) return "Brand";
200
+ if (t.layer === "semantic" && t.type === "color") return "Theme";
201
+ if (
202
+ t.type === "color" ||
203
+ t.type === "shadow" ||
204
+ NOT_SIZES.has(t.path.split(".")[0])
205
+ )
206
+ return null;
207
+ if (t.layer === "primitive")
208
+ return SIZE_TOPS.has(t.path.split(".")[0]) && sizes(t.value)
209
+ ? "Scale"
210
+ : null;
211
+ return sizes(t.value) ? "Size" : null;
212
+ };
213
+ // color.accent.* and color.chrome.* aren't variables; follow them to the palette step.
214
+ const follow = (p) => {
215
+ let t = byPath.get(p);
216
+ while (t && isPaletteAlias(t.path)) t = byPath.get(refOf(t.references));
217
+ return t;
218
+ };
232
219
 
233
- /**
234
- * The variable a reference points at in a mode: a theme mode picks the Brand group, a size
235
- * mode picks the font size group.
236
- */
237
- const target = (p, mode = "Light") => {
238
- const t = follow(p);
239
- const c = home(t);
240
- if (!c) return null;
241
- const name = figmaName(t.path);
242
- if (c === "Brand" && t.type === "color")
243
- return `Brand:${visionOf(mode) ?? "default"}/${name}`;
244
- if (c === "Scale" && t.path.startsWith("font-size."))
245
- return `Scale:${SIZES.includes(mode) ? mode.toLowerCase() : "desktop"}/${name}`;
246
- return `${c}:${name}`;
247
- };
220
+ // Color vision themes are Light with the brand's colors swapped for the theme's own.
221
+ const visions = [
222
+ ...new Set(
223
+ theme.tokens
224
+ .filter((t) => t.path.startsWith("vision."))
225
+ .map((t) => t.path.split(".")[1]),
226
+ ),
227
+ ];
228
+ const visionMode = (v) => v[0].toUpperCase() + v.slice(1);
229
+ const themeModes = [...Object.keys(THEMES), ...visions.map(visionMode)];
230
+ const visionOf = (mode) => visions.find((v) => visionMode(v) === mode);
231
+ // The theme's own version of a brand color, when it differs from the brand's.
232
+ const visionAlt = (t, mode) => {
233
+ const v = visionOf(mode);
234
+ const alt =
235
+ v && home(t) === "Brand"
236
+ ? byPath.get(`vision.${v}.${t.path}`)
237
+ : undefined;
238
+ return alt && solve(alt.value) !== solve(t.value) ? alt : undefined;
239
+ };
248
240
 
249
- /**
250
- * A color token in a theme mode: { alias, hex } when it is one other variable, { hex } when
251
- * it is math or a literal.
252
- */
253
- const colorIn = (p, mode) => {
254
- const t = follow(p);
255
- if (!t) throw new Error(`Figma export: no token ${p}`);
256
- const alt = visionAlt(t, mode);
257
- if (alt) return { hex: solve(alt.value) };
258
- const expr =
259
- home(t) === "Brand" ? (t.references ?? t.value) : expressionIn(t, mode);
260
- const ref = refOf(expr);
261
- if (ref) {
262
- const inner = colorIn(ref, mode);
263
- const alias = target(ref, mode);
264
- return alias ? { alias, hex: inner.hex } : inner;
265
- }
266
- if (typeof expr === "string" && expr.includes("{"))
267
- return {
268
- hex: solve(
269
- expr.replace(/\{([^}]+)\}/g, (_, r) => colorIn(r, mode).hex),
270
- ),
271
- };
272
- return { hex: solve(expr) };
273
- };
274
- const value = (v) => (v.alias ? { alias: v.alias } : v.hex);
275
- const variable = (name, type, values, t, extra = {}) => ({
276
- name,
277
- type,
278
- values,
279
- scopes:
280
- extra.scopes ?? scopesOf(t?.path ?? name.replace(/\//g, "."), type),
281
- ...(extra.code !== null && t && { code: `var(${extra.code ?? t.name})` }),
282
- ...((extra.description ?? t?.description) && {
283
- description: extra.description ?? t.description,
284
- }),
285
- });
241
+ /**
242
+ * The variable a reference points at in a mode: a theme mode picks the Brand group, a size
243
+ * mode picks the font size group.
244
+ */
245
+ const target = (p, mode = "Light") => {
246
+ const t = follow(p);
247
+ const c = home(t);
248
+ if (!c) return null;
249
+ const name = figmaName(t.path);
250
+ if (c === "Brand" && t.type === "color")
251
+ return `Brand:${visionOf(mode) ?? "default"}/${name}`;
252
+ if (c === "Scale" && t.path.startsWith("font-size."))
253
+ return `Scale:${SIZES.includes(mode) ? mode.toLowerCase() : "desktop"}/${name}`;
254
+ return `${c}:${name}`;
255
+ };
286
256
 
287
- // Brand: fonts, then the brand's colors once per theme. Hidden, so nothing here is picked.
288
- const brandColors = theme.tokens.filter(
289
- (t) => home(t) === "Brand" && t.type === "color",
290
- );
291
- const brandVars = [];
292
- for (const t of theme.tokens) {
293
- if (home(t) !== "Brand" || t.type === "color") continue;
294
- const family = String(t.value).split(",")[0].replace(/['"]/g, "").trim();
295
- const ref = refOf(t.references);
296
- brandVars.push(
297
- variable(
298
- figmaName(t.path),
299
- "STRING",
300
- { Default: ref && target(ref) ? { alias: target(ref) } : family },
301
- t,
302
- { scopes: [] },
303
- ),
304
- );
257
+ /**
258
+ * A color token in a theme mode: { alias, hex } when it is one other variable, { hex } when
259
+ * it is math or a literal.
260
+ */
261
+ const colorIn = (p, mode) => {
262
+ const t = follow(p);
263
+ if (!t) throw new Error(`Figma export: no token ${p}`);
264
+ const alt = visionAlt(t, mode);
265
+ if (alt) return { hex: solve(alt.value) };
266
+ const expr =
267
+ home(t) === "Brand" ? (t.references ?? t.value) : expressionIn(t, mode);
268
+ const ref = refOf(expr);
269
+ if (ref) {
270
+ const inner = colorIn(ref, mode);
271
+ const alias = target(ref, mode);
272
+ return alias ? { alias, hex: inner.hex } : inner;
305
273
  }
306
- for (const t of brandColors)
307
- brandVars.push(
308
- variable(
309
- `default/${figmaName(t.path)}`,
310
- "COLOR",
311
- { Default: value(colorIn(t.path, "Light")) },
312
- t,
313
- { scopes: [] },
274
+ if (typeof expr === "string" && expr.includes("{"))
275
+ return {
276
+ hex: solve(
277
+ expr.replace(/\{([^}]+)\}/g, (_, r) => colorIn(r, mode).hex),
314
278
  ),
279
+ };
280
+ return { hex: solve(expr) };
281
+ };
282
+ const value = (v) => (v.alias ? { alias: v.alias } : v.hex);
283
+ const variable = (name, type, values, t, extra = {}) => ({
284
+ name,
285
+ type,
286
+ values,
287
+ scopes:
288
+ extra.scopes ?? scopesOf(t?.path ?? name.replace(/\//g, "."), type),
289
+ ...(extra.code !== null && t && { code: `var(${extra.code ?? t.name})` }),
290
+ ...((extra.description ?? t?.description) && {
291
+ description: extra.description ?? t.description,
292
+ }),
293
+ });
294
+
295
+ // Brand: fonts, then the brand's colors once per theme. Hidden, so nothing here is picked.
296
+ const brandColors = theme.tokens.filter(
297
+ (t) => home(t) === "Brand" && t.type === "color",
298
+ );
299
+ const brandVars = [];
300
+ for (const t of theme.tokens) {
301
+ if (home(t) !== "Brand" || t.type === "color") continue;
302
+ const family = String(t.value).split(",")[0].replace(/['"]/g, "").trim();
303
+ const ref = refOf(t.references);
304
+ brandVars.push(
305
+ variable(
306
+ figmaName(t.path),
307
+ "STRING",
308
+ { Default: ref && target(ref) ? { alias: target(ref) } : family },
309
+ t,
310
+ { scopes: [] },
311
+ ),
312
+ );
313
+ }
314
+ for (const t of brandColors)
315
+ brandVars.push(
316
+ variable(
317
+ `default/${figmaName(t.path)}`,
318
+ "COLOR",
319
+ { Default: value(colorIn(t.path, "Light")) },
320
+ t,
321
+ { scopes: [] },
322
+ ),
323
+ );
324
+ for (const v of visions) {
325
+ for (const t of brandColors) {
326
+ const alt = visionAlt(t, visionMode(v));
327
+ const description = alt
328
+ ? `${t.path} in the ${visionMode(v)} theme.`
329
+ : `${t.path} in the ${visionMode(v)} theme: the same as default.`;
330
+ const values = {
331
+ Default: alt
332
+ ? solve(alt.value)
333
+ : { alias: `Brand:default/${figmaName(t.path)}` },
334
+ };
335
+ brandVars.push(
336
+ variable(`${v}/${figmaName(t.path)}`, "COLOR", values, t, {
337
+ scopes: [],
338
+ description,
339
+ }),
315
340
  );
316
- for (const v of visions) {
317
- for (const t of brandColors) {
318
- const alt = visionAlt(t, visionMode(v));
319
- const description = alt
320
- ? `${t.path} in the ${visionMode(v)} theme.`
321
- : `${t.path} in the ${visionMode(v)} theme: the same as default.`;
322
- const values = {
323
- Default: alt
324
- ? solve(alt.value)
325
- : { alias: `Brand:default/${figmaName(t.path)}` },
326
- };
327
- brandVars.push(
328
- variable(`${v}/${figmaName(t.path)}`, "COLOR", values, t, {
329
- scopes: [],
330
- description,
331
- }),
332
- );
333
- }
334
341
  }
342
+ }
335
343
 
336
- // Scale: the raw steps. Hidden; font sizes once per size mode, so Size can switch them.
337
- const scaleVars = [];
338
- const fontSizes = [];
339
- for (const t of theme.tokens) {
340
- if (home(t) !== "Scale") continue;
341
- const ref = refOf(t.references);
342
- const s = sizes(t.value);
343
- if (t.path.startsWith("font-size.")) {
344
- fontSizes.push(t);
345
- for (const m of SIZES) {
346
- const alias =
347
- ref && target(ref, m)?.startsWith("Scale:")
348
- ? { alias: target(ref, m) }
349
- : null;
350
- scaleVars.push(
351
- variable(
352
- `${m.toLowerCase()}/${figmaName(t.path)}`,
353
- "FLOAT",
354
- { Default: alias ?? s[m] },
355
- t,
356
- {
357
- scopes: [],
358
- description: `${t.description ?? t.path} (${m.toLowerCase()})`,
359
- },
360
- ),
361
- );
362
- }
363
- } else {
344
+ // Scale: the raw steps. Hidden; font sizes once per size mode, so Size can switch them.
345
+ const scaleVars = [];
346
+ const fontSizes = [];
347
+ for (const t of theme.tokens) {
348
+ if (home(t) !== "Scale") continue;
349
+ const ref = refOf(t.references);
350
+ const s = sizes(t.value);
351
+ if (t.path.startsWith("font-size.")) {
352
+ fontSizes.push(t);
353
+ for (const m of SIZES) {
364
354
  const alias =
365
- ref && target(ref)?.startsWith("Scale:")
366
- ? { alias: target(ref) }
355
+ ref && target(ref, m)?.startsWith("Scale:")
356
+ ? { alias: target(ref, m) }
367
357
  : null;
368
358
  scaleVars.push(
369
359
  variable(
370
- figmaName(t.path),
360
+ `${m.toLowerCase()}/${figmaName(t.path)}`,
371
361
  "FLOAT",
372
- { Default: alias ?? s.Desktop },
362
+ { Default: alias ?? s[m] },
373
363
  t,
374
- { scopes: [] },
364
+ {
365
+ scopes: [],
366
+ description: `${t.description ?? t.path} (${m.toLowerCase()})`,
367
+ },
375
368
  ),
376
369
  );
377
370
  }
378
- }
379
-
380
- // Size: the font sizes, then the semantic and component sizes, fluid ones at both ends.
381
- const sizeVars = fontSizes.map((t) =>
382
- variable(
383
- figmaName(t.path),
384
- "FLOAT",
385
- Object.fromEntries(SIZES.map((m) => [m, { alias: target(t.path, m) }])),
386
- t,
387
- ),
388
- );
389
- for (const t of theme.tokens) {
390
- if (home(t) !== "Size") continue;
391
- const ref = refOf(t.references);
392
- // Figma reads a bound opacity as a percent, CSS as a fraction: 0.5 is 50 in Figma.
393
- const opacity = /opacity/.test(t.path);
394
- const s = opacity
395
- ? Object.fromEntries(
396
- Object.entries(sizes(t.value)).map(([m, n]) => [
397
- m,
398
- Math.round(n * 1000) / 10,
399
- ]),
400
- )
401
- : sizes(t.value);
402
- sizeVars.push(
371
+ } else {
372
+ const alias =
373
+ ref && target(ref)?.startsWith("Scale:")
374
+ ? { alias: target(ref) }
375
+ : null;
376
+ scaleVars.push(
403
377
  variable(
404
378
  figmaName(t.path),
405
379
  "FLOAT",
406
- Object.fromEntries(
407
- SIZES.map((m) => [
408
- m,
409
- ref && /^(Scale|Size):/.test(target(ref, m) ?? "")
410
- ? { alias: target(ref, m) }
411
- : s[m],
412
- ]),
413
- ),
380
+ { Default: alias ?? s.Desktop },
414
381
  t,
382
+ { scopes: [] },
415
383
  ),
416
384
  );
417
385
  }
386
+ }
418
387
 
419
- // Theme: every semantic color in each theme mode. Scopes and pair parts are what Status points
420
- // at, not what designers pick, so they get no scopes.
421
- const themeTokens = theme.tokens.filter((t) => home(t) === "Theme");
422
- const themeVars = themeTokens.map((t) =>
388
+ // Size: the font sizes, then the semantic and component sizes, fluid ones at both ends.
389
+ const sizeVars = fontSizes.map((t) =>
390
+ variable(
391
+ figmaName(t.path),
392
+ "FLOAT",
393
+ Object.fromEntries(SIZES.map((m) => [m, { alias: target(t.path, m) }])),
394
+ t,
395
+ ),
396
+ );
397
+ for (const t of theme.tokens) {
398
+ if (home(t) !== "Size") continue;
399
+ const ref = refOf(t.references);
400
+ // Figma reads a bound opacity as a percent, CSS as a fraction: 0.5 is 50 in Figma.
401
+ const opacity = /opacity/.test(t.path);
402
+ const s = opacity
403
+ ? Object.fromEntries(
404
+ Object.entries(sizes(t.value)).map(([m, n]) => [
405
+ m,
406
+ Math.round(n * 1000) / 10,
407
+ ]),
408
+ )
409
+ : sizes(t.value);
410
+ sizeVars.push(
423
411
  variable(
424
412
  figmaName(t.path),
425
- "COLOR",
413
+ "FLOAT",
426
414
  Object.fromEntries(
427
- themeModes.map((m) => [m, value(colorIn(t.path, m))]),
415
+ SIZES.map((m) => [
416
+ m,
417
+ ref && /^(Scale|Size):/.test(target(ref, m) ?? "")
418
+ ? { alias: target(ref, m) }
419
+ : s[m],
420
+ ]),
428
421
  ),
429
422
  t,
430
- /^(scope|pair)\./.test(t.path) ? { scopes: [] } : {},
431
423
  ),
432
424
  );
425
+ }
433
426
 
434
- // Data series: data/<n> points at the named data color each theme puts in that place, as
435
- // --sl-color-data-<n> does in CSS.
436
- const slots = theme.tokens.filter((t) => isDataSlot(t.path));
437
- for (const t of slots) {
438
- const n = t.path.split(".")[2];
439
- const values = {};
440
- for (const m of themeModes) {
441
- const v = visionOf(m);
442
- const slot = (v && byPath.get(`vision.${v}.color.data.${n}`)) || t;
443
- const name = refOf(slot.references)?.split(".").at(-1);
444
- values[m] = { alias: `Brand:${v ?? "default"}/color/data/${name}` };
445
- }
446
- themeVars.push(
447
- variable(`data/${n}`, "COLOR", values, t, {
448
- scopes: ["ALL_FILLS", "STROKE_COLOR"],
449
- }),
450
- );
451
- }
427
+ // Theme: every semantic color in each theme mode. Scopes and pair parts are what Status points
428
+ // at, not what designers pick, so they get no scopes.
429
+ const themeTokens = theme.tokens.filter((t) => home(t) === "Theme");
430
+ const themeVars = themeTokens.map((t) =>
431
+ variable(
432
+ figmaName(t.path),
433
+ "COLOR",
434
+ Object.fromEntries(
435
+ themeModes.map((m) => [m, value(colorIn(t.path, m))]),
436
+ ),
437
+ t,
438
+ /^(scope|pair)\./.test(t.path) ? { scopes: [] } : {},
439
+ ),
440
+ );
452
441
 
453
- // Status: each level's parts per status, pointing at a scope slot or a pair part in Theme.
454
- const statusTokens = theme.tokens.filter((t) =>
455
- t.path.startsWith("status-level."),
442
+ // Data series: data/<n> points at the named data color each theme puts in that place, as
443
+ // --sl-color-data-<n> does in CSS.
444
+ const slots = theme.tokens.filter((t) => isDataSlot(t.path));
445
+ for (const t of slots) {
446
+ const n = t.path.split(".")[2];
447
+ const values = {};
448
+ for (const m of themeModes) {
449
+ const v = visionOf(m);
450
+ const slot = (v && byPath.get(`vision.${v}.color.data.${n}`)) || t;
451
+ const name = refOf(slot.references)?.split(".").at(-1);
452
+ values[m] = { alias: `Brand:${v ?? "default"}/color/data/${name}` };
453
+ }
454
+ themeVars.push(
455
+ variable(`data/${n}`, "COLOR", values, t, {
456
+ scopes: ["ALL_FILLS", "STROKE_COLOR"],
457
+ }),
456
458
  );
457
- const statuses = [
458
- ...new Set(statusTokens.map((t) => t.path.split(".")[1])),
459
- ];
460
- const modeName = (n) => n[0].toUpperCase() + n.slice(1);
461
- const roles = withDefaults(theme.brand?.roles);
462
- const statusVars = [
463
- ...new Set(statusTokens.map((t) => t.path.split(".").slice(2).join("."))),
464
- ].map((levelPart) => {
465
- const [level, part] = levelPart.split(".");
466
- const values = {};
467
- for (const status of statuses) {
468
- const t = byPath.get(`status-level.${status}.${levelPart}`);
469
- const ref = refOf(t.references);
470
- values[modeName(status)] = ref
471
- ? { alias: `Theme:${figmaName(ref)}` }
472
- : solve(t.value);
473
- }
474
- // Neutral: the level's style in the chrome palette, as data-color="neutral" (palette="neutral") does in CSS.
475
- const slot = STYLES[roles.emphasis[level].style][part];
476
- values.Neutral =
477
- slot === "transparent"
478
- ? solve("transparent")
479
- : { alias: `Theme:scope/chrome/${slot}` };
480
- return {
481
- name: `${level}/${part}`,
482
- type: "COLOR",
483
- values,
484
- scopes: [],
485
- description: `The ${part} of ${level} emphasis in each status (data-status; Neutral is data-color="neutral").`,
486
- };
487
- });
459
+ }
488
460
 
489
- // Emphasis: data-emphasis. Each level is its row in Status, so it follows the status in effect.
490
- const levels = [
491
- ...new Set(
492
- theme.tokens
493
- .filter((t) => t.path.startsWith("emphasis."))
494
- .map((t) => t.path.split(".")[1]),
495
- ),
496
- ];
497
- const parts = [
498
- ...new Set(
499
- theme.tokens
500
- .filter((t) => t.path.startsWith("emphasis."))
501
- .map((t) => t.path.split(".")[2]),
502
- ),
503
- ];
504
- const emphasisVars = parts.map((part) => ({
505
- name: `emphasis/${part}`,
461
+ // Status: each level's parts per status, pointing at a scope slot or a pair part in Theme.
462
+ const statusTokens = theme.tokens.filter((t) =>
463
+ t.path.startsWith("status-level."),
464
+ );
465
+ const statuses = [
466
+ ...new Set(statusTokens.map((t) => t.path.split(".")[1])),
467
+ ];
468
+ const modeName = (n) => n[0].toUpperCase() + n.slice(1);
469
+ const roles = withDefaults(theme.brand?.roles);
470
+ const statusVars = [
471
+ ...new Set(statusTokens.map((t) => t.path.split(".").slice(2).join("."))),
472
+ ].map((levelPart) => {
473
+ const [level, part] = levelPart.split(".");
474
+ const values = {};
475
+ for (const status of statuses) {
476
+ const t = byPath.get(`status-level.${status}.${levelPart}`);
477
+ const ref = refOf(t.references);
478
+ values[modeName(status)] = ref
479
+ ? { alias: `Theme:${figmaName(ref)}` }
480
+ : solve(t.value);
481
+ }
482
+ // Neutral: the level's style in the chrome palette, as data-color="neutral" (palette="neutral") does in CSS.
483
+ const slot = STYLES[roles.emphasis[level].style][part];
484
+ values.Neutral =
485
+ slot === "transparent"
486
+ ? solve("transparent")
487
+ : { alias: `Theme:scope/chrome/${slot}` };
488
+ return {
489
+ name: `${level}/${part}`,
506
490
  type: "COLOR",
507
- values: Object.fromEntries(
508
- levels.map((level) => [
509
- modeName(level),
510
- { alias: `Status:${level}/${part}` },
511
- ]),
512
- ),
513
- scopes: /border/.test(part)
514
- ? ["STROKE_COLOR"]
515
- : /fg/.test(part)
516
- ? ["TEXT_FILL", "STROKE_COLOR"]
517
- : ["FRAME_FILL", "SHAPE_FILL"],
518
- code: `var(--${theme.prefix}-e-${part})`,
519
- description: `The ${part} of the emphasis level (data-emphasis), in the status in effect (data-status).`,
520
- }));
491
+ values,
492
+ scopes: [],
493
+ description: `The ${part} of ${level} emphasis in each status (data-status; Neutral is data-color="neutral").`,
494
+ };
495
+ });
521
496
 
522
- // Text styles: one per text style token, following the font family and the Size font size.
523
- const sizeVariable = (ref) => {
524
- const t = follow(ref);
525
- return ["Scale", "Size"].includes(home(t))
526
- ? `Size:${figmaName(t.path)}`
527
- : null;
497
+ // Emphasis: data-emphasis. Each level is its row in Status, so it follows the status in effect.
498
+ const levels = [
499
+ ...new Set(
500
+ theme.tokens
501
+ .filter((t) => t.path.startsWith("emphasis."))
502
+ .map((t) => t.path.split(".")[1]),
503
+ ),
504
+ ];
505
+ const parts = [
506
+ ...new Set(
507
+ theme.tokens
508
+ .filter((t) => t.path.startsWith("emphasis."))
509
+ .map((t) => t.path.split(".")[2]),
510
+ ),
511
+ ];
512
+ const emphasisVars = parts.map((part) => ({
513
+ name: `emphasis/${part}`,
514
+ type: "COLOR",
515
+ values: Object.fromEntries(
516
+ levels.map((level) => [
517
+ modeName(level),
518
+ { alias: `Status:${level}/${part}` },
519
+ ]),
520
+ ),
521
+ scopes: /border/.test(part)
522
+ ? ["STROKE_COLOR"]
523
+ : /fg/.test(part)
524
+ ? ["TEXT_FILL", "STROKE_COLOR"]
525
+ : ["FRAME_FILL", "SHAPE_FILL"],
526
+ code: `var(--${theme.prefix}-e-${part})`,
527
+ description: `The ${part} of the emphasis level (data-emphasis), in the status in effect (data-status).`,
528
+ }));
529
+
530
+ // Text styles: one per text style token, following the font family and the Size font size.
531
+ const sizeVariable = (ref) => {
532
+ const t = follow(ref);
533
+ return ["Scale", "Size"].includes(home(t))
534
+ ? `Size:${figmaName(t.path)}`
535
+ : null;
536
+ };
537
+ const styleNames = [
538
+ ...new Set(
539
+ theme.tokens
540
+ .filter((t) => /^font\.[^.]+\.font-size$/.test(t.path))
541
+ .map((t) => t.path.split(".")[1]),
542
+ ),
543
+ ];
544
+ const textStyles = styleNames.map((name) => {
545
+ const part = (p) => byPath.get(`font.${name}.${p}`);
546
+ const family = part("font-family");
547
+ const size = part("font-size");
548
+ const weight = Number(part("font-weight").value);
549
+ const lineHeight = String(part("line-height").value);
550
+ const letterSpacing = String(part("letter-spacing").value);
551
+ const transform = part("text-transform")?.value;
552
+ const em = letterSpacing.match(/^(-?[\d.]+)em$/);
553
+ const familyRef = refOf(family.references);
554
+ const sizeRef = refOf(size.references);
555
+ const font = byPath.get(`font.${name}.font`);
556
+ return {
557
+ name,
558
+ family: String(family.value).split(",")[0].replace(/['"]/g, "").trim(),
559
+ ...(familyRef &&
560
+ target(familyRef) && { familyVariable: target(familyRef) }),
561
+ weight,
562
+ style: WEIGHT_STYLES[weight] ?? "Regular",
563
+ size: sizes(size.value),
564
+ ...(sizeRef &&
565
+ sizeVariable(sizeRef) && { sizeVariable: sizeVariable(sizeRef) }),
566
+ lineHeight: /^[\d.]+$/.test(lineHeight)
567
+ ? {
568
+ unit: "PERCENT",
569
+ value: Math.round(Number(lineHeight) * 1000) / 10,
570
+ }
571
+ : { unit: "PIXELS", value: px(lineHeight) },
572
+ letterSpacing: em
573
+ ? { unit: "PERCENT", value: Math.round(Number(em[1]) * 1000) / 10 }
574
+ : { unit: "PIXELS", value: px(letterSpacing) ?? 0 },
575
+ ...(transform && {
576
+ textCase:
577
+ { uppercase: "UPPER", lowercase: "LOWER", capitalize: "TITLE" }[
578
+ transform
579
+ ] ?? "ORIGINAL",
580
+ }),
581
+ description: `${(font?.description ?? name).replace(/ As a font shorthand;.*$/, "")}\nCSS: .text-style-${name} or @sl-pattern text-${name};`,
528
582
  };
529
- const styleNames = [
530
- ...new Set(
531
- theme.tokens
532
- .filter((t) => /^font\.[^.]+\.font-size$/.test(t.path))
533
- .map((t) => t.path.split(".")[1]),
534
- ),
535
- ];
536
- const textStyles = styleNames.map((name) => {
537
- const part = (p) => byPath.get(`font.${name}.${p}`);
538
- const family = part("font-family");
539
- const size = part("font-size");
540
- const weight = Number(part("font-weight").value);
541
- const lineHeight = String(part("line-height").value);
542
- const letterSpacing = String(part("letter-spacing").value);
543
- const transform = part("text-transform")?.value;
544
- const em = letterSpacing.match(/^(-?[\d.]+)em$/);
545
- const familyRef = refOf(family.references);
546
- const sizeRef = refOf(size.references);
547
- const font = byPath.get(`font.${name}.font`);
583
+ });
584
+
585
+ // Effect styles: a shadow is several values at once, so it can't be a variable. Each
586
+ // elevation step and focus look is an effect style; colors that are Theme tokens stay bound.
587
+ const colorOf = (css) => {
588
+ const ref = css.match(/^\{([^}]+)\}$/)?.[1];
589
+ if (!ref) return { hex: solve(css) };
590
+ return { hex: colorIn(ref, "Light").hex, variable: target(ref) };
591
+ };
592
+ const layersOf = (value, references) => {
593
+ const split = (v) =>
594
+ String(v)
595
+ .split(/,(?![^(]*\))/)
596
+ .map((x) => x.trim());
597
+ const refs = references ? split(references) : [];
598
+ return split(value).map((layer, i) => {
599
+ const m = layer.match(
600
+ /^(inset\s+)?(-?[\d.]+)(?:px)?\s+(-?[\d.]+)(?:px)?\s+(-?[\d.]+)(?:px)?(?:\s+(-?[\d.]+)(?:px)?)?\s+(.+)$/,
601
+ );
602
+ if (!m) throw new Error(`Figma export: can't read the shadow ${layer}`);
603
+ const colorRef = refs[i]?.match(/(\{[^}]+\})\s*$/)?.[1];
604
+ const color = colorOf(colorRef ?? m[6]);
548
605
  return {
549
- name,
550
- family: String(family.value).split(",")[0].replace(/['"]/g, "").trim(),
551
- ...(familyRef &&
552
- target(familyRef) && { familyVariable: target(familyRef) }),
553
- weight,
554
- style: WEIGHT_STYLES[weight] ?? "Regular",
555
- size: sizes(size.value),
556
- ...(sizeRef &&
557
- sizeVariable(sizeRef) && { sizeVariable: sizeVariable(sizeRef) }),
558
- lineHeight: /^[\d.]+$/.test(lineHeight)
559
- ? {
560
- unit: "PERCENT",
561
- value: Math.round(Number(lineHeight) * 1000) / 10,
562
- }
563
- : { unit: "PIXELS", value: px(lineHeight) },
564
- letterSpacing: em
565
- ? { unit: "PERCENT", value: Math.round(Number(em[1]) * 1000) / 10 }
566
- : { unit: "PIXELS", value: px(letterSpacing) ?? 0 },
567
- ...(transform && {
568
- textCase:
569
- { uppercase: "UPPER", lowercase: "LOWER", capitalize: "TITLE" }[
570
- transform
571
- ] ?? "ORIGINAL",
572
- }),
573
- description: `${(font?.description ?? name).replace(/ As a font shorthand;.*$/, "")}\nCSS: .text-style-${name} or @sl-pattern text-${name};`,
606
+ type: m[1] ? "INNER_SHADOW" : "DROP_SHADOW",
607
+ x: Number(m[2]),
608
+ y: Number(m[3]),
609
+ blur: Number(m[4]),
610
+ spread: Number(m[5] ?? 0),
611
+ color: color.hex,
612
+ ...(color.variable && { colorVariable: color.variable }),
574
613
  };
575
614
  });
615
+ };
616
+ const ring = (spread, ref) => ({
617
+ type: "DROP_SHADOW",
618
+ x: 0,
619
+ y: 0,
620
+ blur: 0,
621
+ spread,
622
+ color: colorIn(ref, "Light").hex,
623
+ ...(target(ref) && { colorVariable: target(ref) }),
624
+ });
625
+ const pxOf = (p) =>
626
+ String(byPath.get(p).value)
627
+ .match(/-?[\d.]+/g)
628
+ .reduce((a, n) => a + Number(n), 0);
629
+ const gap = pxOf("a11y.focus-outline-offset");
630
+ const ringEdge = gap + pxOf("a11y.focus-outline-width");
631
+ const STEPS = ["xs", "sm", "base", "md", "lg", "xl", "2xl"];
632
+ const css = (path) =>
633
+ `\nCSS: var(--${theme.prefix}-${path.replace(/\./g, "-")})`;
634
+ const effectStyles = [
635
+ ...theme.tokens
636
+ .filter((t) => /^elevation\.[^.]+$/.test(t.path))
637
+ .sort(
638
+ (a, b) =>
639
+ STEPS.indexOf(a.path.split(".")[1]) -
640
+ STEPS.indexOf(b.path.split(".")[1]),
641
+ )
642
+ .map((t) => ({
643
+ name: figmaName(t.path),
644
+ effects: layersOf(t.value),
645
+ description: `${t.description ?? `Elevation ${t.path.split(".")[1]}`}.${css(t.path)}`,
646
+ })),
647
+ {
648
+ name: "focus/ring",
649
+ effects: [
650
+ ring(gap, "surface.canvas"),
651
+ ring(ringEdge, "focus.ring"),
652
+ ring(
653
+ pxOf("focus.halo-spread"),
654
+ refOf(byPath.get("focus.halo-color").references),
655
+ ),
656
+ ],
657
+ description: `Keyboard focus: a ${gap}px gap, a ${ringEdge - gap}px ring, then a soft halo. In CSS the ring is an outline, so it follows the element's corners.\nCSS: @sl-pattern focus-ring;`,
658
+ },
659
+ ...[
660
+ ["focus/field", "focus.shadow-3px-primary"],
661
+ ["focus/field-error", "focus.shadow-3px-error"],
662
+ ].map(([name, path]) => ({
663
+ name,
664
+ effects: layersOf(byPath.get(path).value, byPath.get(path).references),
665
+ description: `${byPath.get(path).description}${css(path)}`,
666
+ })),
667
+ ];
576
668
 
577
- // Effect styles: a shadow is several values at once, so it can't be a variable. Each
578
- // elevation step and focus look is an effect style; colors that are Theme tokens stay bound.
579
- const colorOf = (css) => {
580
- const ref = css.match(/^\{([^}]+)\}$/)?.[1];
581
- if (!ref) return { hex: solve(css) };
582
- return { hex: colorIn(ref, "Light").hex, variable: target(ref) };
583
- };
584
- const layersOf = (value, references) => {
585
- const split = (v) =>
586
- String(v)
587
- .split(/,(?![^(]*\))/)
588
- .map((x) => x.trim());
589
- const refs = references ? split(references) : [];
590
- return split(value).map((layer, i) => {
591
- const m = layer.match(
592
- /^(inset\s+)?(-?[\d.]+)(?:px)?\s+(-?[\d.]+)(?:px)?\s+(-?[\d.]+)(?:px)?(?:\s+(-?[\d.]+)(?:px)?)?\s+(.+)$/,
593
- );
594
- if (!m) throw new Error(`Figma export: can't read the shadow ${layer}`);
595
- const colorRef = refs[i]?.match(/(\{[^}]+\})\s*$/)?.[1];
596
- const color = colorOf(colorRef ?? m[6]);
597
- return {
598
- type: m[1] ? "INNER_SHADOW" : "DROP_SHADOW",
599
- x: Number(m[2]),
600
- y: Number(m[3]),
601
- blur: Number(m[4]),
602
- spread: Number(m[5] ?? 0),
603
- color: color.hex,
604
- ...(color.variable && { colorVariable: color.variable }),
605
- };
606
- });
607
- };
608
- const ring = (spread, ref) => ({
609
- type: "DROP_SHADOW",
610
- x: 0,
611
- y: 0,
612
- blur: 0,
613
- spread,
614
- color: colorIn(ref, "Light").hex,
615
- ...(target(ref) && { colorVariable: target(ref) }),
616
- });
617
- const pxOf = (p) =>
618
- String(byPath.get(p).value)
619
- .match(/-?[\d.]+/g)
620
- .reduce((a, n) => a + Number(n), 0);
621
- const gap = pxOf("a11y.focus-outline-offset");
622
- const ringEdge = gap + pxOf("a11y.focus-outline-width");
623
- const STEPS = ["xs", "sm", "base", "md", "lg", "xl", "2xl"];
624
- const css = (path) =>
625
- `\nCSS: var(--${theme.prefix}-${path.replace(/\./g, "-")})`;
626
- const effectStyles = [
627
- ...theme.tokens
628
- .filter((t) => /^elevation\.[^.]+$/.test(t.path))
629
- .sort(
630
- (a, b) =>
631
- STEPS.indexOf(a.path.split(".")[1]) -
632
- STEPS.indexOf(b.path.split(".")[1]),
633
- )
634
- .map((t) => ({
635
- name: figmaName(t.path),
636
- effects: layersOf(t.value),
637
- description: `${t.description ?? `Elevation ${t.path.split(".")[1]}`}.${css(t.path)}`,
638
- })),
669
+ const out = {
670
+ format: FORMAT,
671
+ version: VERSION,
672
+ brand: theme.brand,
673
+ prefix: theme.prefix,
674
+ collections: [
639
675
  {
640
- name: "focus/ring",
641
- effects: [
642
- ring(gap, "surface.canvas"),
643
- ring(ringEdge, "focus.ring"),
644
- ring(
645
- pxOf("focus.halo-spread"),
646
- refOf(byPath.get("focus.halo-color").references),
647
- ),
648
- ],
649
- description: `Keyboard focus: a ${gap}px gap, a ${ringEdge - gap}px ring, then a soft halo. In CSS the ring is an outline, so it follows the element's corners.\nCSS: @sl-pattern focus-ring;`,
676
+ name: "Brand",
677
+ modes: ["Default"],
678
+ hidden: true,
679
+ variables: brandVars,
650
680
  },
651
- ...[
652
- ["focus/field", "focus.shadow-3px-primary"],
653
- ["focus/field-error", "focus.shadow-3px-error"],
654
- ].map(([name, path]) => ({
655
- name,
656
- effects: layersOf(byPath.get(path).value, byPath.get(path).references),
657
- description: `${byPath.get(path).description}${css(path)}`,
658
- })),
659
- ];
681
+ {
682
+ name: "Scale",
683
+ modes: ["Default"],
684
+ hidden: true,
685
+ variables: scaleVars,
686
+ },
687
+ { name: "Size", modes: SIZES, variables: sizeVars },
688
+ { name: "Theme", modes: themeModes, variables: themeVars },
689
+ {
690
+ name: "Status",
691
+ modes: [...statuses.map(modeName), "Neutral"],
692
+ variables: statusVars,
693
+ },
694
+ {
695
+ name: "Emphasis",
696
+ modes: levels.map(modeName),
697
+ variables: emphasisVars,
698
+ },
699
+ ],
700
+ textStyles,
701
+ effectStyles,
702
+ flat: flat(theme, byPath),
703
+ };
704
+ return out;
705
+ }
660
706
 
661
- const out = {
662
- format: FORMAT,
663
- version: VERSION,
664
- brand: theme.brand,
665
- prefix: theme.prefix,
666
- collections: [
667
- {
668
- name: "Brand",
669
- modes: ["Default"],
670
- hidden: true,
671
- variables: brandVars,
672
- },
673
- {
674
- name: "Scale",
675
- modes: ["Default"],
676
- hidden: true,
677
- variables: scaleVars,
678
- },
679
- { name: "Size", modes: SIZES, variables: sizeVars },
680
- { name: "Theme", modes: themeModes, variables: themeVars },
681
- {
682
- name: "Status",
683
- modes: [...statuses.map(modeName), "Neutral"],
684
- variables: statusVars,
685
- },
686
- {
687
- name: "Emphasis",
688
- modes: levels.map(modeName),
689
- variables: emphasisVars,
690
- },
691
- ],
692
- textStyles,
693
- effectStyles,
694
- flat: flat(theme, byPath),
695
- };
696
- return `${JSON.stringify(out)}\n`;
697
- },
698
- };
707
+ /**
708
+ * figma.json for a brand with several looks (generator/looks.mjs). Brand has one mode per look,
709
+ * so one switch changes the look: its colors, data colors and fonts are Brand's, per mode. A
710
+ * value elsewhere that differs between looks (a Scale step, a Size pick, a Theme concept, a data
711
+ * series slot) can't switch with Brand's mode from its own collection, so it points at a Brand
712
+ * variable named look/<collection>/<mode>/<variable>, which holds each look's value. Text styles
713
+ * and effect styles are the brand's own look's; their fonts, sizes and Theme colors follow the
714
+ * look through the variables they are bound to.
715
+ */
716
+ function withLooks(theme) {
717
+ const built = theme.looks.map((look) => ({ ...look, out: single(look.base ? theme : look.theme) }));
718
+ const own = built.find((l) => l.base) ?? built[0];
719
+ const modes = built.map((l) => l.label);
720
+ const index = (out) => new Map(out.collections.map((c) => [c.name, new Map(c.variables.map((v) => [v.name, v]))]));
721
+ const indexes = new Map(built.map((l) => [l, index(l.out)]));
722
+ const union = (name) => {
723
+ const all = new Map();
724
+ for (const l of built) for (const [key, v] of indexes.get(l).get(name)) if (!all.has(key)) all.set(key, v);
725
+ return all;
726
+ };
727
+ const same = (values) => values.every((v) => JSON.stringify(v) === JSON.stringify(values[0]));
728
+ const slug = (n) => n.toLowerCase().replace(/\s+/g, "-");
729
+
730
+ const brandVars = [...union("Brand")].map(([name, v]) => ({
731
+ ...v,
732
+ values: Object.fromEntries(built.map((l) => [l.label, (indexes.get(l).get("Brand").get(name) ?? v).values.Default])),
733
+ }));
734
+ const collections = own.out.collections.map((c) => {
735
+ // Published so files using the library can set the look; its variables keep no scopes, so
736
+ // they stay out of the pickers.
737
+ if (c.name === "Brand") return { ...c, modes, hidden: false, variables: brandVars };
738
+ const variables = [...union(c.name)].map(([name, v]) => {
739
+ const values = {};
740
+ for (const m of c.modes) {
741
+ const each = built.map((l) => (indexes.get(l).get(c.name).get(name) ?? v).values[m]);
742
+ if (same(each)) {
743
+ values[m] = each[0];
744
+ continue;
745
+ }
746
+ const key = `look/${slug(c.name)}/${slug(m)}/${name}`;
747
+ brandVars.push({
748
+ name: key,
749
+ type: v.type,
750
+ values: Object.fromEntries(built.map((l, i) => [l.label, each[i]])),
751
+ scopes: [],
752
+ description: `${c.name} ${name} in ${m}, in each look.`,
753
+ });
754
+ values[m] = { alias: `Brand:${key}` };
755
+ }
756
+ return { ...v, values };
757
+ });
758
+ return { ...c, variables };
759
+ });
760
+ return {
761
+ ...own.out,
762
+ looks: built.map(({ name, label, base }) => ({ name, label, ...(base && { base: true }) })),
763
+ collections,
764
+ };
765
+ }
699
766
 
700
767
  /**
701
768
  * One mode per collection, for Figma Starter: Brand (no vision themes), Color Pair, Theme and