@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
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "Loomiere",
3
+ "id": "loomiere-theme-sync",
4
+ "api": "1.0.0",
5
+ "main": "code.js",
6
+ "ui": "ui.html",
7
+ "editorType": ["figma"],
8
+ "documentAccess": "dynamic-page",
9
+ "networkAccess": {
10
+ "allowedDomains": ["https://unpkg.com", "https://cdn.jsdelivr.net"],
11
+ "devAllowedDomains": ["http://localhost:4322"],
12
+ "reasoning": "Pull from project reads the theme from loomiere studio or loomiere figma serve on this computer; Pull from URL reads a published theme package from unpkg or jsDelivr."
13
+ }
14
+ }
@@ -0,0 +1,237 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <style>
6
+ :root {
7
+ color-scheme: light dark;
8
+ }
9
+ * {
10
+ box-sizing: border-box;
11
+ }
12
+ body {
13
+ margin: 0;
14
+ padding: 16px;
15
+ font: 12px/1.45 Inter, system-ui, sans-serif;
16
+ color: var(--figma-color-text, #222);
17
+ background: var(--figma-color-bg, #fff);
18
+ }
19
+ h1 {
20
+ margin: 0 0 4px;
21
+ font-size: 14px;
22
+ font-weight: 600;
23
+ }
24
+ h2 {
25
+ margin: 16px 0 6px;
26
+ font-size: 11px;
27
+ font-weight: 600;
28
+ text-transform: uppercase;
29
+ letter-spacing: 0.04em;
30
+ color: var(--figma-color-text-secondary, #666);
31
+ }
32
+ p {
33
+ margin: 0 0 8px;
34
+ color: var(--figma-color-text-secondary, #666);
35
+ }
36
+ .row {
37
+ display: flex;
38
+ gap: 6px;
39
+ }
40
+ input[type='text'] {
41
+ flex: 1;
42
+ min-width: 0;
43
+ padding: 6px 8px;
44
+ font: inherit;
45
+ color: inherit;
46
+ background: var(--figma-color-bg-secondary, #f5f5f5);
47
+ border: 1px solid var(--figma-color-border, #ddd);
48
+ border-radius: 4px;
49
+ }
50
+ button {
51
+ padding: 6px 12px;
52
+ font: inherit;
53
+ font-weight: 500;
54
+ color: var(--figma-color-text, #222);
55
+ background: var(--figma-color-bg-secondary, #f0f0f0);
56
+ border: 1px solid var(--figma-color-border, #ddd);
57
+ border-radius: 4px;
58
+ cursor: pointer;
59
+ }
60
+ button.primary {
61
+ color: var(--figma-color-text-onbrand, #fff);
62
+ background: var(--figma-color-bg-brand, #0d99ff);
63
+ border-color: transparent;
64
+ }
65
+ button:disabled {
66
+ opacity: 0.5;
67
+ cursor: default;
68
+ }
69
+ .drop {
70
+ padding: 14px;
71
+ text-align: center;
72
+ border: 1px dashed var(--figma-color-border-strong, #bbb);
73
+ border-radius: 6px;
74
+ cursor: pointer;
75
+ }
76
+ .drop.over {
77
+ background: var(--figma-color-bg-selected, #e5f4ff);
78
+ }
79
+ #status {
80
+ margin-top: 12px;
81
+ padding: 10px;
82
+ border-radius: 6px;
83
+ background: var(--figma-color-bg-secondary, #f5f5f5);
84
+ }
85
+ #status:empty {
86
+ display: none;
87
+ }
88
+ #status.error {
89
+ color: var(--figma-color-text-danger, #c00);
90
+ }
91
+ #status ul {
92
+ margin: 4px 0 0;
93
+ padding-left: 16px;
94
+ }
95
+ details {
96
+ margin-top: 6px;
97
+ }
98
+ details ul {
99
+ max-height: 120px;
100
+ overflow: auto;
101
+ font-family: ui-monospace, monospace;
102
+ font-size: 11px;
103
+ }
104
+ label.check {
105
+ display: flex;
106
+ gap: 6px;
107
+ align-items: center;
108
+ margin: 10px 0;
109
+ }
110
+ .actions {
111
+ display: flex;
112
+ gap: 6px;
113
+ justify-content: flex-end;
114
+ margin-top: 10px;
115
+ }
116
+ </style>
117
+ </head>
118
+ <body>
119
+ <h1>Loomiere</h1>
120
+ <p>Brings a Loomiere theme into this file as variables and text styles. Variables and styles are matched by name, so updating keeps everything bound to them.</p>
121
+
122
+ <h2>Theme file</h2>
123
+ <div class="drop" id="drop" tabindex="0" role="button">Drop figma.json here, or click to choose it</div>
124
+ <input type="file" id="file" accept=".json,application/json" hidden />
125
+ <p style="margin-top: 6px">Every <code>loomiere build</code> writes it to the theme's <code>exports/figma.json</code>.</p>
126
+
127
+ <h2>Pull from project</h2>
128
+ <div class="row">
129
+ <input type="text" id="project" value="http://localhost:4322/figma.json" aria-label="Project address" />
130
+ <button id="pull-project">Pull</button>
131
+ </div>
132
+ <p style="margin-top: 6px">Needs <code>loomiere studio</code> or <code>loomiere figma serve</code> running.</p>
133
+
134
+ <h2>Pull from URL</h2>
135
+ <div class="row">
136
+ <input type="text" id="url" placeholder="https://unpkg.com/@scope/theme/dist/exports/figma.json" aria-label="Theme URL" />
137
+ <button id="pull-url">Pull</button>
138
+ </div>
139
+ <p style="margin-top: 6px">A published theme package, from unpkg or jsDelivr.</p>
140
+
141
+ <div id="status" role="status" aria-live="polite"></div>
142
+ <div id="apply" hidden>
143
+ <label class="check"><input type="checkbox" id="remove" /> Also delete variables and styles the theme no longer has</label>
144
+ <div class="actions">
145
+ <button id="cancel">Cancel</button>
146
+ <button id="go" class="primary">Apply</button>
147
+ </div>
148
+ </div>
149
+
150
+ <script>
151
+ const $ = (id) => document.getElementById(id);
152
+ let data = null;
153
+
154
+ const post = (options) => parent.postMessage({ pluginMessage: { type: 'sync', data, options } }, '*');
155
+ const show = (html, error) => {
156
+ $('status').innerHTML = html;
157
+ $('status').className = error ? 'error' : '';
158
+ };
159
+ const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]);
160
+ const list = (title, items) =>
161
+ items.length ? `<details><summary>${esc(title)} (${items.length})</summary><ul>${items.map((i) => `<li>${esc(i)}</li>`).join('')}</ul></details>` : '';
162
+
163
+ function load(json, from) {
164
+ try {
165
+ data = typeof json === 'string' ? JSON.parse(json) : json;
166
+ if (data.format !== 'loomiere-figma') throw new Error('That file is not a Loomiere figma.json.');
167
+ } catch (err) {
168
+ data = null;
169
+ return show(esc(err.message), true);
170
+ }
171
+ show(`Checking ${esc(data.brand)} from ${esc(from)} against this file…`);
172
+ post({ dryRun: true });
173
+ }
174
+
175
+ async function pull(url) {
176
+ show(`Pulling ${esc(url)}…`);
177
+ try {
178
+ const res = await fetch(url, { cache: 'no-store' });
179
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
180
+ load(await res.text(), url);
181
+ } catch (err) {
182
+ show(`Couldn't read ${esc(url)}: ${esc(err.message)}. Is the project's studio or loomiere figma serve running?`, true);
183
+ }
184
+ }
185
+
186
+ onmessage = (event) => {
187
+ const msg = event.data.pluginMessage;
188
+ if (!msg) return;
189
+ if (msg.type === 'error') {
190
+ $('apply').hidden = true;
191
+ return show(esc(msg.message), true);
192
+ }
193
+ const r = msg.report;
194
+ const changes = r.created.length + r.updated.length + r.modesAdded.length + r.styles.created.length + r.styles.updated.length;
195
+ const head = r.dryRun
196
+ ? changes || r.removed.length
197
+ ? `<strong>${esc(r.brand)}</strong>: ${r.created.length} to create, ${r.updated.length} to update, ${r.unchanged} already match. Text styles: ${r.styles.created.length} to create, ${r.styles.updated.length} to update.`
198
+ : `<strong>${esc(r.brand)}</strong> already matches this file.`
199
+ : `<strong>Done.</strong> ${r.created.length} created, ${r.updated.length} updated, ${r.unchanged} unchanged. Text styles: ${r.styles.created.length} created, ${r.styles.updated.length} updated.${r.removed.length && $('remove').checked ? ` Removed ${r.removed.length}.` : ''}`;
200
+ const layout = r.layout === 'flat' ? '<p>This plan allows one mode per collection, so the theme goes in as Brand, Color Pair, Theme and Theme dark.</p>' : '';
201
+ show(
202
+ head +
203
+ layout +
204
+ (r.warnings.length ? `<ul>${r.warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul>` : '') +
205
+ list('New', r.created.concat(r.styles.created.map((s) => `text style ${s}`))) +
206
+ list('Changed', r.updated.concat(r.styles.updated.map((s) => `text style ${s}`))) +
207
+ list('Modes added', r.modesAdded) +
208
+ list('No longer in the theme', r.removed),
209
+ );
210
+ $('apply').hidden = !r.dryRun || !(changes || r.removed.length);
211
+ };
212
+
213
+ $('drop').onclick = () => $('file').click();
214
+ $('drop').onkeydown = (e) => (e.key === 'Enter' || e.key === ' ') && $('file').click();
215
+ $('file').onchange = async () => $('file').files[0] && load(await $('file').files[0].text(), $('file').files[0].name);
216
+ $('drop').ondragover = (e) => (e.preventDefault(), $('drop').classList.add('over'));
217
+ $('drop').ondragleave = () => $('drop').classList.remove('over');
218
+ $('drop').ondrop = async (e) => {
219
+ e.preventDefault();
220
+ $('drop').classList.remove('over');
221
+ const f = e.dataTransfer.files[0];
222
+ if (f) load(await f.text(), f.name);
223
+ };
224
+ $('pull-project').onclick = () => pull($('project').value.trim());
225
+ $('pull-url').onclick = () => $('url').value.trim() && pull($('url').value.trim());
226
+ $('cancel').onclick = () => {
227
+ $('apply').hidden = true;
228
+ show('');
229
+ };
230
+ $('go').onclick = () => {
231
+ $('apply').hidden = true;
232
+ show('Applying…');
233
+ post({ remove: $('remove').checked });
234
+ };
235
+ </script>
236
+ </body>
237
+ </html>
@@ -0,0 +1,14 @@
1
+ /* The Loomiere plugin: the sync engine above, run on a figma.json the UI hands it. */
2
+ /* global figma, __html__, loomiereSync */
3
+
4
+ figma.showUI(__html__, { width: 420, height: 600, themeColors: true });
5
+
6
+ figma.ui.onmessage = async function (msg) {
7
+ if (msg.type !== 'sync') return;
8
+ try {
9
+ var report = await loomiereSync(msg.data, msg.options);
10
+ figma.ui.postMessage({ type: 'report', report: report });
11
+ } catch (err) {
12
+ figma.ui.postMessage({ type: 'error', message: String((err && err.message) || err) });
13
+ }
14
+ };
@@ -81,9 +81,14 @@ function generateColor({ paper, ink, contrast, palettes, data }) {
81
81
  }
82
82
  color[role].on = on;
83
83
  }
84
+ // Data colors by name, then the series slots charts use, in order, each pointing at a name.
84
85
  color.data = {};
85
- data.forEach(([name, hex], i) => {
86
- color.data[i + 1] = token('color', hex, `Data series ${i + 1} (${name}). Categorical; use in order.`);
86
+ for (const [name, hex] of data) {
87
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error(`Data color "${name}": names use a-z, 0-9 and dashes, starting with a letter`);
88
+ color.data[name] = token('color', hex, `Data color ${name}. Charts use it through the numbered series slots.`);
89
+ }
90
+ data.forEach(([name], i) => {
91
+ color.data[i + 1] = token('color', `{color.data.${name}}`, `Data series ${i + 1}: ${name}. Categorical; use in order.`);
87
92
  });
88
93
  return { color };
89
94
  }
@@ -185,21 +190,18 @@ function generateShadow({ shadowColor, strength }) {
185
190
  shadow[name] = token('shadow', value, `Elevation ${name}`);
186
191
  }
187
192
  shadow.none = token('shadow', 'none', 'No elevation');
188
- // The same heights as a flat 1px outline, in the shadow color, for when blur should go.
189
- shadow.flat = {};
190
- Object.keys(SHADOW_RECIPES)
191
- .filter((name) => name !== 'inner')
192
- .forEach((name, i) => {
193
- const a = round(Math.min(1, 0.05 * (i + 1) * strength), 3);
194
- shadow.flat[name] = token('shadow', `0 0 0 1px rgba(${rgb}, ${a})`, `Elevation ${name} as a 1px outline`);
195
- });
196
193
  return { shadow };
197
194
  }
198
195
 
199
196
  function generateMotion({ speed, durations }) {
200
197
  const duration = {};
201
198
  for (const [name, ms] of Object.entries(durations)) {
202
- duration[name] = token('duration', `${Math.round(ms * speed)}ms`, `${ms}ms × speed ${speed}`);
199
+ // With reduced motion, things change at once instead of moving: 1ms rather than 0, so
200
+ // transitionend and animation events still fire for code that waits on them.
201
+ duration[name] = {
202
+ ...token('duration', `${Math.round(ms * speed)}ms`, `${ms}ms × speed ${speed}. 1ms with reduced motion.`),
203
+ $extensions: { 'sl.modes': { 'reduced-motion': '1ms' } },
204
+ };
203
205
  }
204
206
  return { duration };
205
207
  }
@@ -274,19 +276,19 @@ export function applyOverrides(files, overrides) {
274
276
  * vision.<theme>.color.<role>.<step> and .data: the spot palettes (brand colors and alerts)
275
277
  * and data colors redesigned for each kind of color blindness, built with the same contrast tables so every step keeps
276
278
  * its contrast. Semantic status colors point at the alert palettes, so they follow.
277
- * The build re-declares the brand's variables with these under [data-vision="<theme>"].
279
+ * The build re-declares the brand's variables with these under [data-theme="<theme>"], a light theme of its own.
278
280
  */
279
281
  function generateVision(color) {
280
282
  const vision = {};
281
283
  for (const [theme, { palettes, data }] of Object.entries(visionColors(color))) {
282
- const roles = new Set(Object.keys(palettes));
284
+ const roles = new Set([...Object.keys(palettes), 'data']);
283
285
  const { color: built } = generateColor({ ...color, palettes, data });
284
286
  // On-colors point at the brand palette; point them at this theme's palette instead.
285
287
  const retarget = (v) => (typeof v === 'object' && v !== null
286
288
  ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, retarget(x)]))
287
289
  : typeof v === 'string' ? v.replace(/\{color\.([\w-]+)\./g, (m, role) => (roles.has(role) ? `{vision.${theme}.color.${role}.` : m)) : v);
288
290
  const themed = {};
289
- for (const role of [...roles, 'data']) themed[role] = retarget(built[role]);
291
+ for (const role of roles) themed[role] = retarget(built[role]);
290
292
  vision[theme] = { color: themed };
291
293
  }
292
294
  return { vision };
@@ -248,6 +248,14 @@
248
248
  "type": "string",
249
249
  "pattern": "^#[0-9a-fA-F]{6}$"
250
250
  }
251
+ },
252
+ "dataOrder": {
253
+ "type": "array",
254
+ "description": "The data series in this theme's order, by name. Names left out follow in the brand's order.",
255
+ "items": {
256
+ "type": "string"
257
+ },
258
+ "uniqueItems": true
251
259
  }
252
260
  }
253
261
  },
@@ -269,6 +277,14 @@
269
277
  "type": "string",
270
278
  "pattern": "^#[0-9a-fA-F]{6}$"
271
279
  }
280
+ },
281
+ "dataOrder": {
282
+ "type": "array",
283
+ "description": "The data series in this theme's order, by name. Names left out follow in the brand's order.",
284
+ "items": {
285
+ "type": "string"
286
+ },
287
+ "uniqueItems": true
272
288
  }
273
289
  }
274
290
  },
@@ -290,6 +306,14 @@
290
306
  "type": "string",
291
307
  "pattern": "^#[0-9a-fA-F]{6}$"
292
308
  }
309
+ },
310
+ "dataOrder": {
311
+ "type": "array",
312
+ "description": "The data series in this theme's order, by name. Names left out follow in the brand's order.",
313
+ "items": {
314
+ "type": "string"
315
+ },
316
+ "uniqueItems": true
293
317
  }
294
318
  }
295
319
  },
@@ -311,6 +335,14 @@
311
335
  "type": "string",
312
336
  "pattern": "^#[0-9a-fA-F]{6}$"
313
337
  }
338
+ },
339
+ "dataOrder": {
340
+ "type": "array",
341
+ "description": "The data series in this theme's order, by name. Names left out follow in the brand's order.",
342
+ "items": {
343
+ "type": "string"
344
+ },
345
+ "uniqueItems": true
314
346
  }
315
347
  }
316
348
  }
@@ -615,6 +647,34 @@
615
647
  }
616
648
  }
617
649
  }
650
+ },
651
+ "status": {
652
+ "type": "object",
653
+ "description": "Per status (default, info, success, warning, error), brand pairs that stand in for emphasis levels. A level left out is made automatically: its style in the level's own color for default, and in the status's palette for the others.",
654
+ "propertyNames": {
655
+ "enum": [
656
+ "default",
657
+ "info",
658
+ "success",
659
+ "warning",
660
+ "error"
661
+ ]
662
+ },
663
+ "additionalProperties": {
664
+ "type": "object",
665
+ "propertyNames": {
666
+ "enum": [
667
+ "bold",
668
+ "standard",
669
+ "subtle",
670
+ "quiet"
671
+ ]
672
+ },
673
+ "additionalProperties": {
674
+ "type": "string",
675
+ "description": "A pair name from color.pairs."
676
+ }
677
+ }
618
678
  }
619
679
  }
620
680
  }
@@ -4,7 +4,7 @@
4
4
  * Reads a brand file (the inputs) and the optional <brand>.overrides.json beside it (exact
5
5
  * values pinned on top of the math), and writes DTCG token files to
6
6
  * <generatedDir>/tokens/primitive/ plus the brand-specific semantic files (pairs, role scopes,
7
- * emphasis) to <generatedDir>/tokens/semantic/. Those files are generated: edit the brand
7
+ * statuses, emphasis) to <generatedDir>/tokens/semantic/. Those files are generated: edit the brand
8
8
  * file, not them.
9
9
  */
10
10
  import fs from 'node:fs';
@@ -36,7 +36,7 @@ export function generate(brandFile, generatedDir) {
36
36
  }
37
37
  // Pairs live with the semantic tokens: they are named uses of the palette, with a dark mode.
38
38
  fs.writeFileSync(path.join(semanticDir, 'pair.json'), `${JSON.stringify({ $description: PAIR_FILE_NOTE, ...pairs }, null, 2)}\n`);
39
- for (const name of ['scope', 'emphasis']) {
39
+ for (const name of ['scope', 'status', 'emphasis']) {
40
40
  fs.writeFileSync(path.join(semanticDir, `${name}.json`), `${JSON.stringify({ $description: ROLE_FILE_NOTE, ...roleTokens[name] }, null, 2)}\n`);
41
41
  }
42
42
  return { brand, applied, files };
@@ -78,7 +78,7 @@ export function onColor(role, step, palette, paper, ink) {
78
78
  * and the build stops when one falls short of AA.
79
79
  */
80
80
 
81
- /** Parts every pair has: the three a brand names, then four derived from them. */
81
+ /** Parts every pair has: the three a brand names, then the ones derived from them. */
82
82
  export const PAIR_PARTS = {
83
83
  bg: 'Background',
84
84
  fg: 'Text, icons and strong borders',
@@ -88,6 +88,8 @@ export const PAIR_PARTS = {
88
88
  subtle: 'Accent wash for highlighted items, selection and mark',
89
89
  emphasis: 'Stronger accent wash for selected and pressed items',
90
90
  muted: 'Inset background for code',
91
+ 'bg-hover': 'Background on hover, when the pair paints a control',
92
+ 'bg-active': 'Background while pressed, when the pair paints a control',
91
93
  };
92
94
 
93
95
  /** The "why" for one part of a resolved pair. */
@@ -232,5 +234,8 @@ export function resolvePair(spec, hexOf) {
232
234
  derive('subtle', ['accent', 12, 'bg'], textOn, 'bg');
233
235
  derive('emphasis', ['accent', 22, 'bg'], textOn, 'subtle');
234
236
  derive('muted', ['fg', 6, 'bg'], (hex) => wcagContrast(parts.fg, hex), 'bg');
237
+ // A pair that stands in for an emphasis level paints the control itself: fg is its label.
238
+ derive('bg-hover', ['fg', 8, 'bg'], (hex) => wcagContrast(parts.fg, hex), 'bg');
239
+ derive('bg-active', ['fg', 15, 'bg'], (hex) => wcagContrast(parts.fg, hex), 'bg-hover');
235
240
  return { parts, sources, problems, ratios: { fg: fgRatio, accent: accentRatio } };
236
241
  }
@@ -8,11 +8,17 @@
8
8
  * roles.concepts optional picks that replace a role's default step for one concept:
9
9
  * { "chrome.fg": { light: "primary.1000" } }. A pick is "palette.step",
10
10
  * "white" (paper), "black" (ink) or a hex. Modes left out keep the default.
11
+ * roles.status optional brand pairs that stand in for an emphasis level, per status:
12
+ * { default: { bold: "mirage-dune" }, warning: { subtle: "dune-soft" } }.
13
+ * A level left out is made automatically: its style, in the level's own
14
+ * color for default and in the status's palette for info, success, warning
15
+ * and error.
11
16
  *
12
17
  * Every palette also gets a scope: the same slots (solid, soft, border, text…) at fixed
13
18
  * steps, so any palette can stand in for another. That works because spot palettes share a
14
19
  * contrast table, so step 600 has the same contrast in every hue. A page re-colors a part
15
- * with data-color="secondary"; data-emphasis="subtle" applies a level.
20
+ * with data-color="secondary"; data-emphasis="subtle" applies a level; data-status="warning"
21
+ * picks the warning version of every level.
16
22
  */
17
23
 
18
24
  /**
@@ -58,13 +64,17 @@ export const SLOTS = {
58
64
  divider: [300, 800, { 'contrast-more': 700, 'dark-contrast-more': 400 }],
59
65
  fg: [1200, 'white'],
60
66
  'fg-secondary': [1000, 300, { 'contrast-more': 1100, 'dark-contrast-more': 100 }],
61
- 'fg-tertiary': [900, 400, { 'contrast-more': 1000 }],
67
+ // Tertiary must pass 4.5:1 on every dark surface; overlay and tray stop at 1100 so it can sit at 600.
68
+ 'fg-tertiary': [900, 600, { 'contrast-more': 1000 }],
69
+ // Disabled text: deliberately faint, about 1.7:1 on paper and 3:1 on the dark page. Its own slot so a
70
+ // fill step never decides it (dark fills run light, which made disabled text 7.8:1).
71
+ 'fg-disabled': [500, 900],
62
72
  focus: [800, 400],
63
73
  canvas: ['white', 1200],
64
74
  raised: [50, 1100],
65
- overlay: [100, 1000],
75
+ overlay: [100, 1100],
66
76
  modal: ['white', 1100],
67
- tray: [50, 1000],
77
+ tray: [50, 1100],
68
78
  inverse: [1100, 900],
69
79
  'on-inverse': ['on.1100', 'on.900'],
70
80
  // The empty part of a meter or progress bar. Dark mode goes near the page so any palette's fill reads 3:1 on it.
@@ -91,7 +101,7 @@ export const CONCEPTS = [
91
101
  { role: 'chrome', group: 'Text', slot: 'fg', label: 'Body text and prose', against: 'canvas', min: 4.5 },
92
102
  { role: 'chrome', group: 'Text', slot: 'fg-secondary', label: 'Secondary text', against: 'canvas', min: 4.5 },
93
103
  { role: 'chrome', group: 'Text', slot: 'fg-tertiary', label: 'Tertiary text', against: 'canvas', min: 4.5 },
94
- { role: 'chrome', group: 'Text', slot: 'soft-strong', label: 'Disabled text', against: 'canvas' },
104
+ { role: 'chrome', group: 'Text', slot: 'fg-disabled', label: 'Disabled text', against: 'canvas' },
95
105
  { role: 'chrome', group: 'Lines', slot: 'border', label: 'Control borders', against: 'canvas', min: 3 },
96
106
  { role: 'chrome', group: 'Lines', slot: 'border-hover', label: 'Control border on hover', against: 'canvas', min: 3 },
97
107
  { role: 'chrome', group: 'Lines', slot: 'border-strong', label: 'Strong borders', against: 'canvas', min: 3 },
@@ -126,6 +136,10 @@ export const STYLES = {
126
136
  };
127
137
  export const PARTS = Object.keys(STYLES.solid);
128
138
  export const LEVELS = ['bold', 'standard', 'subtle', 'quiet'];
139
+ /** Statuses: default, then one per alert palette the brand has. */
140
+ export const STATUSES = ['default', 'info', 'success', 'warning', 'error'];
141
+ /** How an emphasis part reads a brand pair that stands in for a level. */
142
+ export const PAIR_PARTS_FOR = { bg: 'bg', 'bg-hover': 'bg-hover', 'bg-active': 'bg-active', fg: 'fg', border: 'bg', 'border-hover': 'bg-hover' };
129
143
 
130
144
  export const DEFAULT_ROLES = {
131
145
  chrome: 'neutral',
@@ -194,6 +208,14 @@ export function resolveRoles(brand) {
194
208
  if (!STYLES[style]) problems.push(`roles.emphasis.${level}.style "${style}" is not one of ${Object.keys(STYLES).join(', ')}`);
195
209
  if (!palettes[paletteFor(roles, color)]) problems.push(`roles.emphasis.${level}.color "${color}" is not accent, chrome or a palette`);
196
210
  }
211
+ for (const [status, picks] of Object.entries(roles.status ?? {})) {
212
+ if (!STATUSES.includes(status)) problems.push(`roles.status "${status}" is not one of ${STATUSES.join(', ')}`);
213
+ else if (status !== 'default' && !palettes[status]) problems.push(`roles.status "${status}": the brand has no ${status} palette`);
214
+ for (const [level, pair] of Object.entries(picks ?? {})) {
215
+ if (!LEVELS.includes(level)) problems.push(`roles.status.${status}.${level}: levels are ${LEVELS.join(', ')}`);
216
+ else if (!brand.color.pairs?.[pair]) problems.push(`roles.status.${status}.${level} "${pair}" is not a pair in color.pairs`);
217
+ }
218
+ }
197
219
  for (const [key, pick] of Object.entries(roles.concepts ?? {})) {
198
220
  const [role, slot] = key.split('.');
199
221
  const palette = palettes[roles[role]];
@@ -217,10 +239,15 @@ const token = (type, value, description, modes) => ({
217
239
  ...(modes && { $extensions: { 'sl.modes': modes } }),
218
240
  });
219
241
 
242
+ /** The statuses a brand has: default, plus each alert palette it names. */
243
+ export const statusesOf = (brand) => STATUSES.filter((s) => s === 'default' || brand.color.palettes[s]);
244
+
220
245
  /**
221
- * tokens/semantic/scope.json and emphasis.json:
222
- * scope.<palette>.<slot> every palette's slots, plus scope.accent and scope.chrome
223
- * emphasis.<level>.<part> each level's colors at its default palette
246
+ * tokens/semantic/scope.json, status.json and emphasis.json:
247
+ * scope.<palette>.<slot> every palette's slots, plus scope.accent and scope.chrome
248
+ * status-level.<status>.<level>.<part> each level's colors in each status: a brand pair
249
+ * the brand file picked, or the level's style in a palette
250
+ * emphasis.<level>.<part> each level's colors in the default status
224
251
  */
225
252
  export function generateRoleTokens(brand) {
226
253
  const roles = resolveRoles(brand);
@@ -244,15 +271,28 @@ export function generateRoleTokens(brand) {
244
271
  );
245
272
  }
246
273
 
274
+ const status = {};
275
+ for (const name of statusesOf(brand)) {
276
+ status[name] = {};
277
+ for (const level of LEVELS) {
278
+ const { style, color: own } = roles.emphasis[level];
279
+ const pair = roles.status?.[name]?.[level];
280
+ const color = name === 'default' ? own : name;
281
+ const palette = paletteFor(roles, color);
282
+ status[name][level] = {};
283
+ for (const [part, slot] of Object.entries(STYLES[style])) {
284
+ status[name][level][part] = pair
285
+ ? token('color', `{pair.${pair}.${PAIR_PARTS_FOR[part]}}`, `${level} in ${name}: the ${pair} pair (roles.status).`)
286
+ : token('color', slot === 'transparent' ? 'transparent' : `{scope.${color}.${slot}}`, `${level} in ${name}: ${style} in ${color === palette ? palette : `${color} (${palette})`}`);
287
+ }
288
+ }
289
+ }
290
+
291
+ // The default status's levels under their own names, for exporters that know only one status.
247
292
  const emphasis = {};
248
293
  for (const level of LEVELS) {
249
- const { style, color } = roles.emphasis[level];
250
- const palette = paletteFor(roles, color);
251
294
  emphasis[level] = {};
252
- for (const [part, slot] of Object.entries(STYLES[style])) {
253
- const value = slot === 'transparent' ? 'transparent' : `{scope.${color}.${slot}}`;
254
- emphasis[level][part] = token('color', value, `${level}: ${style} in ${color === palette ? palette : `${color} (${palette})`}`);
255
- }
295
+ for (const [part, t] of Object.entries(status.default[level])) emphasis[level][part] = token('color', t.$value, t.$description);
256
296
  }
257
- return { scope: { scope }, emphasis: { emphasis }, roles };
297
+ return { scope: { scope }, status: { 'status-level': status }, emphasis: { emphasis }, roles };
258
298
  }
@@ -12,7 +12,8 @@
12
12
  * after it as seen with that vision, and never lose contrast on paper.
13
13
  *
14
14
  * A brand can pin any generated color: color.vision.deuteranopia.keys.primary = "#…" or
15
- * color.vision.deuteranopia.data.coral = "#…"; the others move to make room.
15
+ * color.vision.deuteranopia.data.coral = "#…"; the others move to make room. A theme can also
16
+ * put the data series in its own order: color.vision.tritanopia.dataOrder = ["coral", "navy"].
16
17
  */
17
18
  import { differenceCiede2000, formatHex, oklch, wcagContrast } from 'culori';
18
19
 
@@ -144,7 +145,11 @@ export function visionColors(color) {
144
145
  }
145
146
 
146
147
  const dataPins = color.vision?.[theme]?.data ?? {};
147
- const data = color.data.map(([name, hex]) => [name, dataPins[name] ?? hex]);
148
+ // A theme can put the series in its own order (dataOrder); names it leaves out follow in the
149
+ // brand's order, and names that are no longer data colors are skipped.
150
+ const order = (color.vision?.[theme]?.dataOrder ?? []).filter((name) => color.data.some(([n]) => n === name));
151
+ const ordered = [...order.map((name) => color.data.find(([n]) => n === name)), ...color.data.filter(([n]) => !order.includes(n))];
152
+ const data = ordered.map(([name, hex]) => [name, dataPins[name] ?? hex]);
148
153
  const rules = { visions: [sees], distinct: THEME_DISTINCT, reach: 2 };
149
154
  for (let i = 0; i < data.length; i++) {
150
155
  if (dataPins[data[i][0]]) continue;
package/lib/config.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  * "out": "src/theme", where the theme is written (default: theme)
8
8
  * "prefix": "sl", CSS variable prefix; loomiere-base-ui reads --sl-
9
9
  * "exports": ["ag-grid", "tailwind"] which framework files to write (default: all)
10
+ * "figma": { "file": "<Figma URL>" } the file loomiere figma push writes to
10
11
  * }
11
12
  */
12
13
  import fs from 'node:fs';
@@ -67,5 +68,6 @@ export function resolveConfig(flags = {}, cwd = process.cwd()) {
67
68
  generatedDir: path.join(outDir, 'generated'),
68
69
  prefix: pick('prefix', 'sl'),
69
70
  exports: exports === 'all' ? 'all' : [].concat(exports),
71
+ figma: saved.figma ?? {},
70
72
  };
71
73
  }