@scattered-light/loomiere 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +60 -2
  2. package/bin/loomiere.mjs +112 -0
  3. package/build/build-tokens.mjs +485 -0
  4. package/exporters/README.md +45 -0
  5. package/exporters/ag-grid-settings.mjs +177 -0
  6. package/exporters/ag-grid.mjs +112 -0
  7. package/exporters/chakra.mjs +630 -0
  8. package/exporters/component-styles.mjs +207 -0
  9. package/exporters/dtcg.mjs +31 -0
  10. package/exporters/figma.mjs +100 -0
  11. package/exporters/index.mjs +38 -0
  12. package/exporters/mui.mjs +666 -0
  13. package/exporters/tailwind.mjs +35 -0
  14. package/exporters/theme.mjs +93 -0
  15. package/generator/brand.mjs +328 -0
  16. package/generator/brand.schema.json +657 -0
  17. package/generator/color.mjs +130 -0
  18. package/generator/data-colors.mjs +104 -0
  19. package/generator/format-brand.mjs +60 -0
  20. package/generator/generate.mjs +43 -0
  21. package/generator/pairs.mjs +236 -0
  22. package/generator/roles.mjs +258 -0
  23. package/generator/vision.mjs +167 -0
  24. package/lib/config.mjs +71 -0
  25. package/lib/contrast.mjs +134 -0
  26. package/lib/lab.mjs +108 -0
  27. package/lib/studio.mjs +127 -0
  28. package/lib/tool-server.mjs +96 -0
  29. package/package.json +51 -4
  30. package/templates/brand.json +104 -0
  31. package/tokens/README.md +219 -0
  32. package/tokens/component/avatar.json +10 -0
  33. package/tokens/component/button.json +12 -0
  34. package/tokens/component/drawer.json +26 -0
  35. package/tokens/component/navigation-menu.json +13 -0
  36. package/tokens/component/popover.json +13 -0
  37. package/tokens/component/preview-card.json +13 -0
  38. package/tokens/component/slider.json +13 -0
  39. package/tokens/component/switch.json +41 -0
  40. package/tokens/component/toast.json +21 -0
  41. package/tokens/component/toggle.json +9 -0
  42. package/tokens/component/toolbar.json +9 -0
  43. package/tokens/component/tooltip.json +9 -0
  44. package/tokens/foundation/layering.json +57 -0
  45. package/tokens/foundation/layout.json +75 -0
  46. package/tokens/foundation/motion.json +41 -0
  47. package/tokens/foundation/type.json +92 -0
  48. package/tokens/semantic/accessibility.json +39 -0
  49. package/tokens/semantic/bar.json +45 -0
  50. package/tokens/semantic/check.json +57 -0
  51. package/tokens/semantic/color.json +270 -0
  52. package/tokens/semantic/content.json +124 -0
  53. package/tokens/semantic/disclosure.json +49 -0
  54. package/tokens/semantic/elevation.json +72 -0
  55. package/tokens/semantic/field.json +77 -0
  56. package/tokens/semantic/focus.json +36 -0
  57. package/tokens/semantic/gauge.json +45 -0
  58. package/tokens/semantic/icon-button.json +33 -0
  59. package/tokens/semantic/icon.json +30 -0
  60. package/tokens/semantic/item.json +65 -0
  61. package/tokens/semantic/modal.json +86 -0
  62. package/tokens/semantic/motion.json +20 -0
  63. package/tokens/semantic/notice.json +37 -0
  64. package/tokens/semantic/popup.json +89 -0
  65. package/tokens/semantic/scheme.json +11 -0
  66. package/tokens/semantic/shape.json +54 -0
  67. package/tokens/semantic/spacing.json +110 -0
  68. package/tokens/semantic/table.json +73 -0
  69. package/tokens/semantic/typography.json +312 -0
  70. package/tools/brand-builder/builder.js +1119 -0
  71. package/tools/brand-builder/index.html +329 -0
  72. package/tools/pair-lab/index.html +119 -0
  73. package/tools/pair-lab/lab.js +249 -0
@@ -0,0 +1,249 @@
1
+ /*
2
+ * Pair lab. Served by `loomiere lab`, which supplies /data.json
3
+ * (the built palettes, their labels and the brand file's current pairs) and accepts
4
+ * POST /pairs (writes color.pairs into the brand file and rebuilds the tokens).
5
+ */
6
+ const LEVELS = [['AA-large', 3, 'Large text'], ['AA', 4.5, 'AA'], ['AAA', 7, 'AAA']];
7
+ const AA = 4.5;
8
+ const LIMIT = 240;
9
+
10
+ const lum = (hex) => {
11
+ const n = parseInt(hex.slice(1), 16);
12
+ return [n >> 16, (n >> 8) & 255, n & 255]
13
+ .map((v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; })
14
+ .reduce((a, v, i) => a + v * [0.2126, 0.7152, 0.0722][i], 0);
15
+ };
16
+ const contrast = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
17
+ const levelOf = (r) => [...LEVELS].reverse().find(([, min]) => r >= min)?.[0] ?? null;
18
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
19
+ const $ = (id) => document.getElementById(id);
20
+
21
+ let DATA, all, byKey, STORE;
22
+ let state = { bgRole: 'secondary', bgStep: 'any', fgRole: 'all', min: AA, show: 'visible', starred: {}, hidden: {} };
23
+ const save = () => { try { localStorage.setItem(STORE, JSON.stringify({ ...state, starred: undefined })); } catch { /* private mode */ } };
24
+
25
+ // Brand files say "white" and "black"; the lab calls the ends paper and ink.
26
+ const toKey = (ref) => (ref === 'white' ? 'paper' : ref === 'black' ? 'ink' : ref);
27
+ const toRef = (key) => (key === 'paper' ? 'white' : key === 'ink' ? 'black' : key);
28
+ const pairId = (bg, fg) => `${bg}|${fg}`;
29
+ const nameOf = (key) => { const c = byKey.get(key); return c ? (c.role === 'base' ? c.step : `${c.label} ${c.step}`) : key; };
30
+
31
+ /** Names come from the palettes' own labels: "ember-dune", or "ember-deep" within one palette. */
32
+ function autoName(bg, fg) {
33
+ const word = (c) => (c.role === 'base' ? c.step : c.label);
34
+ const base = bg.role === fg.role ? `${word(bg)}-${lum(bg.hex) < lum(fg.hex) ? 'deep' : 'soft'}` : `${word(bg)}-${word(fg)}`;
35
+ const taken = new Set(Object.values(state.starred).map((s) => s.name));
36
+ if (!taken.has(base)) return base;
37
+ for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
38
+ }
39
+
40
+ function chipRow(el, items, current, onPick) {
41
+ el.innerHTML = items.map(([value, label, dot]) => `<button type="button" class="chip" data-v="${value}" aria-pressed="${String(value) === String(current)}">${dot ? `<span class="dot" style="background:${dot}"></span>` : ''}${esc(label)}</button>`).join('');
42
+ el.onclick = (e) => { const b = e.target.closest('button'); if (b) onPick(b.dataset.v); };
43
+ }
44
+ const mid = (p) => p.steps[Math.min(p.steps.length - 1, Math.floor(p.steps.length / 2))].hex;
45
+
46
+ function renderControls() {
47
+ const palItems = DATA.palettes.map((p) => [p.role, p.role === 'base' ? 'paper & ink' : p.label, mid(p)]);
48
+ chipRow($('bg-pal'), [['all', 'Any'], ...palItems], state.bgRole, (v) => { state.bgRole = v; state.bgStep = 'any'; update(); });
49
+ const pal = DATA.palettes.find((p) => p.role === state.bgRole);
50
+ $('bg-step').innerHTML = `<button type="button" class="step any" data-v="any" aria-pressed="${state.bgStep === 'any'}">Any step</button>` +
51
+ (pal ? pal.steps : []).map((s) => `<button type="button" class="step" data-v="${s.step}" aria-pressed="${state.bgStep === s.step}" style="background:${s.hex};color:${contrast(s.hex, '#ffffff') > AA ? '#fff' : '#000'}" title="${s.hex}">${s.step}</button>`).join('');
52
+ $('bg-step').onclick = (e) => { const b = e.target.closest('button'); if (b) { state.bgStep = b.dataset.v; update(); } };
53
+ chipRow($('fg-pal'), [['all', 'Any palette'], ['same', 'Same palette'], ...palItems], state.fgRole, (v) => { state.fgRole = v; update(); });
54
+ chipRow($('level'), LEVELS.map(([, min, label]) => [min, `${label} · ${min}:1`]), state.min, (v) => { state.min = Number(v); update(); });
55
+ chipRow($('show'), [['visible', 'Hide the ones I hid'], ['starred', 'Only starred'], ['everything', 'Everything']], state.show, (v) => { state.show = v; update(); });
56
+ }
57
+
58
+ function candidates() {
59
+ const bgs = all.filter((c) => (state.bgRole === 'all' || c.role === state.bgRole) && (state.bgStep === 'any' || c.step === state.bgStep));
60
+ const out = [];
61
+ for (const bg of bgs) for (const fg of all) {
62
+ if (fg === bg) continue;
63
+ if (state.fgRole === 'same' ? fg.role !== bg.role : state.fgRole !== 'all' && fg.role !== state.fgRole) continue;
64
+ const ratio = contrast(bg.hex, fg.hex);
65
+ if (ratio < state.min) continue;
66
+ const id = pairId(bg.key, fg.key);
67
+ if (state.show === 'visible' && state.hidden[id]) continue;
68
+ if (state.show === 'starred' && !state.starred[id]) continue;
69
+ out.push({ bg, fg, ratio, id });
70
+ }
71
+ // Pairs closest to the threshold first: those are the subtle, brand-specific mixes.
72
+ return out.sort((a, b) => a.ratio - b.ratio);
73
+ }
74
+
75
+ function renderGrid() {
76
+ const list = candidates();
77
+ const shown = list.slice(0, LIMIT);
78
+ $('count').textContent = `${list.length} pair${list.length === 1 ? '' : 's'}`;
79
+ $('count-meta').textContent = list.length > LIMIT ? `showing the ${LIMIT} closest to ${state.min}:1; narrow the filters to see the rest` : 'lowest contrast first';
80
+ if (!shown.length) { $('grid').innerHTML = '<div class="empty">No pairs match. Try a lower level or another palette.</div>'; return; }
81
+ $('grid').innerHTML = shown.map(({ bg, fg, ratio, id }) => {
82
+ const star = state.starred[id];
83
+ return `<article class="card${star ? ' starred' : ''}" data-id="${id}">
84
+ <div class="mock" style="background:${bg.hex};color:${fg.hex}">
85
+ <span class="eyebrow">Field notes</span>
86
+ <span class="head">Dusk over the dunes</span>
87
+ <span class="copy">Trail closes at sundown. Carry two liters per person.</span>
88
+ <span class="acts"><span class="btn">Plan a trip</span><span class="badge" style="background:${fg.hex};color:${bg.hex}">NEW</span></span>
89
+ </div>
90
+ <div class="foot">
91
+ <span class="name">${esc(star ? star.name : autoName(bg, fg))}<span class="lvl">${levelOf(ratio)} ${ratio.toFixed(2)}</span></span>
92
+ <span class="spec">${esc(nameOf(fg.key))} on ${esc(nameOf(bg.key))}</span>
93
+ <span class="tools">
94
+ <button type="button" class="icon" data-act="star" aria-pressed="${!!star}" aria-label="${star ? 'Unstar' : 'Star'} this pair" title="Star">★</button>
95
+ <button type="button" class="icon" data-act="hide" aria-pressed="${!!state.hidden[id]}" aria-label="${state.hidden[id] ? 'Unhide' : 'Hide'} this pair" title="Hide">✕</button>
96
+ </span>
97
+ </div>
98
+ </article>`;
99
+ }).join('');
100
+ }
101
+
102
+ function toggle(id, act) {
103
+ const [b, f] = id.split('|');
104
+ if (act === 'star') {
105
+ if (state.starred[id]) delete state.starred[id];
106
+ else { state.starred[id] = { bg: b, fg: f, name: autoName(byKey.get(b), byKey.get(f)) }; delete state.hidden[id]; }
107
+ } else if (state.hidden[id]) delete state.hidden[id];
108
+ else { state.hidden[id] = true; delete state.starred[id]; }
109
+ update();
110
+ }
111
+ $('grid').onclick = (e) => { const b = e.target.closest('button[data-act]'); if (b) toggle(b.closest('.card').dataset.id, b.dataset.act); };
112
+
113
+ /** Problems that would stop the build: text or accent under AA, a duplicate or empty name. */
114
+ function problemsOf(id, s) {
115
+ const out = [];
116
+ const check = (mode, bg, fg, accent) => {
117
+ const [b, f, a] = [bg, fg, accent].map((k) => k && byKey.get(k)?.hex);
118
+ if (contrast(b, f) < AA) out.push(`${mode}text is ${contrast(b, f).toFixed(2)}:1, needs ${AA}:1`);
119
+ if (a && contrast(b, a) < AA) out.push(`${mode}accent is ${contrast(b, a).toFixed(2)}:1 on the background, needs ${AA}:1`);
120
+ };
121
+ check('', s.bg, s.fg, s.accent);
122
+ if (s.dark) check('Dark mode: ', s.dark.bg, s.dark.fg, s.dark.accent ?? s.accent);
123
+ if (!/^[a-z][a-z0-9-]*$/.test(s.name)) out.push('Names start with a letter and use a-z, 0-9 and dashes');
124
+ if (Object.entries(state.starred).some(([other, o]) => other !== id && o.name === s.name)) out.push('Another pair has this name');
125
+ return out;
126
+ }
127
+
128
+ function pairsObject() {
129
+ const out = {};
130
+ for (const s of Object.values(state.starred)) {
131
+ const spec = { bg: toRef(s.bg), fg: toRef(s.fg) };
132
+ if (s.accent) spec.accent = toRef(s.accent);
133
+ if (s.dark) spec.dark = Object.fromEntries(Object.entries(s.dark).filter(([, v]) => v).map(([k, v]) => [k, toRef(v)]));
134
+ if (s.description) spec.description = s.description;
135
+ out[s.name] = spec;
136
+ }
137
+ return out;
138
+ }
139
+ const oneLine = (v) => (typeof v === 'object' ? `{ ${Object.entries(v).map(([k, x]) => `${JSON.stringify(k)}: ${oneLine(x)}`).join(', ')} }` : JSON.stringify(v));
140
+ const pairsBlock = () => {
141
+ const entries = Object.entries(pairsObject());
142
+ return entries.length ? `"pairs": {\n${entries.map(([k, v]) => ` ${JSON.stringify(k)}: ${oneLine(v)}`).join(',\n')}\n}` : '"pairs": {}';
143
+ };
144
+
145
+ function accentOptions(bgKey, current) {
146
+ const bg = byKey.get(bgKey).hex;
147
+ const groups = DATA.palettes.map((p) => {
148
+ const opts = p.steps
149
+ .map((st) => ({ key: p.role === 'base' ? st.step : `${p.role}.${st.step}`, hex: st.hex }))
150
+ .filter((c) => c.key === current || contrast(bg, c.hex) >= AA)
151
+ .map((c) => `<option value="${c.key}"${c.key === current ? ' selected' : ''}>${esc(nameOf(c.key))} · ${contrast(bg, c.hex).toFixed(1)}:1</option>`);
152
+ return opts.length ? `<optgroup label="${esc(p.role === 'base' ? 'paper & ink' : p.label)}">${opts.join('')}</optgroup>` : '';
153
+ });
154
+ return `<option value="">None (text color)</option>${groups.join('')}`;
155
+ }
156
+
157
+ function darkOptions(id, s) {
158
+ const others = Object.entries(state.starred).filter(([other]) => other !== id);
159
+ const match = s.dark && others.find(([, o]) => o.bg === s.dark.bg && o.fg === s.dark.fg);
160
+ const custom = s.dark && !match ? `<option value="custom" selected>${esc(nameOf(s.dark.fg))} on ${esc(nameOf(s.dark.bg))} (from the brand file)</option>` : '';
161
+ return `<option value="">Same as light</option>${custom}${others.map(([other, o]) => `<option value="${other}"${match?.[0] === other ? ' selected' : ''}>${esc(o.name)}: ${esc(nameOf(o.fg))} on ${esc(nameOf(o.bg))}</option>`).join('')}`;
162
+ }
163
+
164
+ function renderTray() {
165
+ const entries = Object.entries(state.starred);
166
+ $('saved-meta').textContent = entries.length ? `${entries.length} starred` : '';
167
+ $('saved').innerHTML = entries.length ? entries.map(([id, s]) => {
168
+ const bg = byKey.get(s.bg), fg = byKey.get(s.fg), accent = s.accent && byKey.get(s.accent);
169
+ const problems = problemsOf(id, s);
170
+ return `<div class="saved" data-id="${id}"><span class="sw" style="background:${bg.hex};color:${fg.hex}">Aa${accent ? `<span style="color:${accent.hex}">•</span>` : ''}</span>
171
+ <span style="display:grid;gap:2px;min-width:0"><input data-field="name" value="${esc(s.name)}" aria-label="Name for ${esc(nameOf(s.fg))} on ${esc(nameOf(s.bg))}"><span class="spec">${esc(nameOf(s.fg))} on ${esc(nameOf(s.bg))} · ${contrast(bg.hex, fg.hex).toFixed(2)}:1</span></span>
172
+ <button type="button" class="icon" data-act="star" aria-pressed="true" aria-label="Remove ${esc(s.name)}">★</button>
173
+ <span class="opts">
174
+ <label>Accent <select data-field="accent">${accentOptions(s.bg, s.accent ?? '')}</select></label>
175
+ <label>Dark mode <select data-field="dark">${darkOptions(id, s)}</select></label>
176
+ </span>
177
+ ${problems.map((p) => `<span class="warn">${esc(p)}</span>`).join('')}
178
+ </div>`;
179
+ }).join('') : '<div class="empty">Star a pair above and it lands here with a name.</div>';
180
+ $('json').textContent = pairsBlock();
181
+ const blocked = entries.some(([id, s]) => problemsOf(id, s).length);
182
+ $('save').disabled = blocked;
183
+ $('save').title = blocked ? 'Fix the pairs marked in red first' : '';
184
+ }
185
+
186
+ $('saved').addEventListener('input', (e) => {
187
+ const row = e.target.closest('.saved');
188
+ if (!row || e.target.dataset.field !== 'name') return;
189
+ const clean = e.target.value.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+/, '');
190
+ if (clean !== e.target.value) e.target.value = clean;
191
+ state.starred[row.dataset.id].name = clean;
192
+ $('json').textContent = pairsBlock();
193
+ });
194
+ $('saved').addEventListener('change', (e) => {
195
+ const row = e.target.closest('.saved');
196
+ if (!row) return;
197
+ const s = state.starred[row.dataset.id];
198
+ if (e.target.dataset.field === 'accent') s.accent = e.target.value || undefined;
199
+ if (e.target.dataset.field === 'dark' && e.target.value !== 'custom') {
200
+ const o = state.starred[e.target.value];
201
+ s.dark = o ? { bg: o.bg, fg: o.fg, accent: o.accent } : undefined;
202
+ }
203
+ update();
204
+ });
205
+ $('saved').onclick = (e) => { const b = e.target.closest('button[data-act]'); if (b) toggle(b.closest('.saved').dataset.id, 'star'); };
206
+
207
+ $('copy').onclick = async () => {
208
+ try { await navigator.clipboard.writeText(pairsBlock()); $('status').textContent = 'Copied. Paste it inside "color" in the brand file.'; }
209
+ catch { const r = document.createRange(); r.selectNodeContents($('json')); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); $('status').textContent = 'Selected. Press copy on your keyboard.'; }
210
+ };
211
+
212
+ $('save').onclick = async () => {
213
+ $('save').disabled = true;
214
+ $('status').textContent = 'Saving and rebuilding tokens…';
215
+ try {
216
+ const res = await fetch('/pairs', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pairs: pairsObject() }) });
217
+ const body = await res.json();
218
+ $('status').textContent = body.ok ? `Saved ${Object.keys(pairsObject()).length} pairs to ${DATA.brandFile} and rebuilt the tokens.` : `Not saved: ${body.error}`;
219
+ } catch (err) {
220
+ $('status').textContent = `Not saved: ${err.message}. Is loomiere lab still running?`;
221
+ }
222
+ renderTray();
223
+ };
224
+
225
+ function update() { save(); renderControls(); renderGrid(); renderTray(); }
226
+
227
+ async function start() {
228
+ try {
229
+ DATA = await (await fetch('/data.json')).json();
230
+ } catch {
231
+ document.querySelector('main').innerHTML = '<div class="empty">Start the lab with <code>loomiere lab</code>, then open the address it prints.</div>';
232
+ return;
233
+ }
234
+ STORE = `sl-pair-lab:${DATA.brand}`;
235
+ all = DATA.palettes.flatMap((p) => p.steps.map((s) => ({ role: p.role, label: p.label, step: s.step, hex: s.hex, key: p.role === 'base' ? s.step : `${p.role}.${s.step}` })));
236
+ byKey = new Map(all.map((c) => [c.key, c]));
237
+ try { Object.assign(state, JSON.parse(localStorage.getItem(STORE) || '{}'), { starred: {} }); } catch { /* private mode */ }
238
+ // The brand file is the source of truth for starred pairs.
239
+ for (const [name, spec] of Object.entries(DATA.pairs)) {
240
+ const s = { name, bg: toKey(spec.bg), fg: toKey(spec.fg), accent: spec.accent && toKey(spec.accent), description: spec.description };
241
+ if (spec.dark) s.dark = { bg: toKey(spec.dark.bg ?? spec.bg), fg: toKey(spec.dark.fg ?? spec.fg), accent: spec.dark.accent && toKey(spec.dark.accent) };
242
+ if (byKey.has(s.bg) && byKey.has(s.fg)) state.starred[pairId(s.bg, s.fg)] = s;
243
+ }
244
+ $('title').textContent = `${DATA.brandName} pair lab`;
245
+ document.title = `${DATA.brandName} pair lab`;
246
+ $('brand-file').textContent = DATA.brandFile;
247
+ update();
248
+ }
249
+ start();