@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,1119 @@
1
+ /*
2
+ * Brand builder. Edits a brand file and previews what the generator makes from it.
3
+ *
4
+ * Served by `loomiere studio`, which supplies GET/POST
5
+ * /api/brand. `loomiere studio --export` bundles this file into one page with the brand
6
+ * inlined as window.BRAND_DATA; that copy can't save, only copy the JSON.
7
+ */
8
+ import { formatHex, oklch, wcagContrast } from 'culori';
9
+
10
+ import { buildPrimitives } from '../../generator/brand.mjs';
11
+ import { checkDataColors, DATA_CONTRAST, nudgeDataColor, VISIONS } from '../../generator/data-colors.mjs';
12
+ import { VISION_THEMES, visionColors } from '../../generator/vision.mjs';
13
+ import { formatBrand } from '../../generator/format-brand.mjs';
14
+ import { CONCEPTS, conceptModes, defaultPick, LEVELS, pickRef, SLOTS, stepRef, STYLES, withDefaults } from '../../generator/roles.mjs';
15
+
16
+ const BRAND_ROLES = ['neutral', 'primary', 'secondary', 'tertiary', 'quaternary'];
17
+ const ALERT_ROLES = ['success', 'warning', 'error', 'info'];
18
+ const RATIOS = [
19
+ ['Minor second', 1.067], ['Major second', 1.125], ['Minor third', 1.2], ['Major third', 1.25],
20
+ ['Perfect fourth', 1.333], ['Augmented fourth', 1.414], ['Perfect fifth', 1.5], ['Golden', 1.618],
21
+ ];
22
+ const SAMPLE = 'Dusk over the dunes';
23
+ /**
24
+ * The builder follows the order a brand gets made in: set the brand, then find the pairs it
25
+ * allows, then decide which colors play which part in the UI. Each step only consumes the ones
26
+ * before it, so going back to adjust a brand color and returning never loses anything.
27
+ */
28
+ const STEPS = [
29
+ { id: 'brand', label: 'Brand', intro: 'Colors, type, space and shape: the raw material everything else uses.',
30
+ groups: [['Brand', [['identity', 'Name']]],
31
+ ['Color', [['ends', 'Paper & ink'], ['tables', 'Contrast steps'], ['palettes', 'Brand colors'], ['alerts', 'Alerts'], ['data', 'Data colors'], ['vision', 'Color vision']]],
32
+ ['Shape', [['type', 'Type'], ['space', 'Space'], ['shape', 'Radius & lines'], ['elevation', 'Shadows'], ['motion', 'Motion']]]] },
33
+ { id: 'pairs', label: 'Pairs', intro: 'Background and text combinations the brand colors allow.', groups: [['Pairs', [['pairs', 'Pairs']]]] },
34
+ { id: 'roles', label: 'Roles & themes', intro: 'Which colors play which part, and what the light and dark themes look like.', groups: [['Roles', [['roles', 'Roles & themes'], ['concepts', 'Concepts']]]] },
35
+ ];
36
+ const stepOf = (section) => STEPS.find((st) => st.groups.some(([, items]) => items.some(([id]) => id === section)))?.id;
37
+ const ROOT_PX = 16;
38
+
39
+ const $ = (id) => document.getElementById(id);
40
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
41
+ const clone = (v) => JSON.parse(JSON.stringify(v));
42
+ const round = (n, p = 2) => Number(Number(n).toFixed(p));
43
+ const ratio = (a, b) => wcagContrast(a, b);
44
+ const textOn = (hex) => (ratio(hex, '#ffffff') >= ratio(hex, '#000000') ? '#ffffff' : '#000000');
45
+
46
+ const state = {
47
+ static: Boolean(window.BRAND_DATA),
48
+ name: '',
49
+ brand: null,
50
+ overrides: {},
51
+ savedText: '',
52
+ brands: [],
53
+ built: false,
54
+ usePins: true,
55
+ dataGoal: DATA_CONTRAST,
56
+ // Color vision: which theme's colors are open for tuning, and whether cards show colors
57
+ // as designed or as seen with each vision.
58
+ visionOpen: null,
59
+ step: (() => { try { return localStorage.getItem('brand-builder-step') || 'brand'; } catch { return 'brand'; } })(),
60
+ visionSeen: false,
61
+ // Suggested data color fixes, kept until the data or the goal changes.
62
+ dataFixes: null,
63
+ width: 1280,
64
+ result: null,
65
+ error: null,
66
+ pinError: null,
67
+ // Semantic tokens that read each role concept, from the server ("chrome.fg" → ["text.primary", …]).
68
+ uses: {},
69
+ };
70
+
71
+ /* ---------- paths ---------- */
72
+
73
+ const pathOf = (p) => (Array.isArray(p) ? p : p.split('.'));
74
+ function get(p, obj = state.brand) {
75
+ return pathOf(p).reduce((o, k) => o?.[k], obj);
76
+ }
77
+ function set(p, value, obj = state.brand) {
78
+ const keys = pathOf(p);
79
+ let o = obj;
80
+ for (const k of keys.slice(0, -1)) o = o[k] ??= {};
81
+ if (value === undefined) delete o[keys.at(-1)];
82
+ else o[keys.at(-1)] = value;
83
+ }
84
+
85
+ /* ---------- compute ---------- */
86
+
87
+ function compute() {
88
+ state.pinError = null;
89
+ try {
90
+ try {
91
+ state.result = buildPrimitives(state.brand, state.usePins ? state.overrides : {});
92
+ } catch (err) {
93
+ // A pin that no longer matches (a step was renamed or removed) shouldn't block the preview.
94
+ if (!state.usePins || !/Override/.test(err.message)) throw err;
95
+ state.pinError = err.message;
96
+ state.result = buildPrimitives(state.brand, {});
97
+ }
98
+ state.error = null;
99
+ } catch (err) {
100
+ state.error = err.message;
101
+ }
102
+ }
103
+
104
+ const colors = () => state.result.files.color.color;
105
+ /** A color token's hex, following one level of {color.x.y} reference. */
106
+ function hexAt(tokenPath) {
107
+ const t = tokenPath.split('.').slice(1).reduce((o, k) => o?.[k], colors());
108
+ const v = t?.$value;
109
+ if (typeof v !== 'string') return null;
110
+ const ref = v.match(/^\{(.+)\}$/);
111
+ return ref ? hexAt(ref[1]) : v;
112
+ }
113
+ const pairRef = (r) => `color.${{ paper: 'white', ink: 'black' }[r] ?? r}`;
114
+
115
+ /** px for a CSS length at a viewport width: "1rem", "12px", or clamp(min, a + b, max). */
116
+ function pxAt(value, width) {
117
+ const term = (t) => {
118
+ const m = t.trim().match(/^(-?[\d.]+)(rem|px|vw)?$/);
119
+ if (!m) return 0;
120
+ const n = Number(m[1]);
121
+ return m[2] === 'rem' ? n * ROOT_PX : m[2] === 'vw' ? (n * width) / 100 : n;
122
+ };
123
+ const sum = (expr) => expr.split('+').reduce((a, t) => a + term(t), 0);
124
+ const c = String(value).match(/^clamp\((.+),(.+),(.+)\)$/);
125
+ if (!c) return sum(String(value));
126
+ return Math.min(sum(c[3]), Math.max(sum(c[1]), sum(c[2])));
127
+ }
128
+
129
+ /* ---------- small builders ---------- */
130
+
131
+ const colorIn = (bind, value, label) => `<span class="color-in">
132
+ <input type="color" data-bind="${bind}" data-kind="color" value="${esc(toHex6(value))}" aria-label="${esc(label)}">
133
+ <input type="text" class="hex" data-bind="${bind}" data-kind="hex" value="${esc(value)}" aria-label="${esc(label)} hex" spellcheck="false">
134
+ </span>`;
135
+ function toHex6(v) {
136
+ try { return formatHex(v) ?? '#000000'; } catch { return '#000000'; }
137
+ }
138
+ const numIn = (bind, value, label, attrs = '') => `<label class="field"><span>${label}</span><input type="number" data-bind="${bind}" data-kind="number" value="${esc(value)}" ${attrs}></label>`;
139
+ const textIn = (bind, value, label, attrs = '') => `<label class="field"><span>${label}</span><input type="text" data-bind="${bind}" data-kind="text" value="${esc(value)}" ${attrs}></label>`;
140
+ const listIn = (bind, value, label, kind, hint) => `<label class="field"><span>${label}</span><input type="text" data-bind="${bind}" data-kind="${kind}" value="${esc(value.join(', '))}" spellcheck="false">${hint ? `<small>${hint}</small>` : ''}</label>`;
141
+ const check = (bind, value, label) => `<label class="toggle"><input type="checkbox" data-bind="${bind}" data-kind="bool"${value ? ' checked' : ''}> ${label}</label>`;
142
+ const selectIn = (bind, value, options, label) => `<label class="field"><span>${label}</span><select data-bind="${bind}" data-kind="select">${options.map(([v, l]) => `<option value="${esc(v)}"${String(v) === String(value) ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>`;
143
+
144
+ /** Rows of name → number for an object like radius.steps; names can be renamed. */
145
+ function mapEditor(bind, obj, { valueLabel = 'Value', step = 'any', locks } = {}) {
146
+ const rows = Object.entries(obj).map(([k, v]) => `<div class="map-row${locks ? ' locks' : ''}">
147
+ <input type="text" data-rename="${bind}" data-key="${esc(k)}" value="${esc(k)}" aria-label="Name" spellcheck="false">
148
+ <input type="number" data-bind="${bind}.${k}" data-kind="number" value="${esc(v)}" step="${step}" aria-label="${esc(k)} ${valueLabel}">
149
+ ${locks ? `<label class="toggle" title="Locked steps keep their target even when a brand color lands near them"><input type="checkbox" data-lock="${locks}" data-key="${esc(k)}"${get(locks)?.includes(Number(k)) ? ' checked' : ''}> lock</label>` : ''}
150
+ <button class="icon-btn" type="button" data-act="map-remove" data-bind="${bind}" data-key="${esc(k)}" aria-label="Remove ${esc(k)}">×</button>
151
+ </div>`);
152
+ return `<div class="list">${rows.join('')}<div><button class="btn small" type="button" data-act="map-add" data-bind="${bind}">Add</button></div></div>`;
153
+ }
154
+
155
+ const band = (id, title, intro, edit, preview) => `<section class="band" id="${id}">
156
+ <div class="band-head"><h2>${title}</h2>${intro ? `<p class="muted">${intro}</p>` : ''}</div>
157
+ ${edit === null ? preview : `<div class="band-body"><div class="edit">${edit}</div><div class="preview" id="pv-${id}">${preview}</div></div>`}
158
+ </section>`;
159
+
160
+ /* ---------- sections: editors ---------- */
161
+
162
+ function paletteEditor(role) {
163
+ const p = get(['color', 'palettes', role]);
164
+ const keys = p.keys ?? [];
165
+ const tables = Object.keys(state.brand.color.contrast);
166
+ return `<div class="panel">
167
+ <div class="pal-head"><h3>${esc(p.label ?? role)}</h3>${p.label && p.label !== role ? `<span class="role">${role}</span>` : ''}</div>
168
+ <div class="row">
169
+ ${ALERT_ROLES.includes(role) ? '' : textIn(`color.palettes.${role}.label`, p.label ?? '', 'Label', 'spellcheck="false"')}
170
+ ${selectIn(`color.palettes.${role}.contrast`, p.contrast ?? 'spot', tables.map((t) => [t, t]), 'Contrast steps')}
171
+ </div>
172
+ <div class="field"><span>Keys</span>
173
+ <div class="list">${keys.map((k, i) => `<div class="row">${colorIn(`color.palettes.${role}.keys.${i}`, k, `${role} key ${i + 1}`)}
174
+ <button class="icon-btn" type="button" data-act="key-remove" data-role="${role}" data-i="${i}" aria-label="Remove key ${i + 1}">×</button></div>`).join('')}
175
+ <div><button class="btn small" type="button" data-act="key-add" data-role="${role}">Add key</button></div>
176
+ </div>
177
+ <small>The ramp bends through every key. Order doesn't matter.</small>
178
+ </div>
179
+ <details class="more"${p.exact || p.darkest || p.tone ? ' open' : ''}><summary>More options</summary><div class="edit">
180
+ ${check(`color.palettes.${role}.exact`, p.exact, 'Keys land exactly on their nearest step')}
181
+ <div class="row">
182
+ ${check(`color.palettes.${role}.darkest@toggle`, p.darkest !== undefined, 'Ends at its own darkest color')}
183
+ ${p.darkest !== undefined ? colorIn(`color.palettes.${role}.darkest`, p.darkest, `${role} darkest`) : ''}
184
+ </div>
185
+ ${p.tone ? `<div class="field"><span>Tone</span>
186
+ <div class="row">${colorIn(`color.palettes.${role}.tone.color`, p.tone.color, `${role} tone`)}
187
+ <button class="btn small" type="button" data-act="tone-remove" data-role="${role}">Remove tone</button></div>
188
+ ${rangeIn(`color.palettes.${role}.tone.intensity.0`, toneAt(p, 0), 'Tone in the lights')}
189
+ ${rangeIn(`color.palettes.${role}.tone.intensity.1`, toneAt(p, 1), 'Tone in the darks')}
190
+ <small>A tone tints the ramp like a transparent color over gray, easing from the lights to the darks.</small>
191
+ </div>` : `<div><button class="btn small" type="button" data-act="tone-add" data-role="${role}">Add a tone</button></div>`}
192
+ </div></details>
193
+ </div>`;
194
+ }
195
+ const toneAt = (p, i) => (Array.isArray(p.tone.intensity) ? p.tone.intensity[i] : p.tone.intensity);
196
+ const rangeIn = (bind, value, label) => `<label class="field"><span>${label} · <output data-out="${bind}">${Math.round(value * 100)}%</output></span>
197
+ <input type="range" min="0" max="1" step="0.01" data-bind="${bind}" data-kind="number" value="${esc(value)}"></label>`;
198
+
199
+ function editorsHtml() {
200
+ const b = state.brand;
201
+ const c = b.color;
202
+ const out = [];
203
+
204
+ out.push(band('identity', 'Name', 'What the brand is called and a line about it. The name becomes the file name.',
205
+ `${textIn('name', state.name, 'File name', 'data-special="name" spellcheck="false"')}
206
+ <label class="field"><span>Description</span><textarea rows="2" data-bind="description" data-kind="text">${esc(b.description ?? '')}</textarea></label>`,
207
+ `<div class="panel"><p class="muted">Every other value on this page lives in <code>${esc(state.dir)}/${esc(state.name)}.json</code>. The generator turns it into the primitive tokens; semantic and component tokens point at those, so they follow.</p></div>`));
208
+
209
+ out.push(band('ends', 'Paper and ink', 'The lightest and darkest colors in the system. Every step is measured against paper, and every ramp runs from paper to ink.',
210
+ `<div class="row"><label class="field"><span>Paper</span>${colorIn('color.paper', c.paper, 'Paper')}</label>
211
+ <label class="field"><span>Ink</span>${colorIn('color.ink', c.ink, 'Ink')}</label></div>`,
212
+ ''));
213
+
214
+ out.push(band('tables', 'Contrast steps', 'Each step sits where its contrast against paper hits a target. Palettes on the same table match step for step, so 500 means AA everywhere.',
215
+ Object.entries(c.contrast).map(([name, t]) => `<details class="panel"><summary><h3>${esc(name)}</h3><span class="muted">${Object.keys(t.targets).length} steps · edit</span></summary>
216
+ ${mapEditor(`color.contrast.${name}.targets`, t.targets, { valueLabel: 'contrast', step: '0.01', locks: `color.contrast.${name}.locked` })}</details>`).join(''),
217
+ ''));
218
+
219
+ out.push(`<section class="band" id="palettes"><div class="band-head"><h2>Brand colors</h2>
220
+ <p class="muted">Pick the brand's colors as keys. The neutral uses a tone plus exact keys, so its grays bend through your own light and dark neutrals. Text on each step is that step's on-color, and the curve under each ramp is its path: how colorful each step is, from paper on the left to ink on the right.</p></div>
221
+ <div class="palettes">${BRAND_ROLES.map((r) => `<div class="pal">${paletteEditor(r)}<div class="preview" id="pv-pal-${r}"></div></div>`).join('')}</div></section>`);
222
+
223
+ out.push(`<section class="band" id="alerts"><div class="band-head"><h2>Alerts</h2>
224
+ <p class="muted">Status colors for success, warning, error and info messages. One key each is usually enough; the contrast table does the rest.</p></div>
225
+ <div class="palettes">${ALERT_ROLES.map((r) => `<div class="pal">${paletteEditor(r)}<div class="preview" id="pv-pal-${r}"></div></div>`).join('')}</div>
226
+ <div class="preview" id="pv-alert-sample"></div></section>`);
227
+
228
+ out.push(band('data', 'Data colors', 'Categorical colors for charts, used in this order. Neighbors should be easy to tell apart.',
229
+ `<div class="list">${c.data.map(([name, hex], i) => `<div class="row">
230
+ <input type="text" data-bind="color.data.${i}.0" data-kind="text" value="${esc(name)}" aria-label="Series ${i + 1} name" style="flex:1 1 90px" spellcheck="false">
231
+ ${colorIn(`color.data.${i}.1`, hex, `Series ${i + 1}`)}
232
+ <button class="icon-btn" type="button" data-act="data-up" data-i="${i}" aria-label="Move ${esc(name)} earlier"${i === 0 ? ' disabled' : ''}>↑</button>
233
+ <button class="icon-btn" type="button" data-act="data-remove" data-i="${i}" aria-label="Remove ${esc(name)}">×</button></div>`).join('')}
234
+ <div><button class="btn small" type="button" data-act="data-add">Add a color</button></div></div>
235
+ <label class="field"><span>Contrast on paper</span>
236
+ <select id="data-goal">${[[0, 'No minimum'], [1.5, '1.5:1, faint marks show'], [3, '3:1, WCAG for graphics']].map(([v, t]) => `<option value="${v}"${v === state.dataGoal ? ' selected' : ''}>${t}</option>`).join('')}</select></label>
237
+ <p class="muted">Every color is also checked against its neighbor as seen with each kind of color blindness. Under 3:1 is fine when bars get an outline or a label.</p>`,
238
+ ''));
239
+
240
+ out.push(`<section class="band" id="vision"><div class="band-head"><h2>Color vision</h2>
241
+ <p class="muted">Each theme redesigns this brand's colors for one kind of color blindness, moving each color only as far as it needs to so colors that look different to most people don't blur together. Contrast steps stay the same. Open a theme to tune any color. A page turns one on with <code>data-vision="deuteranopia"</code> on its root element.</p></div>
242
+ <div class="row"><label class="toggle"><input type="checkbox" id="vision-seen"${state.visionSeen ? ' checked' : ''}> Show each theme as people with that vision see it</label></div>
243
+ <div class="preview" id="pv-vision"></div>
244
+ ${visionDetailHtml()}</section>`);
245
+
246
+ const r = withDefaults(b.roles);
247
+ const pals = Object.entries(c.palettes);
248
+ const chromeOpts = pals.filter(([, p]) => p.contrast === 'neutral').map(([n]) => [n, n]);
249
+ const accentOpts = pals.filter(([, p]) => p.contrast !== 'neutral').map(([n]) => [n, n]);
250
+ const colorOpts = [['accent', `accent (${r.accent})`], ['chrome', `chrome (${r.chrome})`], ...pals.map(([n]) => [n, n])];
251
+ const styleOpts = Object.keys(STYLES).map((v) => [v, v]);
252
+ out.push(band('pairs', 'Pairs', 'Named background and text combinations. Pick them in the pair lab (<code>loomiere lab</code>); here you can see whether palette changes still keep them at AA.', null,
253
+ '<div class="preview" id="pv-pairs"></div>'));
254
+
255
+ const t = b.type;
256
+ const stepOpts = t.steps.map((s) => [s, s]);
257
+ out.push(band('type', 'Type', 'A modular scale: each step is the one below times the ratio. The ratio can differ on small and large screens, and sizes ease between them.',
258
+ `<div class="panel"><h3>Families</h3>
259
+ ${['display', 'body', 'mono'].map((r) => listIn(`type.families.${r}`, t.families[r] ?? [], r, 'list-str')).join('')}
260
+ <small class="muted">Google Fonts families load automatically for the preview.</small></div>
261
+ <div class="panel"><h3>Scale</h3>
262
+ <div class="row">${numIn('type.base.min', t.base.min, 'Base, small screen (px)', 'step="0.5"')}${numIn('type.base.max', t.base.max, 'Base, large screen (px)', 'step="0.5"')}</div>
263
+ <div class="row">${numIn('type.ratio.min', t.ratio.min, 'Ratio, small screen', 'step="0.001"')}${numIn('type.ratio.max', t.ratio.max, 'Ratio, large screen', 'step="0.001"')}</div>
264
+ <div class="row">${numIn('type.viewport.min', t.viewport.min, 'Small screen (px)')}${numIn('type.viewport.max', t.viewport.max, 'Large screen (px)')}</div>
265
+ ${listIn('type.steps', t.steps, 'Steps, smallest first', 'list-str')}
266
+ <div class="row">${selectIn('type.baseStep', t.baseStep, stepOpts, 'Base step')}${numIn('type.small.floor', t.small?.floor ?? 0, 'Smallest size (px)')}</div>
267
+ ${check('type.small.fixed', t.small?.fixed !== false, 'Sizes below the base stay fixed')}
268
+ ${check('type.stepDown', Boolean(t.stepDown), 'Headings drop one step on small screens')}
269
+ <small class="muted">On a small screen each size above the base starts where the step below ends on a large screen, instead of shrinking by the base's ratio.</small>
270
+ ${selectIn('type.displayFrom', t.displayFrom ?? '', [['', 'Never'], ...stepOpts], 'Display family from')}</div>
271
+ <div class="panel"><h3>Line height</h3>
272
+ <small class="muted">Each line is the font's own height plus leading. Leading eases from text to headings, so small text gets air and big headings sit tight.</small>
273
+ <div class="row">${numIn('type.lineHeight.text', t.lineHeight.text, 'Text leading (em)', 'step="0.001"')}${selectIn('type.lineHeight.textStep', t.lineHeight.textStep, stepOpts, 'Up to step')}</div>
274
+ <div class="row">${numIn('type.lineHeight.heading', t.lineHeight.heading, 'Heading leading (em)', 'step="0.001"')}${selectIn('type.lineHeight.headingStep', t.lineHeight.headingStep, stepOpts, 'From step')}</div>
275
+ ${selectIn('type.lineHeight.solidFrom', t.lineHeight.solidFrom ?? '', [['', 'Never'], ...stepOpts], 'Set solid (line height 1) from')}
276
+ <h3>Font measurements</h3>
277
+ <div class="table-wrap"><table class="metrics"><thead><tr><th>Font</th><th>Own height</th><th>Cap</th><th>x</th></tr></thead><tbody>
278
+ ${['display', 'body', 'mono'].map((r) => {
279
+ const m = t.metrics?.[r];
280
+ return `<tr><td>${r}</td>${m ? `<td>${round(m.ascent + m.descent, 3)}</td><td>${m.capHeight ?? '–'}</td><td>${m.xHeight ?? '–'}</td>` : '<td colspan="3" class="muted">not measured (1.2 assumed)</td>'}</tr>`;
281
+ }).join('')}</tbody></table></div>
282
+ <div class="row"><button class="btn small" type="button" data-act="measure-fonts">Measure fonts</button>
283
+ <small class="muted">Reads ascent, descent, cap and x-height from the loaded fonts. Do it after changing a family.</small></div></div>`,
284
+ `<div class="panel"><label class="field"><span>Preview width · <output id="width-out">${state.width}px</output></span>
285
+ <input type="range" id="width" min="320" max="1600" step="10" value="${state.width}"></label></div>
286
+ <div class="preview" id="pv-type-out"></div>`));
287
+
288
+ out.push(band('space', 'Space', 'Every space token is the unit times a step, so the whole layout keeps one rhythm.',
289
+ `${numIn('space.unit', b.space.unit, 'Unit (px)', 'step="0.5" min="1"')}
290
+ ${listIn('space.steps', b.space.steps, 'Steps (multiples of the unit)', 'list-num')}`,
291
+ ''));
292
+
293
+ out.push(band('shape', 'Radius and lines', 'Corner radii and border widths, each a multiple of its base.',
294
+ `<div class="panel"><h3>Radius</h3>${numIn('radius.base', b.radius.base, 'Base (px)', 'step="0.5"')}${mapEditor('radius.steps', b.radius.steps, { valueLabel: '× base', step: '0.25' })}</div>
295
+ <div class="panel"><h3>Lines</h3>${numIn('stroke.base', b.stroke.base, 'Base (px)', 'step="0.5"')}${mapEditor('stroke.steps', b.stroke.steps, { valueLabel: '× base', step: '0.5' })}</div>`,
296
+ ''));
297
+
298
+ out.push(band('elevation', 'Shadows', 'The shadow shapes are fixed; the brand sets their color and how strong they are.',
299
+ `<label class="field"><span>Shadow color</span>${colorIn('elevation.shadowColor', b.elevation.shadowColor, 'Shadow color')}</label>
300
+ <label class="field"><span>Strength · <output data-out="elevation.strength">${b.elevation.strength}×</output></span>
301
+ <input type="range" min="0" max="3" step="0.05" data-bind="elevation.strength" data-kind="number" value="${b.elevation.strength}"></label>`,
302
+ ''));
303
+
304
+ out.push(band('motion', 'Motion', 'Durations for transitions. Speed scales all of them at once.',
305
+ `<label class="field"><span>Speed · <output data-out="motion.speed">${b.motion.speed}×</output></span>
306
+ <input type="range" min="0.25" max="3" step="0.05" data-bind="motion.speed" data-kind="number" value="${b.motion.speed}"></label>
307
+ ${mapEditor('motion.durations', b.motion.durations, { valueLabel: 'ms', step: '10' })}`,
308
+ ''));
309
+
310
+ out.push(band('roles', 'Roles & themes', 'Which colors play the main parts, shown on a sample screen in light and dark. Chrome is the surfaces, text and borders; the accent is links, focus and the loudest actions. The ladder sets four levels of emphasis that components share, loudest first. Any part can still be painted in another palette with <code>data-color</code>.',
311
+ `<div class="panel"><div class="row">${selectIn('roles.chrome', r.chrome, chromeOpts, 'Chrome')}${selectIn('roles.accent', r.accent, accentOpts, 'Accent')}</div>
312
+ <small class="muted">The accent doesn't have to be primary. A red brand might keep red for errors and pick a calmer accent.</small></div>
313
+ <div class="panel"><h3>Emphasis ladder</h3>
314
+ ${LEVELS.map((level) => `<div class="row"><b class="level">${level}</b>${selectIn(`roles.emphasis.${level}.style`, r.emphasis[level].style, styleOpts, 'Style')}${selectIn(`roles.emphasis.${level}.color`, r.emphasis[level].color, colorOpts, 'Color')}</div>`).join('')}
315
+ <small class="muted">In code: <code>&lt;Button emphasis="subtle"&gt;</code>, or <code>data-emphasis="subtle"</code> on any element. Add <code>palette="error"</code> (<code>data-color</code>) to repaint a level.</small></div>
316
+ <div class="panel"><h3>What uses what</h3><p class="muted">Each part of the sample screen reads one role. Change a setting and the parts that use it light up.</p>
317
+ <div class="palette-strip" id="pv-strip"></div>
318
+ <div class="row"><button class="btn small" type="button" data-act="step" data-step="brand">Adjust brand colors</button></div></div>`,
319
+ '<div id="pv-theme-out"></div>'));
320
+
321
+ out.push(band('concepts', 'Concepts', 'The colors the theme reuses most, each in light and dark. Every row starts on a step of the chrome or accent palette; pick another step, paper, ink, any palette or a hex to change it, and Reset to go back. Picks are saved under <code>roles.concepts</code> and keep their color when chrome or accent changes.',
322
+ null, '<div class="preview" id="pv-concepts"></div>'));
323
+
324
+ out.push(band('file', 'Brand file', 'What Save writes. Pins come from the overrides file and are never changed here.', null,
325
+ `<div class="preview" id="pv-file"></div>`));
326
+ return out.join('');
327
+ }
328
+
329
+ /* ---------- sections: previews ---------- */
330
+
331
+ function rampHtml(role) {
332
+ const pal = colors()[role];
333
+ const p = state.brand.color.palettes[role];
334
+ const table = state.brand.color.contrast[p.contrast ?? 'spot'];
335
+ const steps = Object.keys(pal).filter((s) => s !== 'on');
336
+ const keys = (p.keys ?? []).map((k) => toHex6(k));
337
+ const paper = state.brand.color.paper;
338
+ const cells = steps.map((s) => {
339
+ const hex = pal[s].$value;
340
+ const on = hexAt(`color.${role}.on.${s}`) ?? textOn(hex);
341
+ const r = ratio(hex, paper);
342
+ const isKey = keys.includes(hex.toLowerCase());
343
+ const locked = table.locked?.includes(Number(s));
344
+ const pinned = pal[s].$extensions?.['sl.override'];
345
+ return `<div class="sw" style="background:${hex};color:${on}" title="${s} ${hex} · ${round(r)}:1 on paper${isKey ? ' · brand key' : ''}${pinned ? ' · pinned' : ''}">
346
+ ${isKey ? '<span class="tag">KEY</span>' : locked ? '<span class="tag">AA</span>' : pinned ? '<span class="tag">PIN</span>' : ''}
347
+ <b>${s}</b><span class="r">${round(r)}:1</span><span class="h">${hex}</span></div>`;
348
+ });
349
+ return `<div class="ramp-wrap"><div class="ramp" role="img" aria-label="${role} ramp">${cells.join('')}</div></div>
350
+ ${curveHtml(role, steps.map((st) => pal[st].$value), keys)}
351
+ ${p.darkest ? `<div class="ends"><span><i style="background:${p.darkest}"></i>ends at ${esc(p.darkest)}</span></div>` : ''}`;
352
+ }
353
+
354
+ /** Chroma against lightness: the path the ramp takes from paper to ink, with the keys on it. */
355
+ function curveHtml(role, hexes, keys) {
356
+ const w = 560, h = 110, pad = 18;
357
+ const pts = hexes.map((hex) => ({ hex, ...oklch(hex) }));
358
+ const ks = keys.map((hex) => ({ hex, ...oklch(hex) }));
359
+ const maxC = Math.max(0.12, ...pts.map((q) => q.c ?? 0), ...ks.map((q) => q.c ?? 0));
360
+ const x = (l) => pad + (1 - l) * (w - pad * 2);
361
+ const y = (c) => h - pad - ((c ?? 0) / maxC) * (h - pad * 2);
362
+ const line = pts.map((q, i) => `${i ? 'L' : 'M'}${x(q.l).toFixed(1)} ${y(q.c).toFixed(1)}`).join(' ');
363
+ return `<svg class="curve" viewBox="0 0 ${w} ${h}" role="img" aria-label="${role} chroma by lightness">
364
+ <line x1="${pad}" x2="${w - pad}" y1="${h - pad}" y2="${h - pad}" class="tick"/>
365
+ <text x="${pad}" y="${h - 4}">lighter</text><text x="${w - pad}" y="${h - 4}" text-anchor="end">darker</text>
366
+ <text x="${pad}" y="11">chroma ${round(maxC, 2)}</text>
367
+ <path d="${line}" fill="none" stroke="var(--strong)" stroke-width="1.5"/>
368
+ ${pts.map((q) => `<circle cx="${x(q.l)}" cy="${y(q.c)}" r="5" fill="${q.hex}" stroke="var(--line)"/>`).join('')}
369
+ ${ks.map((q) => `<rect x="${x(q.l) - 5}" y="${y(q.c) - 5}" width="10" height="10" transform="rotate(45 ${x(q.l)} ${y(q.c)})" fill="${q.hex}" stroke="var(--fg)" stroke-width="1.5"><title>key ${q.hex}</title></rect>`).join('')}
370
+ </svg>`;
371
+ }
372
+
373
+ function tablesHtml() {
374
+ // Each table's targets on a log scale of contrast, with the WCAG lines.
375
+ const max = 21;
376
+ const x = (r) => 30 + (Math.log(r) / Math.log(max)) * 560;
377
+ return Object.entries(state.brand.color.contrast).map(([name, t]) => {
378
+ const marks = Object.entries(t.targets).map(([s, r], i) => {
379
+ const y = [16, 31, 46][i % 3];
380
+ return `<line x1="${x(r)}" x2="${x(r)}" y1="54" y2="${y + 4}" class="tick"/><circle cx="${x(r)}" cy="58" r="4" fill="${t.locked?.includes(Number(s)) ? 'var(--accent)' : 'var(--fg)'}"/><text x="${x(r)}" y="${y}" text-anchor="middle">${s}</text>`;
381
+ });
382
+ const lvls = [[3, 'AA large'], [4.5, 'AA'], [7, 'AAA']].map(([r, l]) => `<line x1="${x(r)}" x2="${x(r)}" y1="10" y2="72" class="lvl"/><text x="${x(r) + 3}" y="84">${l} ${r}</text>`);
383
+ const axis = [1, 2, 5, 10, 21].map((r) => `<text x="${x(r)}" y="100" text-anchor="middle">${r}:1</text>`);
384
+ return `<div class="panel"><h3>${esc(name)}</h3><svg class="scale-svg" viewBox="0 0 620 106" role="img" aria-label="${esc(name)} contrast targets">
385
+ <line x1="30" x2="590" y1="58" y2="58" stroke="var(--line)"/>${lvls.join('')}${marks.join('')}${axis.join('')}</svg>
386
+ <p class="muted">${Object.keys(t.targets).length} steps. Filled accent dots are locked.</p></div>`;
387
+ }).join('');
388
+ }
389
+
390
+ function endsHtml() {
391
+ const { paper, ink } = state.brand.color;
392
+ return `<div class="panel" style="background:${paper};color:${ink};border-color:${ink}22">
393
+ <h3 style="font-size:1.6rem">Ink on paper</h3><p>${round(ratio(paper, ink))}:1. This is the most contrast any pair in the system can have.</p></div>`;
394
+ }
395
+
396
+ function alertsHtml() {
397
+ const c = colors();
398
+ return `<div class="alerts">${ALERT_ROLES.map((r) => {
399
+ const bg = c[r]['75']?.$value ?? c[r]['50'].$value;
400
+ const edge = c[r]['500'].$value;
401
+ const fg = c[r]['900']?.$value ?? c[r]['800'].$value;
402
+ const copy = { success: ['Saved', 'Your changes are live.'], warning: ['Check this', 'Two fields need a second look.'], error: ['Couldn’t save', 'The server didn’t answer. Try again.'], info: ['Heads up', 'Trails close at sundown.'] }[r];
403
+ return `<div class="alert" style="background:${bg};border-color:${edge};color:${fg}"><b>${copy[0]}</b><span>${copy[1]}</span>
404
+ <span class="mono">${r} 900 on 75 · ${round(ratio(fg, bg))}:1</span></div>`;
405
+ }).join('')}</div>`;
406
+ }
407
+
408
+ const dataKey = () => JSON.stringify([state.brand.color.data, state.brand.color.paper, state.dataGoal]);
409
+ const visionName = (v) => ({ typical: 'typical vision', protan: 'red-blind vision', deutan: 'green-blind vision', tritan: 'blue-blind vision' })[v];
410
+
411
+ /** One note per color that has problems, in series order. */
412
+ function dataNotes() {
413
+ const data = state.brand.color.data;
414
+ const byColor = new Map();
415
+ for (const p of checkDataColors(data, state.brand.color.paper, state.dataGoal)) {
416
+ const note = byColor.get(p.i) ?? { contrast: null, alike: [] };
417
+ if (p.kind === 'contrast') note.contrast = p.value;
418
+ else note.alike.push(p.vision);
419
+ byColor.set(p.i, note);
420
+ }
421
+ return [...byColor].map(([i, { contrast, alike }]) => {
422
+ const [name] = data[i];
423
+ const parts = [];
424
+ if (contrast !== null) parts.push(`is ${round(contrast)}:1 on paper, so bars and lines need an outline to be seen`);
425
+ if (alike.length) parts.push(`looks like ${data[i - 1][0]} next to it with ${alike.map(visionName).join(', ')}`);
426
+ return { i, text: `${name} ${parts.join(', and ')}.` };
427
+ });
428
+ }
429
+
430
+ function dataHtml() {
431
+ const data = state.brand.color.data;
432
+ const paper = state.brand.color.paper;
433
+ const notes = dataNotes();
434
+ const flagged = new Set(notes.map((n) => n.i));
435
+ const fixes = state.dataFixes?.key === dataKey() ? state.dataFixes.hex : null;
436
+ const chips = data.map(([name, hex], i) => `<span class="chip${flagged.has(i) ? ' flag' : ''}"><i style="background:${hex}"></i>${i + 1} ${esc(name)}<br>${round(ratio(hex, paper))}:1</span>`);
437
+ const values = [62, 48, 75, 33, 57, 41, 69, 52, 28, 64, 45, 71, 37, 59, 50, 66, 30, 55, 43, 68];
438
+ const n = Math.min(data.length, 12);
439
+ const w = 560, h = 180, left = 34, bw = (w - left - 10) / n;
440
+ const y = (v) => h - 20 - (v / 100) * (h - 40);
441
+ const grid = [0, 25, 50, 75, 100].map((v) => `<line class="grid" x1="${left}" x2="${w - 6}" y1="${y(v)}" y2="${y(v)}"/><text x="${left - 6}" y="${y(v) + 3}" text-anchor="end">${v}</text>`);
442
+ const bars = data.slice(0, n).map(([name, hex], i) => `<rect x="${left + i * bw + 3}" y="${y(values[i])}" width="${bw - 6}" height="${y(0) - y(values[i])}" rx="2" fill="${hex}"><title>${esc(name)}: ${values[i]}</title></rect>`);
443
+ const fixFor = (i) => {
444
+ if (!fixes) return '';
445
+ const hex = fixes[i];
446
+ if (!hex) return '<span class="muted">No nearby color clears this; try another hue or move it in the order.</span>';
447
+ return `<button class="fix" type="button" data-act="data-fix" data-i="${i}" data-hex="${hex}" title="Use ${hex} for ${esc(data[i][0])}">
448
+ <i style="background:${data[i][1]}"></i>→<i style="background:${hex}"></i> Use ${hex}</button>`;
449
+ };
450
+ const actions = !notes.length ? ''
451
+ : fixes ? `<div class="row"><button class="btn small" type="button" data-act="data-fix-all">Use every fix</button></div>`
452
+ : `<div class="row"><button class="btn small" type="button" data-act="data-suggest">Suggest fixes</button>
453
+ <span class="muted">Finds the nearest color that clears each check, for every kind of color vision.</span></div>`;
454
+ return `<div class="chips">${chips.join('')}</div>
455
+ <div class="panel"><span class="lbl">The first ${n} series in a column chart (sample values)</span>
456
+ <svg class="chart" viewBox="0 0 ${w} ${h}" role="img" aria-label="Sample chart with the data colors">${grid.join('')}${bars.join('')}</svg></div>
457
+ ${notes.length ? `<ul class="warn-list">${notes.map((note) => `<li><span>${esc(note.text)}</span>${fixFor(note.i)}</li>`).join('')}</ul>${actions}`
458
+ : '<p class="muted">Every color shows on paper and every neighbor is easy to tell apart, for every kind of color vision.</p>'}`;
459
+ }
460
+
461
+ function suggestDataFixes() {
462
+ const { data, paper } = state.brand.color;
463
+ const hex = {};
464
+ for (const { i } of dataNotes()) hex[i] = nudgeDataColor(data, i, paper, state.dataGoal);
465
+ state.dataFixes = { key: dataKey(), hex };
466
+ }
467
+
468
+ /** Applies fixes in series order, re-checking each against the colors already fixed. */
469
+ function fixAllData() {
470
+ const { data, paper } = state.brand.color;
471
+ for (const { i } of dataNotes()) {
472
+ const still = checkDataColors(data, paper, state.dataGoal).some((p) => p.i === i);
473
+ const hex = still && nudgeDataColor(data, i, paper, state.dataGoal);
474
+ if (hex) data[i][1] = hex;
475
+ }
476
+ }
477
+
478
+ const PREVALENCE = {
479
+ deuteranopia: 'About 6 in 100 men, including milder forms',
480
+ protanopia: 'About 2 in 100 men, including milder forms',
481
+ tritanopia: 'About 1 in 10,000 people',
482
+ achromatopsia: 'About 1 in 30,000 people, plus grayscale screens and print',
483
+ };
484
+ const seeHex = (vision, hex) => formatHex(VISIONS[vision].see(hex));
485
+
486
+ /** Five cards: the brand, then each theme. A theme card opens its colors for tuning. */
487
+ function visionHtml() {
488
+ const c = state.brand.color;
489
+ const generated = visionColors(c);
490
+ const roles = Object.keys(Object.values(generated)[0]?.keys ?? {});
491
+ const card = ({ id, title, sub, note, keys, data, sees, open }) => {
492
+ const show = (hex) => (sees && state.visionSeen ? seeHex(sees, hex) : hex);
493
+ return `<article class="vcard${open ? ' open' : ''}">
494
+ <header><b>${title}</b><span class="muted">${sub}</span></header>
495
+ <div class="vcard-keys">${keys.map(([r, hex]) => `<i style="background:${show(hex)}" title="${esc(r)} ${hex}"></i>`).join('')}</div>
496
+ <div class="vision-data">${data.map(([name, hex]) => `<i style="background:${show(hex)}" title="${esc(name)} ${hex}"></i>`).join('')}</div>
497
+ <small class="muted">${note}</small>
498
+ ${id ? `<button class="btn small" type="button" data-act="vision-open" data-theme="${id}" aria-expanded="${open}">${open ? 'Close' : 'Tune colors'}</button>` : ''}</article>`;
499
+ };
500
+ const brandCard = card({
501
+ title: 'Brand', sub: 'Typical color vision', note: `${roles.length} palettes and ${c.data.length} data colors, as designed above.`,
502
+ keys: roles.map((r) => [r, generated[Object.keys(generated)[0]].brandKeys[r]]), data: c.data,
503
+ });
504
+ const themeCards = Object.entries(generated).map(([theme, g]) => {
505
+ const { name, label, sees } = VISION_THEMES[theme];
506
+ const pins = Object.keys(c.vision?.[theme]?.keys ?? {}).length + Object.keys(c.vision?.[theme]?.data ?? {}).length;
507
+ const blur = g.blurred.before === g.blurred.after ? `${g.blurred.after} pairs blur together` : `Blurred pairs: ${g.blurred.before} → ${g.blurred.after}`;
508
+ return card({
509
+ id: theme, title: name, sub: label, sees, open: state.visionOpen === theme,
510
+ note: `${PREVALENCE[theme]}. ${blur}${pins ? `. ${pins} pinned` : ''}.`,
511
+ keys: Object.entries(g.keys), data: g.data,
512
+ });
513
+ });
514
+ return `<div class="vcards">${brandCard}${themeCards.join('')}</div>`;
515
+ }
516
+
517
+ /** Every color of the open theme: brand and theme, each as designed and as seen, with a pin. */
518
+ function visionDetailHtml() {
519
+ const theme = state.visionOpen;
520
+ if (!theme) return '';
521
+ const c = state.brand.color;
522
+ const g = visionColors(c)[theme];
523
+ const { name, sees } = VISION_THEMES[theme];
524
+ const pins = c.vision?.[theme] ?? {};
525
+ const sw = (hex, title) => `<i class="sw-s" style="background:${hex}" title="${esc(title)} ${hex}"></i>`;
526
+ const row = (label, kind, key, brandHex, themeHex) => {
527
+ const pinned = pins[kind]?.[key];
528
+ return `<tr><th scope="row">${esc(label)}</th>
529
+ <td>${sw(brandHex, 'brand')}${sw(seeHex(sees, brandHex), 'brand, as seen')}</td>
530
+ <td>${sw(themeHex, name)}${sw(seeHex(sees, themeHex), `${name}, as seen`)}</td>
531
+ <td>${colorIn(`color.vision.${theme}.${kind}.${key}`, pinned ?? themeHex, `${name} ${label}`)}</td>
532
+ <td>${pinned ? `<button class="btn small" type="button" data-act="vision-unpin" data-theme="${theme}" data-kind="${kind}" data-key="${esc(key)}">Use generated</button>` : `<span class="muted">${themeHex.toLowerCase() === brandHex.toLowerCase() ? 'same as brand' : 'generated'}</span>`}</td></tr>`;
533
+ };
534
+ const head = '<thead><tr><th></th><th>Brand · as seen</th><th>Theme · as seen</th><th>Color</th><th></th></tr></thead>';
535
+ const paletteRows = Object.entries(g.keys).map(([r, hex]) => row(c.palettes[r].label ? `${c.palettes[r].label} (${r})` : r, 'keys', r, g.brandKeys[r], hex));
536
+ const dataRows = g.data.map(([n, hex], i) => row(n, 'data', n, c.data[i][1], hex));
537
+ return `<div class="panel vision-detail"><div class="row"><h3>${name} colors</h3>
538
+ <button class="btn small" type="button" data-act="vision-open" data-theme="${theme}">Close</button></div>
539
+ <small class="muted">Each pair of swatches is the color, then how it looks with ${name.toLowerCase()}. Change a color to pin it; the others move to make room. A palette's color is its most colorful key, and the whole ramp follows it.</small>
540
+ <div class="table-wrap"><table class="vtable">${head}<tbody><tr><td colspan="5" class="vgroup">Palettes</td></tr>${paletteRows.join('')}
541
+ <tr><td colspan="5" class="vgroup">Data colors</td></tr>${dataRows.join('')}</tbody></table></div></div>`;
542
+ }
543
+
544
+ function pairsHtml() {
545
+ const pairs = state.brand.color.pairs ?? {};
546
+ const problems = state.result.problems;
547
+ if (!Object.keys(pairs).length) return '<p class="muted">No pairs yet. Run <code>loomiere lab</code> to pick some.</p>';
548
+ const cards = Object.entries(pairs).map(([name, spec]) => {
549
+ const bg = hexAt(pairRef(spec.bg)), fg = hexAt(pairRef(spec.fg)), accent = spec.accent && hexAt(pairRef(spec.accent));
550
+ if (!bg || !fg) return `<div class="pair"><b>${esc(name)}</b><span>Uses a color that no longer exists.</span></div>`;
551
+ return `<div class="pair" style="background:${bg};color:${fg}"><b>${esc(name)}</b>
552
+ <span>${esc(spec.fg)} on ${esc(spec.bg)} · ${round(ratio(fg, bg))}:1</span>
553
+ ${accent ? `<span style="color:${accent}">accent ${esc(spec.accent)} · ${round(ratio(accent, bg))}:1</span>` : ''}</div>`;
554
+ });
555
+ return `${problems.length ? `<div class="notice bad"><p>These pairs would stop the build:<br>${problems.map(esc).join('<br>')}</p></div>` : ''}<div class="pairs">${cards.join('')}</div>`;
556
+ }
557
+
558
+ /* Roles: the ladder and a few Base UI parts, drawn from the scope slots in light and dark. */
559
+ const tableOf = (palette) => (state.brand.color.palettes[palette]?.contrast === 'neutral' ? 'neutral' : 'spot');
560
+ const slotHex = (palette, slot, dark) => hexAt(stepRef(palette, SLOTS[tableOf(palette)][slot][dark ? 1 : 0]).slice(1, -1));
561
+
562
+ /** A pick or token value ("{color.x.y}" or a hex) as a hex. */
563
+ const refHex = (ref) => (ref?.startsWith('{') ? hexAt(ref.slice(1, -1)) : ref ?? null);
564
+ /** A role concept's hex with the brand's picks applied, the way the build makes it. */
565
+ const conceptHex = (role, slot, dark) => refHex(conceptModes(state.brand, withDefaults(state.brand.roles), role, slot)[dark ? 'dark' : 'light']);
566
+ /** A slot's hex in a role (with picks) or a named palette (as generated). */
567
+ const scopeHex = (color, slot, dark) => (color === 'accent' || color === 'chrome' ? conceptHex(color, slot, dark) : slotHex(color, slot, dark));
568
+
569
+ function paint(level, dark, palette) {
570
+ const r = withDefaults(state.brand.roles);
571
+ const { style, color } = r.emphasis[level];
572
+ return Object.fromEntries(Object.entries(STYLES[style]).map(([part, slot]) => [part, slot === 'transparent' ? 'transparent' : scopeHex(palette ?? color, slot, dark)]));
573
+ }
574
+
575
+ function rolesScene(dark) {
576
+ const r = withDefaults(state.brand.roles);
577
+ const ch = r.chrome, ac = r.accent;
578
+ const c = (sl) => conceptHex('chrome', sl, dark);
579
+ const page = c('canvas');
580
+ const text = c('fg');
581
+ const muted = c('fg-secondary');
582
+ const raised = c('raised');
583
+ const line = c('border-subtle');
584
+ const field = c('border');
585
+ const slot = (p, sl) => (p === ac ? conceptHex('accent', sl, dark) : p === ch ? c(sl) : slotHex(p, sl, dark));
586
+ const warn = dark ? '#ff8a80' : '#b42318';
587
+ // A level's color: accent levels read the accent, chrome levels the chrome, so both flash.
588
+ const uses = (level) => `${level} ${r.emphasis[level].color}`;
589
+ const btn = (level, label, { palette, on = page, rate = false } = {}) => {
590
+ const e = paint(level, dark, palette);
591
+ const cr = ratio(e.fg, e.bg === 'transparent' ? on : e.bg);
592
+ const button = `<span class="rb" data-uses="${palette ? 'palette' : uses(level)}" title="${level}${palette ? ` in ${palette}` : ''}: ${r.emphasis[level].style}, ${round(cr, 1)}:1" style="background:${e.bg};color:${e.fg};border-color:${e.border}">${esc(label)}</span>`;
593
+ return rate ? `<span class="rb-wrap">${button}<small style="color:${cr < 4.5 ? warn : muted}">${round(cr, 1)}:1${cr < 4.5 ? ' low' : ''}</small></span>` : button;
594
+ };
595
+ const tag = (t) => `<em class="uses" style="color:${muted}">${t}</em>`;
596
+ const spots = Object.keys(state.brand.color.palettes).filter((n) => tableOf(n) === 'spot');
597
+ const hl = paint('subtle', dark);
598
+ return `<div class="scene" style="background:${page};color:${text}">
599
+ <b class="scene-label" style="color:${muted}">${dark ? 'Dark' : 'Light'}</b>
600
+ <div class="rapp-bar" data-uses="chrome" style="background:${raised};border-color:${line}">
601
+ <b>Dunes</b>
602
+ <nav>${['Surveys', 'Maps', 'Team'].map((t, i) => `<span data-uses="${i === 0 ? 'accent' : 'chrome'}" style="color:${i === 0 ? slot(ac, 'fg') : muted}">${t}</span>`).join('')}</nav>
603
+ ${btn('bold', 'New survey', { on: raised })}
604
+ </div>
605
+ <div class="rgrid">
606
+ <div class="rcard" data-uses="chrome" style="background:${raised};border-color:${line}">
607
+ <b>Dune survey</b><span style="color:${muted}">Three sites logged at dusk.</span>
608
+ <div class="rb-row">${btn('bold', 'Open', { on: raised })}${btn('standard', 'Edit', { on: raised })}${btn('subtle', 'Copy', { on: raised })}${btn('quiet', 'Archive', { on: raised })}</div></div>
609
+ <div class="rform">
610
+ <span class="ri" data-uses="chrome" style="border-color:${field};color:${muted}">Search surveys</span>
611
+ <span class="ri" data-uses="accent" style="border-color:${field};outline:2px solid ${slot(ac, 'focus')};outline-offset:2px;color:${text}">Focused field</span>
612
+ <span class="rb-row">
613
+ <span class="rcheck-row" data-uses="accent"><i class="rcheck" style="background:${slot(ac, 'solid')};color:${slot(ac, 'on-solid')}">✓</i>Checked</span>
614
+ <span class="rcheck-row" data-uses="accent"><i class="rswitch" style="background:${slot(ac, 'solid')}"><i style="background:${slot(ac, 'on-solid')}"></i></i>On</span>
615
+ <span class="rcheck-row" data-uses="chrome"><i class="rswitch off" style="background:${slot(ch, 'soft-active')}"><i style="background:${page}"></i></i>Off</span>
616
+ </span>
617
+ </div>
618
+ <div class="rmenu" data-uses="chrome" style="background:${raised};border-color:${line}">
619
+ <span>Rename</span><span data-uses="${uses('subtle')}" style="background:${hl.bg};color:${hl.fg}" title="Highlighted item: the subtle level">Duplicate</span><span>Move to…</span>
620
+ <span data-uses="palette" style="color:${slot('error', 'fg') ?? text}">Delete</span></div>
621
+ </div>
622
+ <div class="rtabs" data-uses="accent" style="border-color:${line}">${['Overview', 'Activity', 'Settings'].map((t, i) => `<span style="${i === 0 ? `color:${slot(ac, 'fg')};border-color:${slot(ac, 'solid')}` : `color:${muted};border-color:transparent`}">${t}</span>`).join('')}</div>
623
+ ${['error', 'success'].filter((p) => state.brand.color.palettes[p]).map((p) => `<div class="ralert" data-uses="palette" style="background:${slot(p, 'soft')};color:${slot(p, 'on-soft')};border-color:${slot(p, 'border-subtle')}">
624
+ <b>${p === 'error' ? 'Upload failed' : 'Saved'}</b> ${p === 'error' ? 'The file is over 20 MB.' : 'Your changes are live.'}</div>`).join('')}
625
+ <div class="rladder">${tag('Emphasis ladder')}<div class="rb-row">${LEVELS.map((l) => btn(l, l, { rate: true })).join('')}</div></div>
626
+ <div class="rladder">${tag('Bold in every palette (data-color)')}<div class="rb-row">${spots.map((p) => btn('bold', p, { palette: p, rate: true })).join('')}</div></div>
627
+ </div>`;
628
+ }
629
+
630
+ /** Briefly marks the preview parts that read a role setting, so a change shows where it lands. */
631
+ function flashUses(bind) {
632
+ const [, what, level] = bind.split('.');
633
+ const key = what === 'emphasis' ? level : what;
634
+ for (const el of document.querySelectorAll(`#pv-theme-out [data-uses~="${key}"]`)) {
635
+ el.classList.remove('flash');
636
+ void el.offsetWidth;
637
+ el.classList.add('flash');
638
+ }
639
+ }
640
+
641
+ const rolesHtml = () => `<div class="scenes">${rolesScene(false)}${rolesScene(true)}</div>`;
642
+
643
+ /* Concepts: one row per reused color, light and dark, with the step it uses and a reset. */
644
+ function conceptPicker(key, mode, role, current, fallback) {
645
+ const palettes = state.brand.color.palettes;
646
+ const steps = (name) => Object.keys(colors()[name] ?? {}).filter((k) => k !== 'on');
647
+ const own = withDefaults(state.brand.roles)[role];
648
+ const opt = (value, label) => `<option value="${esc(value)}"${value === current ? ' selected' : ''}>${esc(label)}${value === fallback ? ' (default)' : ''}</option>`;
649
+ const group = (name) => `<optgroup label="${esc(name)}">${steps(name).map((st) => opt(`${name}.${st}`, `${name} ${st}`)).join('')}</optgroup>`;
650
+ const hex = /^#/.test(current);
651
+ // On-colors and other steps the lists leave out still show as the selected option.
652
+ const listed = hex || current === 'white' || current === 'black' || Object.keys(palettes).some((n) => steps(n).some((st) => `${n}.${st}` === current));
653
+ return `<span class="cpick"><select data-concept="${key}" data-mode="${mode}" aria-label="${key} ${mode}">
654
+ ${listed ? '' : opt(current, current.replace('.', ' '))}${group(own)}<optgroup label="Ends">${opt('white', 'Paper')}${opt('black', 'Ink')}</optgroup>
655
+ ${Object.keys(palettes).filter((n) => n !== own).map(group).join('')}
656
+ <optgroup label="Other">${opt(hex ? current : '#', hex ? `Hex ${current}` : 'Hex…')}</optgroup></select>
657
+ ${hex ? `<input type="text" class="chex" data-concept-hex="${key}" data-mode="${mode}" value="${esc(current)}" spellcheck="false" aria-label="${key} ${mode} hex">` : ''}</span>`;
658
+ }
659
+
660
+ function conceptsHtml() {
661
+ const r = withDefaults(state.brand.roles);
662
+ const palettes = state.brand.color.palettes;
663
+ const warn = '#b42318';
664
+ // Emphasis levels read concepts too: a level in a role reads that role's slots.
665
+ const levelUses = (role, slot) => LEVELS.filter((l) => r.emphasis[l].color === role && Object.values(STYLES[r.emphasis[l].style]).includes(slot)).map((l) => `emphasis ${l}`);
666
+ let lastGroup = '';
667
+ const rows = CONCEPTS.filter(({ role, slot }) => SLOTS[tableOf(r[role])]?.[slot]).map(({ role, group, slot, label, against, min }) => {
668
+ const key = `${role}.${slot}`;
669
+ const [l, d] = SLOTS[tableOf(r[role])][slot];
670
+ const pick = r.concepts?.[key] ?? {};
671
+ const other = against && (SLOTS[tableOf(r[role])][against] ? [role, against] : ['chrome', against]);
672
+ const cell = (mode) => {
673
+ const dark = mode === 'dark';
674
+ const hex = conceptHex(role, slot, dark);
675
+ const fallback = defaultPick(r[role], dark ? d : l);
676
+ const cr = other ? ratio(hex, conceptHex(other[0], other[1], dark)) : null;
677
+ const low = min && cr < min;
678
+ return `<td class="cswatch${dark ? ' dark' : ''}" style="--cbg:${conceptHex('chrome', 'canvas', dark)}"><i style="background:${hex}"></i>
679
+ <span>${conceptPicker(key, mode, role, pick[mode] ?? fallback, fallback)}
680
+ ${cr ? `<small class="${low ? 'bad' : 'muted'}" style="${low ? `color:${warn}` : ''}">${round(cr, 1)}:1 on ${other[1]}${low ? ` · below ${min}` : ''}</small>` : ''}</span></td>`;
681
+ };
682
+ const used = [...(state.uses[key] ?? []), ...levelUses(role, slot)];
683
+ const head = group !== lastGroup ? `<tr class="cgroup"><th colspan="4">${esc(group)} <small class="muted">${esc(r[role])}</small></th></tr>` : '';
684
+ lastGroup = group;
685
+ const changed = Object.keys(pick).length > 0;
686
+ return `${head}<tr data-uses="${role}">
687
+ <th><b>${esc(label)}</b><code>${key}</code>${changed ? `<button class="btn small" type="button" data-act="concept-reset" data-key="${key}">Reset</button>` : ''}</th>
688
+ ${cell('light')}${cell('dark')}
689
+ <td class="cused">${used.length ? used.map((u) => `<code>${esc(u)}</code>`).join(' ') : '<span class="muted">Only through data-color</span>'}</td></tr>`;
690
+ });
691
+ const bad = Object.keys(r.concepts ?? {}).filter((k) => !palettes[r[k.split('.')[0]]]);
692
+ return `${bad.length ? `<p class="notice bad">${bad.map(esc).join(', ')} no longer match a palette.</p>` : ''}
693
+ <div class="ctable-wrap"><table class="ctable"><thead><tr><th>Concept</th><th>Light</th><th>Dark</th><th>Used by</th></tr></thead><tbody>${rows.join('')}</tbody></table></div>`;
694
+ }
695
+
696
+ /** Saves a concept pick, dropping it (and the row) when it matches the default again. */
697
+ function setConcept(key, mode, value) {
698
+ const r = withDefaults(state.brand.roles);
699
+ const [role, slot] = key.split('.');
700
+ const [l, d] = SLOTS[tableOf(r[role])][slot];
701
+ const roles = (state.brand.roles ??= {});
702
+ const picks = (roles.concepts ??= {});
703
+ const pick = (picks[key] ??= {});
704
+ if (value === defaultPick(r[role], mode === 'dark' ? d : l) || pickRef(value, state.brand.color.palettes) === undefined) delete pick[mode];
705
+ else pick[mode] = value;
706
+ if (!Object.keys(pick).length) delete picks[key];
707
+ if (!Object.keys(picks).length) delete roles.concepts;
708
+ }
709
+
710
+ const scaleAt = (t, r, step, width) => {
711
+ // The generator's own math, for one ratio, at one width.
712
+ const n = t.steps.indexOf(step) - t.steps.indexOf(t.baseStep);
713
+ const at = (base, rr) => base * rr ** n;
714
+ const small = at(t.base.min, r.min), large = at(t.base.max, r.max);
715
+ const k = Math.min(1, Math.max(0, (width - t.viewport.min) / (t.viewport.max - t.viewport.min)));
716
+ return small + (large - small) * k;
717
+ };
718
+
719
+ function typeHtml() {
720
+ const t = state.brand.type;
721
+ const files = state.result.files.type;
722
+ const pinnedCount = Object.values(files['font-size']).filter((v) => v.$extensions?.['sl.override']).length;
723
+ const fam = (r) => (t.families[r] ?? []).map((f) => (/\s/.test(f) ? `'${f}'` : f)).join(', ');
724
+ const w = state.width;
725
+ const rows = [...t.steps].reverse().map((s) => {
726
+ const tok = files['font-size'][s];
727
+ const px = pxAt(tok.$value, w);
728
+ const lh = files['line-height'][s].$value;
729
+ const pin = tok.$extensions?.['sl.override'] ? '<span class="pin">PIN</span>' : '';
730
+ const display = t.displayFrom && t.steps.indexOf(s) >= t.steps.indexOf(t.displayFrom);
731
+ const face = display ? fam('display') : fam('body');
732
+ return `<tr${s === t.baseStep ? ' class="base-row"' : ''}><td class="n">${s}${pin}</td><td class="n">${round(px, 1)}px</td><td class="n">${lh}<br><small>${round(px * lh, 1)}px</small></td>
733
+ <td class="s" style="font-family:${esc(face)};font-size:${px}px;line-height:${lh}">${SAMPLE}</td></tr>`;
734
+ });
735
+ const presets = RATIOS.map(([name, r]) => {
736
+ const ladder = ['xs', t.baseStep, ...t.steps.slice(t.steps.indexOf(t.baseStep) + 1, t.steps.indexOf(t.baseStep) + 5)]
737
+ .filter((s, i, a) => t.steps.includes(s) && a.indexOf(s) === i);
738
+ const onMin = Math.abs(t.ratio.min - r) < 0.0005, onMax = Math.abs(t.ratio.max - r) < 0.0005;
739
+ return `<div class="preset${onMin ? ' on-min' : ''}${onMax ? ' on-max' : ''}">
740
+ <div class="name">${name}<em>${r}</em></div>
741
+ <div class="ladder" style="font-family:${esc(fam('display'))}">${ladder.slice().reverse().map((s) => `<span style="font-size:${Math.min(56, scaleAt(t, { min: r, max: r }, s, t.viewport.max))}px">Aa ${s}</span>`).join('')}</div>
742
+ <div class="row"><button class="btn small" type="button" data-act="ratio" data-which="min" data-r="${r}" aria-pressed="${onMin}">Small screens</button>
743
+ <button class="btn small" type="button" data-act="ratio" data-which="max" data-r="${r}" aria-pressed="${onMax}">Large screens</button></div></div>`;
744
+ });
745
+ return `${pinnedCount && state.usePins ? `<div class="notice"><p>${pinnedCount} sizes are pinned in the overrides file, so scale changes don't move them. Turn pins off to see the math.</p>
746
+ <button class="btn small" type="button" data-act="pins-off">Turn pins off</button></div>` : ''}
747
+ <div class="panel"><span class="lbl">Compare ratios. Pick one for small screens and one for large; sizes ease between them.</span><div class="presets">${presets.join('')}</div></div>
748
+ <div class="panel"><span class="lbl">Every step at ${w}px wide</span>
749
+ <div class="table-wrap"><table class="type-table"><thead><tr><th>Step</th><th>Size</th><th>Line</th><th>Sample</th></tr></thead><tbody>${rows.join('')}</tbody></table></div>
750
+ </div>`;
751
+ }
752
+
753
+ function spaceHtml() {
754
+ const space = state.result.files.space.space;
755
+ // Integer-like keys sort first in a JS object, so order by size instead.
756
+ const rows = Object.entries(space).map(([s, t]) => [s, pxAt(t.$value, 0)]).sort((a, b) => a[1] - b[1]);
757
+ return `<div class="panel"><div class="bars">${rows.map(([s, px]) => `<div><span>${s}</span><span>${round(px, 1)}px</span><i style="width:${px}px"></i></div>`).join('')}</div></div>`;
758
+ }
759
+
760
+ function shapeHtml() {
761
+ const { radius } = state.result.files.radius;
762
+ const { stroke } = state.result.files.stroke;
763
+ return `<div class="panel"><span class="lbl">Radius</span><div class="tiles">${Object.entries(radius).map(([s, t]) => {
764
+ const px = pxAt(t.$value, 0);
765
+ return `<span class="tile"><i style="border-radius:${px}px"></i>${s} · ${round(px, 1)}px</span>`;
766
+ }).join('')}</div></div>
767
+ <div class="panel"><span class="lbl">Lines</span><div class="lines">${Object.entries(stroke).map(([s, t]) => {
768
+ const px = pxAt(t.$value, 0);
769
+ return `<div><span>${s} ${round(px, 1)}px</span><i style="height:${px}px"></i></div>`;
770
+ }).join('')}</div></div>`;
771
+ }
772
+
773
+ function shadowHtml() {
774
+ const { shadow } = state.result.files.shadow;
775
+ return `<div class="shadows">${Object.entries(shadow).filter(([s]) => s !== 'none').map(([s, t]) => `<div style="box-shadow:${t.$value}">${s}</div>`).join('')}</div>`;
776
+ }
777
+
778
+ function motionHtml() {
779
+ const { duration } = state.result.files.motion;
780
+ return `<div class="panel"><div class="row"><button class="btn small" type="button" data-act="play">Play</button><span class="muted">Each dot crosses in its duration.</span></div>
781
+ <div class="motion-rows">${Object.entries(duration).map(([s, t]) => `<div><span>${s} · ${t.$value}</span><span class="track" style="--w:100%"><i style="transition-duration:${t.$value}"></i></span></div>`).join('')}</div></div>`;
782
+ }
783
+
784
+ function fileHtml() {
785
+ const text = currentText();
786
+ const pins = Object.entries(state.overrides).filter(([k]) => !k.startsWith('$'));
787
+ return `<div class="band-body"><div class="edit">
788
+ <div class="panel"><h3>${text === state.savedText ? 'No unsaved changes' : 'Unsaved changes'}</h3>
789
+ <p class="muted">${state.static ? 'This copy can\'t write files. Copy the JSON into <code>brand/NAME.json</code>, or open the builder with <code>loomiere studio</code> to save directly.' : `Save writes <code>${esc(state.dir)}/${esc(state.name)}.json</code>${state.built ? ' and rebuilds the tokens' : ''}.`}</p>
790
+ <div class="row"><button class="btn" type="button" data-act="revert"${text === state.savedText ? ' disabled' : ''}>Undo all changes</button></div></div>
791
+ <div class="panel"><h3>Pins</h3><p class="muted">${pins.length ? `${pins.length} exact values sit on top of the math in <code>${esc(state.name)}.overrides.json</code>.` : 'No pins. Everything comes from the math.'}</p>
792
+ ${pins.length ? `<pre class="json">${esc(pins.map(([k, v]) => `${k}: ${v}`).join('\n'))}</pre>` : ''}
793
+ ${state.pinError ? `<div class="notice bad"><p>${esc(state.pinError)}. The preview is showing the math without pins.</p></div>` : ''}</div>
794
+ </div><pre class="json" id="brand-json" aria-label="Brand file">${esc(text)}</pre></div>`;
795
+ }
796
+
797
+ /* ---------- render ---------- */
798
+
799
+ const currentText = () => formatBrand({ $schema: state.schema, name: state.name, ...state.brand });
800
+
801
+ function stepNav() {
802
+ const i = STEPS.findIndex((st) => st.id === state.step);
803
+ const prev = STEPS[i - 1], next = STEPS[i + 1];
804
+ return `<div class="step-end">${prev ? `<button class="btn" type="button" data-act="step" data-step="${prev.id}">← ${prev.label}</button>` : '<span></span>'}
805
+ ${next ? `<button class="btn primary" type="button" data-act="step" data-step="${next.id}">Next: ${next.label} →</button>` : ''}</div>`;
806
+ }
807
+
808
+ function renderSteps() {
809
+ if (!STEPS.some((st) => st.id === state.step)) state.step = 'brand';
810
+ $('steps').innerHTML = STEPS.map((st, i) => `<button type="button" class="step${st.id === state.step ? ' on' : ''}" data-act="step" data-step="${st.id}" aria-current="${st.id === state.step ? 'step' : 'false'}"><b>${i + 1}</b> ${esc(st.label)}</button>`).join('');
811
+ const step = STEPS.find((st) => st.id === state.step);
812
+ $('rail').innerHTML = step.groups.map(([label, items]) => `<span class="rail-label">${esc(label)}</span>${items.map(([id, name]) => `<a href="#${id}">${esc(name)}</a>`).join('')}`).join('')
813
+ + '<span class="rail-label">Output</span><a href="#file">Brand file</a>';
814
+ }
815
+
816
+ function renderEditors() {
817
+ renderSteps();
818
+ $('main').innerHTML = editorsHtml() + stepNav();
819
+ // Every step keeps every input on the page (so previews stay wired); only this step's show.
820
+ for (const el of $('main').querySelectorAll('section.band')) el.hidden = el.id !== 'file' && stepOf(el.id) !== state.step;
821
+ renderPreviews();
822
+ }
823
+
824
+ function goStep(id) {
825
+ state.step = id;
826
+ try { localStorage.setItem('brand-builder-step', id); } catch { /* the step just won't be remembered */ }
827
+ renderEditors();
828
+ window.scrollTo({ top: 0 });
829
+ }
830
+
831
+ function renderPreviews() {
832
+ compute();
833
+ const error = $('status');
834
+ if (state.error) {
835
+ error.textContent = `The generator stopped: ${state.error}`;
836
+ error.className = 'status bad';
837
+ return;
838
+ }
839
+ if (error.classList.contains('bad')) { error.textContent = ''; error.className = 'status'; }
840
+ const put = (id, html) => { const el = $(id); if (el) el.innerHTML = html; };
841
+ put('pv-ends', endsHtml());
842
+ put('pv-tables', tablesHtml());
843
+ for (const r of [...BRAND_ROLES, ...ALERT_ROLES]) put(`pv-pal-${r}`, rampHtml(r));
844
+ put('pv-alert-sample', alertsHtml());
845
+ put('pv-data', dataHtml());
846
+ put('pv-pairs', pairsHtml());
847
+ put('pv-theme-out', rolesHtml());
848
+ put('pv-concepts', conceptsHtml());
849
+ put('pv-strip', Object.keys(state.brand.color.palettes).map((n) => `<span><i style="background:${hexAt(`color.${n}.600`) ?? 'transparent'}"></i>${esc(n)}</span>`).join(''));
850
+ put('pv-vision', visionHtml());
851
+ put('pv-type-out', typeHtml());
852
+ put('pv-space', spaceHtml());
853
+ put('pv-shape', shapeHtml());
854
+ put('pv-elevation', shadowHtml());
855
+ put('pv-motion', motionHtml());
856
+ put('pv-file', fileHtml());
857
+ loadFonts();
858
+ const dirty = currentText() !== state.savedText;
859
+ $('file-name').innerHTML = `${esc(state.name)}.json${dirty ? ' <span class="dirty">● unsaved</span>' : ''}`;
860
+ $('pins-label').textContent = `Pins (${Object.keys(state.overrides).filter((k) => !k.startsWith('$')).length})`;
861
+ }
862
+
863
+ let fontsLoaded = '';
864
+ /** Ascent, descent, cap and x-height in em for each family, read from the loaded font files. */
865
+ async function measureFonts() {
866
+ const generic = /^(serif|sans-serif|monospace|system-ui|cursive|fantasy|ui-\w+)$/;
867
+ const ctx = document.createElement('canvas').getContext('2d');
868
+ const metrics = {};
869
+ for (const [role, stack] of Object.entries(state.brand.type.families)) {
870
+ const family = stack.find((f) => !generic.test(f));
871
+ if (!family) continue;
872
+ const font = `400 1000px "${family}"`;
873
+ await document.fonts.load(font);
874
+ if (!document.fonts.check(font)) { status(`Couldn't load ${family} to measure it.`, true); continue; }
875
+ ctx.font = font;
876
+ const H = ctx.measureText('H'), x = ctx.measureText('x');
877
+ metrics[role] = {
878
+ ascent: round(H.fontBoundingBoxAscent / 1000, 3),
879
+ descent: round(H.fontBoundingBoxDescent / 1000, 3),
880
+ capHeight: round(H.actualBoundingBoxAscent / 1000, 3),
881
+ xHeight: round(x.actualBoundingBoxAscent / 1000, 3),
882
+ };
883
+ }
884
+ state.brand.type.metrics = { ...state.brand.type.metrics, ...metrics };
885
+ status(`Measured ${Object.keys(metrics).join(', ')}.`);
886
+ }
887
+
888
+ function loadFonts() {
889
+ const generic = /^(serif|sans-serif|monospace|system-ui|cursive|fantasy|ui-\w+)$/;
890
+ const names = [...new Set(Object.values(state.brand.type.families).flat().filter((f) => !generic.test(f)))];
891
+ const key = names.join('|');
892
+ if (!names.length || key === fontsLoaded) return;
893
+ fontsLoaded = key;
894
+ let link = document.getElementById('brand-fonts');
895
+ if (!link) { link = document.createElement('link'); link.rel = 'stylesheet'; link.id = 'brand-fonts'; document.head.append(link); }
896
+ link.href = `https://fonts.googleapis.com/css2?${names.map((n) => `family=${encodeURIComponent(n).replace(/%20/g, '+')}:wght@400;600;700`).join('&')}&display=swap`;
897
+ }
898
+
899
+ /* ---------- events ---------- */
900
+
901
+ let pending = 0;
902
+ const schedule = () => { cancelAnimationFrame(pending); pending = requestAnimationFrame(renderPreviews); };
903
+
904
+ function readInput(el) {
905
+ const kind = el.dataset.kind;
906
+ if (kind === 'number') return el.value === '' ? undefined : Number(el.value);
907
+ if (kind === 'bool') return el.checked;
908
+ if (kind === 'list-num') return el.value.split(/[,\s]+/).filter(Boolean).map(Number).filter((n) => !Number.isNaN(n));
909
+ if (kind === 'list-str') return el.value.split(',').map((s) => s.trim()).filter(Boolean);
910
+ if (kind === 'hex') return /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(el.value.trim()) ? el.value.trim().toLowerCase() : undefined;
911
+ return el.value;
912
+ }
913
+
914
+ document.addEventListener('input', (e) => {
915
+ const el = e.target;
916
+ if (el.id === 'width') { state.width = Number(el.value); $('width-out').textContent = `${el.value}px`; return schedule(); }
917
+ if (el.dataset.special === 'name') { state.name = el.value.toLowerCase().replace(/[^a-z0-9-]+/g, '-'); return schedule(); }
918
+ const bind = el.dataset.bind;
919
+ if (!bind || el.dataset.kind === 'bool' || el.dataset.kind === 'select') return;
920
+ const value = readInput(el);
921
+ if (value === undefined) return;
922
+ set(bind, value);
923
+ // Keep a color's picker and hex box in step.
924
+ document.querySelectorAll(`[data-bind="${CSS.escape(bind)}"]`).forEach((other) => {
925
+ if (other === el) return;
926
+ if (other.dataset.kind === 'color') other.value = toHex6(value);
927
+ if (other.dataset.kind === 'hex') other.value = value;
928
+ });
929
+ const out = document.querySelector(`[data-out="${CSS.escape(bind)}"]`);
930
+ if (out) out.textContent = bind.includes('tone') ? `${Math.round(value * 100)}%` : `${value}×`;
931
+ schedule();
932
+ });
933
+
934
+ document.addEventListener('change', (e) => {
935
+ const el = e.target;
936
+ if (el.id === 'pins') { state.usePins = el.checked; return renderPreviews(); }
937
+ if (el.id === 'brand-pick') return loadBrand(el.value);
938
+ if (el.id === 'data-goal') { state.dataGoal = Number(el.value); return renderPreviews(); }
939
+ if (el.id === 'vision-seen') { state.visionSeen = el.checked; return renderPreviews(); }
940
+ if (el.id === 'vision-sim') { document.body.dataset.vision = el.value; return; }
941
+ if (el.dataset.rename) return renameKey(el.dataset.rename, el.dataset.key, el.value.trim());
942
+ if (el.dataset.concept || el.dataset.conceptHex) {
943
+ const key = el.dataset.concept ?? el.dataset.conceptHex;
944
+ // "Hex…" starts from the color the row shows now.
945
+ const value = el.value === '#' ? conceptHex(...key.split('.'), el.dataset.mode === 'dark') : el.value.trim().toLowerCase();
946
+ if (el.dataset.conceptHex && pickRef(value, {}) === undefined) return status('A hex looks like #221928.', true);
947
+ setConcept(key, el.dataset.mode, value);
948
+ return renderEditors();
949
+ }
950
+ if (el.dataset.lock) {
951
+ const locked = new Set(get(el.dataset.lock) ?? []);
952
+ if (el.checked) locked.add(Number(el.dataset.key)); else locked.delete(Number(el.dataset.key));
953
+ set(el.dataset.lock, locked.size ? [...locked].sort((a, b) => a - b) : undefined);
954
+ return renderPreviews();
955
+ }
956
+ const bind = el.dataset.bind;
957
+ if (!bind) return;
958
+ if (bind.endsWith('@toggle')) {
959
+ const real = bind.replace('@toggle', '');
960
+ set(real, el.checked ? state.brand.color.ink : undefined);
961
+ return renderEditors();
962
+ }
963
+ if (bind.startsWith('roles.')) {
964
+ set(bind, readInput(el));
965
+ renderEditors();
966
+ flashUses(bind);
967
+ return;
968
+ }
969
+ if (el.dataset.kind === 'bool' || el.dataset.kind === 'select') {
970
+ // "exact" is off unless it says otherwise, so leave it out of the file when it's off.
971
+ // Optional settings ("Never", "exact" off) are left out of the file rather than saved empty.
972
+ const off = (bind.endsWith('.exact') && !el.checked) || (el.dataset.kind === 'select' && el.value === '');
973
+ set(bind, off ? undefined : readInput(el));
974
+ // A new step list changes the step menus.
975
+ return renderEditors();
976
+ }
977
+ // New step names change the step menus; a pinned vision color gets its "Use generated" button.
978
+ if (bind === 'type.steps' || bind.startsWith('color.vision.')) renderEditors();
979
+ });
980
+
981
+ function renameKey(bind, from, to) {
982
+ if (!/^[\w-]+$/.test(to) || to === from) return renderEditors();
983
+ const obj = get(bind);
984
+ if (obj[to] !== undefined) return status(`There is already a step called ${to}.`, true);
985
+ set(bind, Object.fromEntries(Object.entries(obj).map(([k, v]) => [k === from ? to : k, v])));
986
+ renderEditors();
987
+ }
988
+
989
+ document.addEventListener('click', (e) => {
990
+ const b = e.target.closest('button[data-act]');
991
+ if (!b) return;
992
+ const { act, role, i, bind, key } = b.dataset;
993
+ const pal = role && state.brand.color.palettes[role];
994
+ const data = state.brand.color.data;
995
+ switch (act) {
996
+ case 'step': return goStep(b.dataset.step);
997
+ case 'concept-reset': {
998
+ const picks = state.brand.roles?.concepts;
999
+ if (picks) { delete picks[key]; if (!Object.keys(picks).length) delete state.brand.roles.concepts; }
1000
+ break;
1001
+ }
1002
+ case 'key-add': (pal.keys ??= []).push(pal.keys.at(-1) ?? '#888888'); break;
1003
+ case 'key-remove': pal.keys.splice(Number(i), 1); break;
1004
+ case 'tone-add': pal.tone = { color: pal.keys?.[0] ?? '#888888', intensity: [0.1, 0.6] }; break;
1005
+ case 'tone-remove': delete pal.tone; break;
1006
+ case 'data-add': data.push([`color-${data.length + 1}`, '#888888']); break;
1007
+ case 'data-remove': data.splice(Number(i), 1); break;
1008
+ case 'data-up': [data[i - 1], data[i]] = [data[i], data[i - 1]]; break;
1009
+ case 'data-fix': data[i][1] = b.dataset.hex; break;
1010
+ case 'data-fix-all': fixAllData(); break;
1011
+ case 'data-suggest': {
1012
+ b.disabled = true;
1013
+ b.textContent = 'Looking…';
1014
+ // Let the button repaint before the search runs.
1015
+ return setTimeout(() => { suggestDataFixes(); renderPreviews(); }, 30);
1016
+ }
1017
+ case 'map-add': {
1018
+ const obj = get(bind);
1019
+ let n = Object.keys(obj).length + 1;
1020
+ while (obj[`step-${n}`] !== undefined) n++;
1021
+ obj[`step-${n}`] = Object.values(obj).at(-1) ?? 1;
1022
+ break;
1023
+ }
1024
+ case 'map-remove': delete get(bind)[key]; break;
1025
+ case 'ratio': set(['type', 'ratio', b.dataset.which], Number(b.dataset.r)); break;
1026
+ case 'vision-open': state.visionOpen = state.visionOpen === b.dataset.theme ? null : b.dataset.theme; break;
1027
+ case 'vision-unpin': {
1028
+ const v = state.brand.color.vision;
1029
+ const { theme, kind } = b.dataset;
1030
+ delete v[theme][kind][key];
1031
+ if (!Object.keys(v[theme][kind]).length) delete v[theme][kind];
1032
+ if (!Object.keys(v[theme]).length) delete v[theme];
1033
+ if (!Object.keys(v).length) delete state.brand.color.vision;
1034
+ break;
1035
+ }
1036
+ case 'measure-fonts': return measureFonts().then(renderEditors);
1037
+ case 'pins-off': state.usePins = false; $('pins').checked = false; return renderPreviews();
1038
+ case 'revert': state.brand = JSON.parse(state.savedText); delete state.brand.$schema; delete state.brand.name; break;
1039
+ case 'play': {
1040
+ const tracks = document.querySelectorAll('.track');
1041
+ const going = tracks[0]?.classList.contains('go');
1042
+ tracks.forEach((t) => t.classList.toggle('go', !going));
1043
+ return;
1044
+ }
1045
+ default: return;
1046
+ }
1047
+ renderEditors();
1048
+ });
1049
+
1050
+ /* ---------- load and save ---------- */
1051
+
1052
+ function status(text, bad = false) {
1053
+ $('status').textContent = text;
1054
+ $('status').className = `status${bad ? ' bad' : ''}`;
1055
+ }
1056
+
1057
+ function take(data) {
1058
+ state.name = data.name;
1059
+ state.dir = data.dir ?? 'brand';
1060
+ state.schema = data.schema ?? 'https://unpkg.com/@scattered-light/loomiere/generator/brand.schema.json';
1061
+ state.brands = data.brands;
1062
+ state.built = data.built;
1063
+ state.overrides = data.overrides ?? {};
1064
+ state.uses = data.uses ?? {};
1065
+ // The page owns $schema and name (the file name field), so the brand keeps only the rest.
1066
+ const brand = { ...data.brand };
1067
+ delete brand.$schema;
1068
+ delete brand.name;
1069
+ state.brand = brand;
1070
+ state.savedText = currentText();
1071
+ state.width = brand.type.viewport.max;
1072
+ $('brand-pick').innerHTML = data.brands.map((b) => `<option${b === data.name ? ' selected' : ''}>${esc(b)}</option>`).join('');
1073
+ $('brand-pick').hidden = state.static;
1074
+ $('save').hidden = state.static;
1075
+ renderEditors();
1076
+ }
1077
+
1078
+ async function loadBrand(name) {
1079
+ if (state.brand && currentText() !== state.savedText) {
1080
+ status(`Switched without saving ${state.name}; its changes are gone.`);
1081
+ }
1082
+ const res = await fetch(`/api/brand?name=${encodeURIComponent(name)}`);
1083
+ const data = await res.json();
1084
+ if (!res.ok) return status(data.error, true);
1085
+ take(data);
1086
+ }
1087
+
1088
+ $('save').onclick = async () => {
1089
+ if (state.error) return status('Fix the problem above before saving.', true);
1090
+ $('save').disabled = true;
1091
+ status('Saving…');
1092
+ try {
1093
+ const res = await fetch('/api/brand', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: state.name, brand: state.brand }) });
1094
+ const body = await res.json();
1095
+ if (!body.ok) throw new Error(body.error);
1096
+ state.savedText = currentText();
1097
+ if (!state.brands.includes(state.name)) { state.brands.push(state.name); $('brand-pick').innerHTML += `<option selected>${esc(state.name)}</option>`; }
1098
+ status(body.output);
1099
+ renderPreviews();
1100
+ } catch (err) {
1101
+ status(`Not saved: ${err.message}`, true);
1102
+ } finally {
1103
+ $('save').disabled = false;
1104
+ }
1105
+ };
1106
+
1107
+ $('copy').onclick = async () => {
1108
+ try {
1109
+ await navigator.clipboard.writeText(currentText());
1110
+ status(`Copied ${state.name}.json to the clipboard.`);
1111
+ } catch {
1112
+ const pre = $('brand-json');
1113
+ if (pre) { const r = document.createRange(); r.selectNodeContents(pre); getSelection().removeAllRanges(); getSelection().addRange(r); pre.scrollIntoView({ block: 'center' }); }
1114
+ status('Selected the JSON. Press copy on your keyboard.');
1115
+ }
1116
+ };
1117
+
1118
+ if (state.static) take(clone(window.BRAND_DATA));
1119
+ else loadBrand(new URLSearchParams(location.search).get('brand') ?? '').catch(() => status('Start the builder with loomiere studio, then open the address it prints.', true));