@scattered-light/loomiere 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +6 -1
  2. package/bin/loomiere.mjs +24 -0
  3. package/build/build-tokens.mjs +93 -49
  4. package/exporters/README.md +1 -1
  5. package/exporters/ag-grid-settings.mjs +7 -7
  6. package/exporters/ag-grid.mjs +6 -6
  7. package/exporters/chakra.mjs +43 -43
  8. package/exporters/component-styles.mjs +4 -4
  9. package/exporters/design-system.mjs +17 -12
  10. package/exporters/figma.mjs +774 -68
  11. package/exporters/mui.mjs +40 -40
  12. package/exporters/shadcn.mjs +5 -13
  13. package/exporters/tailwind.mjs +14 -5
  14. package/figma/README.md +136 -0
  15. package/figma/build-plugin.mjs +25 -0
  16. package/figma/engine.js +730 -0
  17. package/figma/library/README.md +92 -0
  18. package/figma/library/check/known-differences.md +20 -0
  19. package/figma/library/check/mojave.json +246 -0
  20. package/figma/library/kit/base.js +89 -0
  21. package/figma/library/kit/components.js +95 -0
  22. package/figma/library/kit/docs.js +37 -0
  23. package/figma/library/kit/fingerprint.js +143 -0
  24. package/figma/library/pages/02-palettes.js +40 -0
  25. package/figma/library/pages/03-color.js +126 -0
  26. package/figma/library/pages/04-type.js +56 -0
  27. package/figma/library/pages/05-scale.js +58 -0
  28. package/figma/library/pages/06-size.js +111 -0
  29. package/figma/library/pages/07-elevation-focus-motion.js +108 -0
  30. package/figma/library/pages/08-themes.js +36 -0
  31. package/figma/library/pages/09-fields/01-input.js +55 -0
  32. package/figma/library/pages/09-fields/02-checks.js +82 -0
  33. package/figma/library/pages/09-fields/03-field.js +32 -0
  34. package/figma/library/pages/09-fields/04-more-fields.js +144 -0
  35. package/figma/library/pages/09-fields/05-labels.js +14 -0
  36. package/figma/library/pages/09-fields/06-doc.js +75 -0
  37. package/figma/library/pages/10-buttons/01-buttons.js +97 -0
  38. package/figma/library/pages/10-buttons/02-doc.js +55 -0
  39. package/figma/library/pages/11-popups/01-popups.js +166 -0
  40. package/figma/library/pages/11-popups/02-more-popups.js +90 -0
  41. package/figma/library/pages/11-popups/03-labels.js +14 -0
  42. package/figma/library/pages/11-popups/04-doc.js +89 -0
  43. package/figma/library/pages/12-overlays/01-overlays.js +135 -0
  44. package/figma/library/pages/12-overlays/02-labels.js +10 -0
  45. package/figma/library/pages/12-overlays/03-doc.js +80 -0
  46. package/figma/library/pages/13-disclosure/01-disclosure.js +205 -0
  47. package/figma/library/pages/13-disclosure/02-collapsible.js +39 -0
  48. package/figma/library/pages/13-disclosure/03-labels.js +16 -0
  49. package/figma/library/pages/13-disclosure/04-doc.js +91 -0
  50. package/figma/library/pages/14-display/01-display.js +165 -0
  51. package/figma/library/pages/14-display/02-more-display.js +55 -0
  52. package/figma/library/pages/14-display/03-labels.js +14 -0
  53. package/figma/library/pages/14-display/04-doc.js +96 -0
  54. package/figma/library/pages/15-cover.js +87 -0
  55. package/figma/library/run-order.json +21 -0
  56. package/figma/library/tools/compare.mjs +11 -0
  57. package/figma/library/tools/diff-snippet.js +15 -0
  58. package/figma/library/tools/kit-script.mjs +21 -0
  59. package/figma/library/tools/run.mjs +27 -0
  60. package/figma/library/tools/runs.mjs +34 -0
  61. package/figma/plugin/code.js +746 -0
  62. package/figma/plugin/manifest.json +14 -0
  63. package/figma/plugin/ui.html +237 -0
  64. package/figma/plugin-main.js +14 -0
  65. package/generator/brand.mjs +16 -14
  66. package/generator/brand.schema.json +60 -0
  67. package/generator/generate.mjs +2 -2
  68. package/generator/pairs.mjs +6 -1
  69. package/generator/roles.mjs +55 -15
  70. package/generator/vision.mjs +7 -2
  71. package/lib/config.mjs +2 -0
  72. package/lib/contrast.mjs +8 -3
  73. package/lib/figma.mjs +338 -0
  74. package/lib/studio.mjs +4 -0
  75. package/lib/tool-server.mjs +7 -4
  76. package/package.json +5 -1
  77. package/tokens/README.md +10 -5
  78. package/tokens/component/avatar.json +41 -6
  79. package/tokens/semantic/color.json +1 -1
  80. package/tokens/semantic/elevation.json +8 -43
  81. package/tokens/semantic/typography.json +61 -94
  82. package/tools/brand-builder/builder.js +33 -4
@@ -1,100 +1,806 @@
1
1
  /**
2
- * Figma variables, one collection per builder step so each step can sync on its own:
3
- * Brand palettes, data colors, space, radius, stroke, type (step 1)
4
- * Pairs each pair's colors, light and dark (step 2)
5
- * Theme surfaces, text, borders, scopes and emphasis levels (step 3)
6
- * Pairs and Theme alias Brand, and Theme aliases itself the way the tokens do
7
- * (emphasis → scope → palette step), so changing a role in Figma moves everything that uses it.
8
- * Values that are math in CSS (color-mix) are resolved to colors. Every variable carries its
9
- * CSS variable as WEB code syntax. The file is data for a sync script that writes the
10
- * variables with the Figma plugin API; it isn't a Figma import format.
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 'culori';
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(/^color-mix\(in (\w+),\s*(.+?)\s+([\d.]+)%,\s*(.+)\)$/);
22
- if (mix) return hex(interpolate([solve(mix[4]), solve(mix[2])], mix[1] === 'srgb' ? 'rgb' : mix[1])(Number(mix[3]) / 100));
23
- if (value === 'transparent') return '#00000000';
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 COLLECTION_OF = (t) => {
29
- if (t.layer === 'primitive' && !t.path.startsWith('vision.') && !/^color\.(accent|chrome)\./.test(t.path)) return 'Brand';
30
- if (t.layer === 'semantic' && t.type === 'color') return t.path.startsWith('pair.') ? 'Pairs' : 'Theme';
31
- return null;
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
- const SCOPES = {
35
- surface: ['FRAME_FILL', 'SHAPE_FILL'],
36
- text: ['TEXT_FILL'],
37
- border: ['STROKE_COLOR'],
38
- color: ['ALL_FILLS', 'STROKE_COLOR'],
39
- space: ['GAP', 'WIDTH_HEIGHT'],
40
- radius: ['CORNER_RADIUS'],
41
- stroke: ['STROKE_FLOAT'],
42
- 'font-size': ['FONT_SIZE'],
43
- 'font-family': ['FONT_FAMILY'],
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: 'figma',
49
- name: 'Figma variables',
50
- file: 'figma.json',
51
- for: 'Syncing to Figma variables: Brand, Pairs and Theme collections with light and dark modes. Ask Claude to sync a step.',
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
- const figmaName = (path) => path.replace(/\./g, '/');
55
- const refOf = (r) => r?.match(/^\{([^}]+)\}$/)?.[1];
56
- // Follow accent/chrome aliases (they aren't variables in Figma) to the palette step.
57
- const target = (path) => {
58
- let t = byPath.get(path);
59
- while (t && /^color\.(accent|chrome)\./.test(t.path)) t = byPath.get(refOf(t.references));
60
- return t && COLLECTION_OF(t) ? `${COLLECTION_OF(t)}:${figmaName(t.path)}` : null;
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
- const valueFor = (t, mode) => {
63
- const ref = mode === 'Dark' ? refOf(t.modeReferences?.dark) ?? (t.modes?.dark === undefined ? refOf(t.references) : null) : refOf(t.references);
64
- const alias = ref && target(ref);
65
- if (alias) return { alias };
66
- const raw = mode === 'Dark' ? valueIn(byPath, t.path, 'dark') : t.value;
67
- return t.type === 'color' ? solve(raw) : raw;
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
- const collections = { Brand: [], Pairs: [], Theme: [] };
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
- const c = COLLECTION_OF(t);
73
- if (!c) continue;
74
- const top = t.path.split('.')[0];
75
- let type, value;
76
- if (t.type === 'color' || top === 'color') type = 'COLOR';
77
- else if (['space', 'radius', 'stroke', 'font-size'].includes(top)) {
78
- // Fluid sizes go in at their large-screen size; Figma variables hold one number.
79
- const m = String(t.value).match(/,\s*([\d.]+rem)\)$/);
80
- value = px(m ? m[1] : t.value);
81
- if (value === undefined) continue;
82
- type = 'FLOAT';
83
- } else if (top === 'font-family') {
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
- { name: 'Brand', step: 'brand', modes: ['Value'], variables: collections.Brand },
94
- { name: 'Pairs', step: 'pairs', modes: ['Light', 'Dark'], variables: collections.Pairs },
95
- { name: 'Theme', step: 'roles', modes: ['Light', 'Dark'], variables: collections.Theme },
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
+ }