@scattered-light/loomiere 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/bin/loomiere.mjs +24 -0
- package/build/build-tokens.mjs +93 -49
- package/exporters/README.md +1 -1
- package/exporters/ag-grid-settings.mjs +7 -7
- package/exporters/ag-grid.mjs +6 -6
- package/exporters/chakra.mjs +43 -43
- package/exporters/component-styles.mjs +4 -4
- package/exporters/design-system.mjs +17 -12
- package/exporters/figma.mjs +774 -68
- package/exporters/mui.mjs +40 -40
- package/exporters/shadcn.mjs +5 -13
- package/exporters/tailwind.mjs +14 -5
- package/figma/README.md +135 -0
- package/figma/build-plugin.mjs +25 -0
- package/figma/engine.js +730 -0
- package/figma/plugin/code.js +746 -0
- package/figma/plugin/manifest.json +14 -0
- package/figma/plugin/ui.html +237 -0
- package/figma/plugin-main.js +14 -0
- package/generator/brand.mjs +16 -14
- package/generator/brand.schema.json +60 -0
- package/generator/generate.mjs +2 -2
- package/generator/pairs.mjs +6 -1
- package/generator/roles.mjs +55 -15
- package/generator/vision.mjs +7 -2
- package/lib/config.mjs +2 -0
- package/lib/contrast.mjs +8 -3
- package/lib/figma.mjs +338 -0
- package/lib/studio.mjs +4 -0
- package/lib/tool-server.mjs +7 -4
- package/package.json +5 -1
- package/tokens/README.md +10 -5
- package/tokens/component/avatar.json +41 -6
- package/tokens/semantic/color.json +1 -1
- package/tokens/semantic/elevation.json +8 -43
- package/tokens/semantic/typography.json +61 -94
- package/tools/brand-builder/builder.js +33 -4
package/exporters/figma.mjs
CHANGED
|
@@ -1,100 +1,806 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Figma variables,
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
2
|
+
* Figma variables, text styles and effect styles: figma.json, the file the Figma sync reads (figma/README.md).
|
|
3
|
+
*
|
|
4
|
+
* Hidden collections hold raw values, with a group per variant; published ones are what
|
|
5
|
+
* designers pick from and switch. Figma resolves each collection's mode on its own (a mode can't
|
|
6
|
+
* pick another collection's mode), so a variant that one switch must change lives as a group in
|
|
7
|
+
* a hidden collection, and the published collection's modes point at the right group.
|
|
8
|
+
*
|
|
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:
|
|
11
|
+
* default/color/…, then deuteranopia/color/… and the other color vision themes,
|
|
12
|
+
* where a color the theme doesn't change points at default's. The data colors are
|
|
13
|
+
* here by name (color/data/violet…).
|
|
14
|
+
* Scale hidden, one mode. The space, radius, stroke and icon size scales, and the font
|
|
15
|
+
* sizes as desktop/, tablet/ and mobile/font-size/… (each clamp() at that width).
|
|
16
|
+
* Size the sizes designers pick: the semantic and component sizes and the font sizes.
|
|
17
|
+
* Modes: Desktop, Tablet, Mobile.
|
|
18
|
+
* Theme every semantic color, the palette scopes, each brand pair's parts (pair/<name>/…)
|
|
19
|
+
* and the data series slots (data/1…), each pointing at a named data color. Modes:
|
|
20
|
+
* Light, Dark, Light high contrast, Dark high contrast, and one per color vision
|
|
21
|
+
* theme (Deuteranopia…), each a light theme of its own: the modes are data-theme
|
|
22
|
+
* in CSS. Scopes and pairs have no scopes, so only Status reaches them.
|
|
23
|
+
* Status what each emphasis level is in each status: a brand pair the brand file picked
|
|
24
|
+
* (roles.status) or the level's style in a palette. Modes: Default, Info, Success,
|
|
25
|
+
* Warning, Error (data-status), then Neutral: the chrome palette, as
|
|
26
|
+
* data-color="neutral". No scopes; Emphasis reads it.
|
|
27
|
+
* Emphasis the emphasis parts components paint with. Modes: Bold, Standard, Subtle, Quiet
|
|
28
|
+
* (data-emphasis), each pointing at its level in Status.
|
|
29
|
+
* So a frame can be Dark, Warning and Subtle at once, as the CSS can.
|
|
30
|
+
|
|
31
|
+
* Plans with one mode per collection (Figma Starter) get `flat` instead: Brand, Color Pair, Theme
|
|
32
|
+
* and "Theme dark", each color resolved for its mode.
|
|
33
|
+
*
|
|
34
|
+
* Values are a hex, a number, a string, or { alias: "Collection:variable/name" }. Values that
|
|
35
|
+
* are math in CSS (color-mix) are resolved to colors. Every variable carries its CSS variable
|
|
36
|
+
* as WEB code syntax. Text styles name the variables their font family and size follow; effect styles name
|
|
37
|
+
* the Theme variables their shadow colors follow.
|
|
11
38
|
*/
|
|
12
|
-
import { formatHex, formatHex8, interpolate, parse } from
|
|
39
|
+
import { formatHex, formatHex8, interpolate, parse } from "culori";
|
|
40
|
+
|
|
41
|
+
import { px, valueIn } from "./theme.mjs";
|
|
42
|
+
import { STYLES, withDefaults } from "../generator/roles.mjs";
|
|
43
|
+
|
|
44
|
+
export const FORMAT = "loomiere-figma";
|
|
45
|
+
export const VERSION = 2;
|
|
13
46
|
|
|
14
47
|
/** #rrggbb, or #rrggbbaa when the color is see-through. */
|
|
15
48
|
const hex = (c) => ((c.alpha ?? 1) < 1 ? formatHex8(c) : formatHex(c));
|
|
16
49
|
|
|
17
|
-
import { px, valueIn } from './theme.mjs';
|
|
18
|
-
|
|
19
50
|
/** color-mix(in oklab|srgb, A p%, B) → a hex; anything else parse() understands → a hex. */
|
|
20
51
|
function solve(value) {
|
|
21
|
-
const mix = String(value).match(
|
|
22
|
-
|
|
23
|
-
|
|
52
|
+
const mix = String(value).match(
|
|
53
|
+
/^color-mix\(in (\w+),\s*(.+?)\s+([\d.]+)%,\s*(.+)\)$/,
|
|
54
|
+
);
|
|
55
|
+
if (mix)
|
|
56
|
+
return hex(
|
|
57
|
+
interpolate(
|
|
58
|
+
[solve(mix[4]), solve(mix[2])],
|
|
59
|
+
mix[1] === "srgb" ? "rgb" : mix[1],
|
|
60
|
+
)(Number(mix[3]) / 100),
|
|
61
|
+
);
|
|
62
|
+
if (value === "transparent") return "#00000000";
|
|
24
63
|
const c = parse(value);
|
|
25
64
|
return c ? hex(c) : null;
|
|
26
65
|
}
|
|
27
66
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
67
|
+
const SCOPES = {
|
|
68
|
+
surface: ["FRAME_FILL", "SHAPE_FILL"],
|
|
69
|
+
text: ["TEXT_FILL"],
|
|
70
|
+
border: ["STROKE_COLOR"],
|
|
71
|
+
color: ["ALL_FILLS", "STROKE_COLOR"],
|
|
72
|
+
space: ["GAP", "WIDTH_HEIGHT"],
|
|
73
|
+
gap: ["GAP"],
|
|
74
|
+
spacing: ["GAP"],
|
|
75
|
+
inset: ["GAP"],
|
|
76
|
+
radius: ["CORNER_RADIUS"],
|
|
77
|
+
stroke: ["STROKE_FLOAT"],
|
|
78
|
+
"font-size": ["FONT_SIZE"],
|
|
79
|
+
"font-family": ["FONT_FAMILY"],
|
|
80
|
+
"icon-size": ["WIDTH_HEIGHT"],
|
|
81
|
+
};
|
|
82
|
+
const scopesOf = (path, type) => {
|
|
83
|
+
const top = path.split(".")[0];
|
|
84
|
+
if (SCOPES[top]) return SCOPES[top];
|
|
85
|
+
if (type === "COLOR") return ["ALL_FILLS", "STROKE_COLOR", "EFFECT_COLOR"];
|
|
86
|
+
// Semantic sizes: the last word says what they size.
|
|
87
|
+
if (/opacity/.test(path)) return ["OPACITY"];
|
|
88
|
+
if (/radius/.test(path)) return ["CORNER_RADIUS"];
|
|
89
|
+
if (/border-width|stroke/.test(path)) return ["STROKE_FLOAT"];
|
|
90
|
+
if (/gap|padding|inset|margin/.test(path)) return ["GAP"];
|
|
91
|
+
return ["WIDTH_HEIGHT", "GAP"];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const figmaName = (path) => path.replace(/\./g, "/");
|
|
95
|
+
const refOf = (r) =>
|
|
96
|
+
typeof r === "string" ? r.match(/^\{([^}]+)\}$/)?.[1] : undefined;
|
|
97
|
+
|
|
98
|
+
/** Theme modes: the CSS mode blocks each one sees, last wins (build-tokens.mjs MODES order). */
|
|
99
|
+
const THEMES = {
|
|
100
|
+
Light: [],
|
|
101
|
+
Dark: ["dark"],
|
|
102
|
+
"Light high contrast": ["contrast-more"],
|
|
103
|
+
"Dark high contrast": ["dark", "contrast-more", "dark-contrast-more"],
|
|
32
104
|
};
|
|
105
|
+
const SIZES = ["Desktop", "Tablet", "Mobile"];
|
|
106
|
+
/** The width Figma's Tablet mode stands for: where a fluid size is read for tablet frames. */
|
|
107
|
+
const TABLET_WIDTH = 768;
|
|
33
108
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
109
|
+
/** A token's expression in a theme mode: the last mode block that sets it, references kept. */
|
|
110
|
+
function expressionIn(t, mode) {
|
|
111
|
+
for (const block of [...(THEMES[mode] ?? [])].reverse()) {
|
|
112
|
+
if (t.modes?.[block] !== undefined)
|
|
113
|
+
return t.modeReferences?.[block] ?? t.modes[block];
|
|
114
|
+
}
|
|
115
|
+
return t.references ?? t.value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* "clamp(0.875rem, 0.8333rem + 0.2083vw, 1rem)" → { Desktop: 16, Tablet: 15, Mobile: 14 }, the
|
|
120
|
+
* Tablet value read at TABLET_WIDTH; "0.5rem" → 8 in all three.
|
|
121
|
+
*/
|
|
122
|
+
function sizes(value) {
|
|
123
|
+
const m = String(value).match(/^clamp\(\s*([^,]+),\s*([^,]+),\s*([^,)]+)\)$/);
|
|
124
|
+
if (!m) {
|
|
125
|
+
const v = px(value);
|
|
126
|
+
return v === undefined ? null : { Desktop: v, Tablet: v, Mobile: v };
|
|
127
|
+
}
|
|
128
|
+
const [small, large] = [px(m[1].trim()), px(m[3].trim())];
|
|
129
|
+
if (small === undefined || large === undefined) return null;
|
|
130
|
+
const fluid = m[2].match(/^([\d.]+(?:rem|px))\s*\+\s*([\d.]+)vw$/);
|
|
131
|
+
const at = fluid
|
|
132
|
+
? px(fluid[1]) + (Number(fluid[2]) * TABLET_WIDTH) / 100
|
|
133
|
+
: (small + large) / 2;
|
|
134
|
+
const tablet = Math.round(Math.min(large, Math.max(small, at)) * 100) / 100;
|
|
135
|
+
return { Desktop: large, Tablet: tablet, Mobile: small };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const SIZE_TOPS = new Set([
|
|
139
|
+
"space",
|
|
140
|
+
"radius",
|
|
141
|
+
"stroke",
|
|
142
|
+
"font-size",
|
|
143
|
+
"icon-size",
|
|
144
|
+
]);
|
|
145
|
+
const NOT_SIZES = new Set([
|
|
146
|
+
"breakpoint",
|
|
147
|
+
"container",
|
|
148
|
+
"measure",
|
|
149
|
+
"letter-spacing",
|
|
150
|
+
"z-index",
|
|
151
|
+
"font-weight",
|
|
152
|
+
"line-height",
|
|
153
|
+
"font",
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
const WEIGHT_STYLES = {
|
|
157
|
+
100: "Thin",
|
|
158
|
+
200: "ExtraLight",
|
|
159
|
+
300: "Light",
|
|
160
|
+
400: "Regular",
|
|
161
|
+
500: "Medium",
|
|
162
|
+
600: "SemiBold",
|
|
163
|
+
700: "Bold",
|
|
164
|
+
800: "ExtraBold",
|
|
165
|
+
900: "Black",
|
|
44
166
|
};
|
|
45
|
-
const scopesOf = (path) => SCOPES[path.split('.')[0]] ?? ['ALL_FILLS', 'STROKE_COLOR', 'EFFECT_COLOR'];
|
|
46
167
|
|
|
47
168
|
export default {
|
|
48
|
-
id:
|
|
49
|
-
name:
|
|
50
|
-
file:
|
|
51
|
-
for:
|
|
169
|
+
id: "figma",
|
|
170
|
+
name: "Figma variables",
|
|
171
|
+
file: "figma.json",
|
|
172
|
+
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.",
|
|
52
173
|
build(theme) {
|
|
53
174
|
const byPath = new Map(theme.tokens.map((t) => [t.path, t]));
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
175
|
+
|
|
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;
|
|
61
204
|
};
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
};
|
|
211
|
+
|
|
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
|
+
};
|
|
232
|
+
|
|
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
|
+
};
|
|
248
|
+
|
|
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) };
|
|
68
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
|
+
});
|
|
69
286
|
|
|
70
|
-
|
|
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 = [];
|
|
71
292
|
for (const t of theme.tokens) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
type = 'STRING';
|
|
85
|
-
value = String(t.value).split(',')[0].replace(/['"]/g, '').trim();
|
|
86
|
-
} else continue;
|
|
87
|
-
const modes = c === 'Brand' ? { Value: value ?? valueFor(t, 'Light') } : { Light: valueFor(t, 'Light'), Dark: valueFor(t, 'Dark') };
|
|
88
|
-
collections[c].push({ name: figmaName(t.path), type, modes, scopes: scopesOf(t.path), code: `var(${t.name})`, ...(t.description && { description: t.description }) });
|
|
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
|
+
);
|
|
89
305
|
}
|
|
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: [] },
|
|
314
|
+
),
|
|
315
|
+
);
|
|
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
|
+
}
|
|
335
|
+
|
|
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 {
|
|
364
|
+
const alias =
|
|
365
|
+
ref && target(ref)?.startsWith("Scale:")
|
|
366
|
+
? { alias: target(ref) }
|
|
367
|
+
: null;
|
|
368
|
+
scaleVars.push(
|
|
369
|
+
variable(
|
|
370
|
+
figmaName(t.path),
|
|
371
|
+
"FLOAT",
|
|
372
|
+
{ Default: alias ?? s.Desktop },
|
|
373
|
+
t,
|
|
374
|
+
{ scopes: [] },
|
|
375
|
+
),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
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(
|
|
403
|
+
variable(
|
|
404
|
+
figmaName(t.path),
|
|
405
|
+
"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
|
+
),
|
|
414
|
+
t,
|
|
415
|
+
),
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
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) =>
|
|
423
|
+
variable(
|
|
424
|
+
figmaName(t.path),
|
|
425
|
+
"COLOR",
|
|
426
|
+
Object.fromEntries(
|
|
427
|
+
themeModes.map((m) => [m, value(colorIn(t.path, m))]),
|
|
428
|
+
),
|
|
429
|
+
t,
|
|
430
|
+
/^(scope|pair)\./.test(t.path) ? { scopes: [] } : {},
|
|
431
|
+
),
|
|
432
|
+
);
|
|
433
|
+
|
|
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
|
+
}
|
|
452
|
+
|
|
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."),
|
|
456
|
+
);
|
|
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
|
+
});
|
|
488
|
+
|
|
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}`,
|
|
506
|
+
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
|
+
}));
|
|
521
|
+
|
|
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;
|
|
528
|
+
};
|
|
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`);
|
|
548
|
+
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};`,
|
|
574
|
+
};
|
|
575
|
+
});
|
|
576
|
+
|
|
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
|
+
})),
|
|
639
|
+
{
|
|
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;`,
|
|
650
|
+
},
|
|
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
|
+
];
|
|
660
|
+
|
|
90
661
|
const out = {
|
|
662
|
+
format: FORMAT,
|
|
663
|
+
version: VERSION,
|
|
91
664
|
brand: theme.brand,
|
|
665
|
+
prefix: theme.prefix,
|
|
92
666
|
collections: [
|
|
93
|
-
{
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
},
|
|
96
691
|
],
|
|
692
|
+
textStyles,
|
|
693
|
+
effectStyles,
|
|
694
|
+
flat: flat(theme, byPath),
|
|
97
695
|
};
|
|
98
696
|
return `${JSON.stringify(out)}\n`;
|
|
99
697
|
},
|
|
100
698
|
};
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* One mode per collection, for Figma Starter: Brand (no vision themes), Color Pair, Theme and
|
|
702
|
+
* "Theme dark", with sizes at their desktop value in Brand.
|
|
703
|
+
*/
|
|
704
|
+
function flat(theme, byPath) {
|
|
705
|
+
const inCollection = (t) => {
|
|
706
|
+
if (
|
|
707
|
+
t.layer === "primitive" &&
|
|
708
|
+
!t.path.startsWith("vision.") &&
|
|
709
|
+
!/^color\.(accent|chrome)\./.test(t.path)
|
|
710
|
+
)
|
|
711
|
+
return "Brand";
|
|
712
|
+
if (t.layer === "semantic" && t.type === "color")
|
|
713
|
+
return t.path.startsWith("pair.") ? "Color Pair" : "Theme";
|
|
714
|
+
return null;
|
|
715
|
+
};
|
|
716
|
+
const target = (path) => {
|
|
717
|
+
let t = byPath.get(path);
|
|
718
|
+
while (t && /^color\.(accent|chrome)\./.test(t.path))
|
|
719
|
+
t = byPath.get(refOf(t.references));
|
|
720
|
+
return t && inCollection(t)
|
|
721
|
+
? `${inCollection(t)}:${figmaName(t.path)}`
|
|
722
|
+
: null;
|
|
723
|
+
};
|
|
724
|
+
const valueFor = (t, mode) => {
|
|
725
|
+
const ref =
|
|
726
|
+
mode === "Dark"
|
|
727
|
+
? (refOf(t.modeReferences?.dark) ??
|
|
728
|
+
(t.modes?.dark === undefined ? refOf(t.references) : null))
|
|
729
|
+
: refOf(t.references);
|
|
730
|
+
const alias = ref && target(ref);
|
|
731
|
+
// Theme dark is its own collection, so its aliases into Theme point at Theme dark.
|
|
732
|
+
if (alias)
|
|
733
|
+
return {
|
|
734
|
+
alias:
|
|
735
|
+
mode === "Dark" && alias.startsWith("Theme:")
|
|
736
|
+
? alias.replace("Theme:", "Theme dark:")
|
|
737
|
+
: alias,
|
|
738
|
+
};
|
|
739
|
+
const raw = mode === "Dark" ? valueIn(byPath, t.path, "dark") : t.value;
|
|
740
|
+
return t.type === "color" ? solve(raw) : raw;
|
|
741
|
+
};
|
|
742
|
+
const collections = {
|
|
743
|
+
Brand: [],
|
|
744
|
+
"Color Pair": [],
|
|
745
|
+
Theme: [],
|
|
746
|
+
"Theme dark": [],
|
|
747
|
+
};
|
|
748
|
+
for (const t of theme.tokens) {
|
|
749
|
+
const c = inCollection(t);
|
|
750
|
+
if (!c) continue;
|
|
751
|
+
const top = t.path.split(".")[0];
|
|
752
|
+
let type, value;
|
|
753
|
+
if (t.type === "color" || top === "color") type = "COLOR";
|
|
754
|
+
else if (["space", "radius", "stroke", "font-size"].includes(top)) {
|
|
755
|
+
value = sizes(t.value)?.Desktop;
|
|
756
|
+
if (value === undefined) continue;
|
|
757
|
+
type = "FLOAT";
|
|
758
|
+
} else if (top === "font-family") {
|
|
759
|
+
type = "STRING";
|
|
760
|
+
value = String(t.value).split(",")[0].replace(/['"]/g, "").trim();
|
|
761
|
+
} else continue;
|
|
762
|
+
const base = {
|
|
763
|
+
name: figmaName(t.path),
|
|
764
|
+
type,
|
|
765
|
+
scopes: scopesOf(t.path, type),
|
|
766
|
+
code: `var(${t.name})`,
|
|
767
|
+
...(t.description && { description: t.description }),
|
|
768
|
+
};
|
|
769
|
+
if (c === "Brand")
|
|
770
|
+
collections.Brand.push({
|
|
771
|
+
...base,
|
|
772
|
+
values: { Value: value ?? valueFor(t, "Light") },
|
|
773
|
+
});
|
|
774
|
+
else if (c === "Color Pair")
|
|
775
|
+
collections["Color Pair"].push({
|
|
776
|
+
...base,
|
|
777
|
+
values: { Value: valueFor(t, "Light") },
|
|
778
|
+
});
|
|
779
|
+
else {
|
|
780
|
+
collections.Theme.push({
|
|
781
|
+
...base,
|
|
782
|
+
values: { Light: valueFor(t, "Light") },
|
|
783
|
+
});
|
|
784
|
+
collections["Theme dark"].push({
|
|
785
|
+
...base,
|
|
786
|
+
values: { Dark: valueFor(t, "Dark") },
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
collections: [
|
|
792
|
+
{ name: "Brand", modes: ["Value"], variables: collections.Brand },
|
|
793
|
+
{
|
|
794
|
+
name: "Color Pair",
|
|
795
|
+
modes: ["Value"],
|
|
796
|
+
variables: collections["Color Pair"],
|
|
797
|
+
},
|
|
798
|
+
{ name: "Theme", modes: ["Light"], variables: collections.Theme },
|
|
799
|
+
{
|
|
800
|
+
name: "Theme dark",
|
|
801
|
+
modes: ["Dark"],
|
|
802
|
+
variables: collections["Theme dark"],
|
|
803
|
+
},
|
|
804
|
+
],
|
|
805
|
+
};
|
|
806
|
+
}
|