@scattered-light/loomiere 0.4.1 → 0.5.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.
- package/README.md +3 -1
- package/bin/loomiere.mjs +3 -1
- package/build/build-tokens.mjs +180 -35
- package/exporters/design-system.mjs +7 -0
- package/exporters/figma.mjs +545 -478
- package/exporters/index.mjs +4 -0
- package/figma/README.md +16 -1
- package/figma/library/pages/03-color.js +1 -1
- package/generator/brand.schema.json +83 -0
- package/generator/generate.mjs +26 -6
- package/generator/looks.mjs +62 -0
- package/lib/contrast.mjs +24 -6
- package/lib/figma.mjs +58 -23
- package/package.json +2 -1
- package/tokens/README.md +26 -0
package/exporters/figma.mjs
CHANGED
|
@@ -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
|
|
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
|
|
175
|
+
const out = theme.looks?.length > 1 ? withLooks(theme) : single(theme);
|
|
176
|
+
return `${JSON.stringify(out)}\n`;
|
|
177
|
+
},
|
|
178
|
+
};
|
|
175
179
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
362
|
+
{ Default: alias ?? s[m] },
|
|
373
363
|
t,
|
|
374
|
-
{
|
|
364
|
+
{
|
|
365
|
+
scopes: [],
|
|
366
|
+
description: `${t.description ?? t.path} (${m.toLowerCase()})`,
|
|
367
|
+
},
|
|
375
368
|
),
|
|
376
369
|
);
|
|
377
370
|
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
"
|
|
413
|
+
"FLOAT",
|
|
426
414
|
Object.fromEntries(
|
|
427
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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: "
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
653
|
-
["
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|