@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.
- 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 +136 -0
- package/figma/build-plugin.mjs +25 -0
- package/figma/engine.js +730 -0
- package/figma/library/README.md +92 -0
- package/figma/library/check/known-differences.md +20 -0
- package/figma/library/check/mojave.json +246 -0
- package/figma/library/kit/base.js +89 -0
- package/figma/library/kit/components.js +95 -0
- package/figma/library/kit/docs.js +37 -0
- package/figma/library/kit/fingerprint.js +143 -0
- package/figma/library/pages/02-palettes.js +40 -0
- package/figma/library/pages/03-color.js +126 -0
- package/figma/library/pages/04-type.js +56 -0
- package/figma/library/pages/05-scale.js +58 -0
- package/figma/library/pages/06-size.js +111 -0
- package/figma/library/pages/07-elevation-focus-motion.js +108 -0
- package/figma/library/pages/08-themes.js +36 -0
- package/figma/library/pages/09-fields/01-input.js +55 -0
- package/figma/library/pages/09-fields/02-checks.js +82 -0
- package/figma/library/pages/09-fields/03-field.js +32 -0
- package/figma/library/pages/09-fields/04-more-fields.js +144 -0
- package/figma/library/pages/09-fields/05-labels.js +14 -0
- package/figma/library/pages/09-fields/06-doc.js +75 -0
- package/figma/library/pages/10-buttons/01-buttons.js +97 -0
- package/figma/library/pages/10-buttons/02-doc.js +55 -0
- package/figma/library/pages/11-popups/01-popups.js +166 -0
- package/figma/library/pages/11-popups/02-more-popups.js +90 -0
- package/figma/library/pages/11-popups/03-labels.js +14 -0
- package/figma/library/pages/11-popups/04-doc.js +89 -0
- package/figma/library/pages/12-overlays/01-overlays.js +135 -0
- package/figma/library/pages/12-overlays/02-labels.js +10 -0
- package/figma/library/pages/12-overlays/03-doc.js +80 -0
- package/figma/library/pages/13-disclosure/01-disclosure.js +205 -0
- package/figma/library/pages/13-disclosure/02-collapsible.js +39 -0
- package/figma/library/pages/13-disclosure/03-labels.js +16 -0
- package/figma/library/pages/13-disclosure/04-doc.js +91 -0
- package/figma/library/pages/14-display/01-display.js +165 -0
- package/figma/library/pages/14-display/02-more-display.js +55 -0
- package/figma/library/pages/14-display/03-labels.js +14 -0
- package/figma/library/pages/14-display/04-doc.js +96 -0
- package/figma/library/pages/15-cover.js +87 -0
- package/figma/library/run-order.json +21 -0
- package/figma/library/tools/compare.mjs +11 -0
- package/figma/library/tools/diff-snippet.js +15 -0
- package/figma/library/tools/kit-script.mjs +21 -0
- package/figma/library/tools/run.mjs +27 -0
- package/figma/library/tools/runs.mjs +34 -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
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>' })[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
|
+
};
|
package/generator/brand.mjs
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
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
|
}
|
package/generator/generate.mjs
CHANGED
|
@@ -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 };
|
package/generator/pairs.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|
package/generator/roles.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
75
|
+
overlay: [100, 1100],
|
|
66
76
|
modal: ['white', 1100],
|
|
67
|
-
tray: [50,
|
|
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: '
|
|
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>
|
|
223
|
-
*
|
|
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,
|
|
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
|
}
|
package/generator/vision.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|