@threadlabs/looma 0.35.1 → 0.37.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 (60) hide show
  1. package/components/ui-avatar/ui-avatar.html +1 -1
  2. package/components/ui-avatar-group/ui-avatar-group.html +1 -1
  3. package/components/ui-button/ui-button.html +11 -4
  4. package/components/ui-color-picker/ui-color-picker.html +1 -1
  5. package/components/ui-combobox/ui-combobox.html +1 -1
  6. package/components/ui-curve-editor/ui-curve-editor.html +1 -0
  7. package/components/ui-curve-editor/ui-curve-editor.js +15 -6
  8. package/components/ui-input/ui-input.html +4 -1
  9. package/components/ui-light-editor/ui-light-editor.html +143 -19
  10. package/components/ui-light-editor/ui-light-editor.js +26 -9
  11. package/components/ui-select/ui-select.html +10 -6
  12. package/dist/index.js +1 -1
  13. package/docs/README.md +33 -7
  14. package/docs/card.js +1 -0
  15. package/docs/catalog.json +39 -5
  16. package/docs/customizer-render.mjs +27 -9
  17. package/docs/customizer.js +146 -18
  18. package/docs/fonts.mjs +45 -0
  19. package/docs/index.d.ts +2 -2
  20. package/docs/palette.mjs +67 -0
  21. package/docs/site.mjs +1 -1
  22. package/docs/theme-session.mjs +28 -9
  23. package/docs/theme.mjs +67 -26
  24. package/docs/value-editors.mjs +6 -3
  25. package/package.json +2 -2
  26. package/styles/ui-avatar-group.css +1 -1
  27. package/styles/ui-avatar.css +1 -1
  28. package/styles/ui-button.css +11 -4
  29. package/styles/ui-color-picker.css +1 -1
  30. package/styles/ui-combobox.css +2 -2
  31. package/styles/ui-input.css +4 -1
  32. package/styles/ui-light-editor.css +111 -16
  33. package/styles/ui-select.css +10 -6
  34. package/theme-system.css +17 -4
  35. package/tokens.css +28 -15
  36. package/vanilla/UiCurveEditor.d.ts +1 -0
  37. package/vanilla/UiCurveEditor.js +1 -1
  38. package/vanilla/UiLightEditor.d.ts +1 -0
  39. package/vanilla/UiLightEditor.js +2 -2
  40. package/vue/UiAvatar.vue +1 -1
  41. package/vue/UiAvatarGroup.vue +1 -1
  42. package/vue/UiButton.vue +4 -2
  43. package/vue/UiColorPicker.vue +1 -1
  44. package/vue/UiCombobox.vue +2 -2
  45. package/vue/UiCurveEditor.d.ts +6 -0
  46. package/vue/UiCurveEditor.vue +2 -0
  47. package/vue/UiInput.vue +4 -1
  48. package/vue/UiLightEditor.d.ts +8 -0
  49. package/vue/UiLightEditor.vue +22 -3
  50. package/vue/UiSelect.vue +1 -1
  51. package/vue/chunks/UiAvatar.js +1 -1
  52. package/vue/chunks/UiAvatarGroup.js +1 -1
  53. package/vue/chunks/UiButton.js +1 -1
  54. package/vue/chunks/UiColorPicker.js +1 -1
  55. package/vue/chunks/UiCombobox.js +1 -1
  56. package/vue/chunks/UiCurveEditor.js +5 -3
  57. package/vue/chunks/UiInput.js +1 -1
  58. package/vue/chunks/UiLightEditor.js +16 -6
  59. package/vue/chunks/UiSelect.js +1 -1
  60. package/vue/components.css +43 -16
@@ -1,6 +1,7 @@
1
+ import { googleFontStack, systemFont, loadGoogleFonts } from './fonts.mjs';
1
2
  import { documentationTheme, themeEvent } from './theme-session.mjs';
2
3
  import { componentRegistry } from '@nextwebwg/html-next/runtime';
3
- import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme } from './theme.mjs';
4
+ import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme, startingPointLabels, nearbyVariation } from './theme.mjs';
4
5
 
5
6
  import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
6
7
 
@@ -21,15 +22,19 @@ export function connect(host) {
21
22
  let alive = true;
22
23
  let selectedExample = '';
23
24
  let refinementTrigger;
25
+ let previewFrame = 0, pending = false, comparing = false, candidate, gestureStart;
26
+ const entryRecipe = structuredClone(recipe);
24
27
  const abort = new AbortController();
25
28
  const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
26
29
  const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
27
30
  const fields = new Map(themeFields.map(field => [field.id, field]));
28
31
  const unitContext = () => ({ remPixels: parseFloat(view.getComputedStyle(document.documentElement).fontSize) });
32
+ const editorFor = (field, value) => valueEditor(field, value, { remPixels: parseFloat(recipe.values['text-size']) });
33
+ let boundsBase;
29
34
  const updateControl = (element, value) => {
30
35
  const field = fields.get(element.dataset.token);
31
36
  if (!field || !element.dataset.editorKind) { componentRegistry.update(element, { value }); return; }
32
- const editor = valueEditor(field, value, unitContext());
37
+ const editor = editorFor(field, value);
33
38
  if (element.dataset.editorKind === 'curve') {
34
39
  element.closest('[data-curve-for]').hidden = editor.kind !== 'curve';
35
40
  if (editor.kind === 'curve') componentRegistry.update(element, { value });
@@ -37,13 +42,33 @@ export function connect(host) {
37
42
  };
38
43
  const updateUnits = () => {
39
44
  for (const control of root.querySelectorAll('[data-unit-for]')) {
40
- const field = fields.get(control.dataset.unitFor), editor = valueEditor(field, recipe.values[field.id], unitContext());
45
+ const field = fields.get(control.dataset.unitFor), editor = editorFor(field, recipe.values[field.id]);
41
46
  if (![...control.options].some(option => option.value === editor.unit)) control.add(new view.Option(editor.unit, editor.unit));
42
47
  componentRegistry.update(control, { value: editor.unit });
43
48
  }
44
49
  };
50
+ const updateSizes = () => {
51
+ const samples = [...root.querySelectorAll('[data-size-sample]')].filter(sample => sample.getClientRects().length);
52
+ if (!samples.length) return;
53
+ const base = parseFloat(view.getComputedStyle(document.documentElement).fontSize);
54
+ const rounded = value => Number(value.toFixed(2));
55
+ for (const sample of samples) {
56
+ const button = sample.querySelector('button');
57
+ if (button) {
58
+ const height = button.getBoundingClientRect().height;
59
+ sample.querySelector('[data-size-readout]').textContent = ` · ${rounded(height / base)}rem (${rounded(height)}px)`;
60
+ }
61
+ }
62
+ };
45
63
  const paint = () => {
46
64
  const css = theme?.css || themeCSS(recipe);
65
+ if (boundsBase !== recipe.values['text-size']) {
66
+ boundsBase = recipe.values['text-size'];
67
+ for (const control of root.querySelectorAll('[data-token]')) {
68
+ const field = fields.get(control.dataset.token);
69
+ if (field?.type.includes('length') && field.id !== 'text-size') updateControl(control, recipe.values[field.id]);
70
+ }
71
+ }
47
72
  for (const family of ['material','palette']) {
48
73
  const state = refinementState(recipe, family), presets = family === 'material' ? materialPresets : themePalettes;
49
74
  for (const select of root.querySelectorAll(`[data-refinement-preset="${family}"]`)) {
@@ -52,13 +77,25 @@ export function connect(host) {
52
77
  }
53
78
  componentRegistry.update(root.querySelector(`[data-action="restore-${family}"]`), { disabled: state.id === 'custom' || !state.customized });
54
79
  }
80
+ for (const control of root.querySelectorAll('[data-font-picker]')) componentRegistry.update(control, { value: recipe.fonts?.[control.dataset.fontPicker] ?? (recipe.values[control.dataset.fontPicker] === systemFont ? 'system' : 'custom') });
55
81
  componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
56
82
  const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
57
83
  const report = root.querySelector('[data-palette-adjustments]');
58
84
  report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your seed colors stay editable; resolved values are shown below.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
59
85
  root.querySelector('[data-palette-adjustment-details]').textContent = adjustments.length ? adjustments.map(item => `${item.mode} ${fields.get(item.id).label.toLowerCase()} ${item.requested} → ${item.resolved}`).join('; ') + '. Seed colors remain editable.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
60
- for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
61
- updateControl(root.querySelector('[data-css]'), css);
86
+ for (const card of root.querySelectorAll('[data-example-card]')) if (card.getClientRects().length) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
87
+ const cssControl = root.querySelector('[data-css]');
88
+ if (cssControl.getClientRects().length) updateControl(cssControl, css);
89
+ for (const action of ['undo','redo']) componentRegistry.update(root.querySelector(`[data-action="${action}"]`), { disabled: !theme?.[action === 'undo' ? 'canUndo' : 'canRedo'] });
90
+ const origin = recipe.origin ?? startingPointFor(recipe);
91
+ root.querySelector('[data-theme-origin]').textContent = startingPointLabels[origin] ? `${startingPointLabels[origin]}${startingPointFor(recipe) === origin ? '' : ' · customized'}` : 'Your theme';
92
+ root.querySelector('[data-material-dependencies]').textContent = Number(recipe.values['light-strength']) === 0
93
+ ? 'Light strength is zero: reflections, glow and bright edge catches are off. Depth shadows and the dark edge remain; depth still follows the source direction.'
94
+ : Number(recipe.values.finish) === 0 ? 'This material is matte: light spread has no reflection to shape. Raise Finish or choose Polished to see it; depth and illuminated edges remain independent.'
95
+ : 'Finish catches the light. Spread shapes its reflection; strength affects reflection, edge light and glow together.';
96
+ const gaps = root.querySelector('[data-spacing-samples]');
97
+ gaps.setAttribute('aria-label', `Spacing progression with curve ${recipe.values['space-curve']}`);
98
+ updateSizes();
62
99
  };
63
100
  const show = (next, persist = true) => {
64
101
  recipe = validateRecipe(next);
@@ -69,10 +106,38 @@ export function connect(host) {
69
106
  changed = JSON.stringify(recipe) !== JSON.stringify(saved);
70
107
  paint();
71
108
  };
109
+ const clearComparison = () => {
110
+ if (!comparing && !candidate) return;
111
+ comparing = false; candidate = undefined; theme?.compare();
112
+ root.querySelector('[data-action="compare"]').setAttribute('aria-pressed', 'false');
113
+ root.querySelector('[data-variation-confirm]').hidden = true;
114
+ };
115
+ const preview = next => {
116
+ const validated = validateRecipe(next);
117
+ if (JSON.stringify(validated.values) === JSON.stringify(recipe.values)) return;
118
+ clearComparison();
119
+ if (!pending) gestureStart = structuredClone(recipe);
120
+ recipe = validated; pending = true;
121
+ if (!previewFrame) previewFrame = view.requestAnimationFrame(() => {
122
+ previewFrame = 0;
123
+ theme?.preview(recipe);
124
+ show(recipe, false);
125
+ });
126
+ };
127
+ const flush = (commit = true) => {
128
+ if (previewFrame) view.cancelAnimationFrame(previewFrame);
129
+ previewFrame = 0;
130
+ if (!pending) return;
131
+ pending = false;
132
+ const netChange = JSON.stringify(recipe.values) !== JSON.stringify(gestureStart.values);
133
+ if (commit && netChange) theme?.apply(recipe); else theme?.cancel();
134
+ show(theme?.recipe ?? (netChange && commit ? recipe : gestureStart), false);
135
+ gestureStart = undefined;
136
+ };
72
137
  const filterExamples = () => {
73
138
  const words = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
74
139
  let visible = 0;
75
- for (const card of root.querySelectorAll('[data-example-card]')) {
140
+ for (const card of root.querySelectorAll('[data-customizer-overview] .examples [data-example-card]')) {
76
141
  const matches = words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
77
142
  card.closest('section').hidden = !matches;
78
143
  if (matches) visible++;
@@ -86,14 +151,16 @@ export function connect(host) {
86
151
  const state = refinementState(recipe, family);
87
152
  if (!presets[family] && state.id !== 'custom') presets[family] = state.id;
88
153
  }
89
- return { ...recipe, presets, values };
154
+ const origin = recipe.origin ?? (startingPointFor(recipe) === 'custom' ? undefined : startingPointFor(recipe));
155
+ const fonts = { ...recipe.fonts };
156
+ for (const key of Object.keys(fonts)) if (values[key] !== googleFontStack(fonts[key])) delete fonts[key];
157
+ return { ...recipe, fonts, ...(origin ? { origin } : {}), presets, values };
90
158
  };
91
159
  const input = event => {
92
160
  if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
93
161
  if (event.target.matches('[data-light-editor]')) {
94
162
  try {
95
- show(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
96
- sayApplied('Light position remembered throughout your design system.');
163
+ preview(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
97
164
  } catch (error) { say(error.message, true); }
98
165
  return;
99
166
  }
@@ -104,13 +171,12 @@ export function connect(host) {
104
171
  const value = control.dataset.editorKind ? editorValue(field, raw) : String(raw);
105
172
  const next = refinedRecipe({ ...recipe.values, [control.dataset.token]: value });
106
173
  try {
107
- recipe = validateRecipe(next);
108
- theme?.apply(recipe); changed = true;
174
+ preview(next); changed = JSON.stringify(recipe) !== JSON.stringify(saved);
109
175
  updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
110
176
  control.removeAttribute('aria-invalid');
111
177
  for (const sibling of root.querySelectorAll('[data-token]')) if (sibling.dataset.token === control.dataset.token) updateControl(sibling, value);
112
178
  updateUnits();
113
- paint(); sayApplied('Theme remembered in this browser. Download it or save it to your repository.');
179
+ say('Previewing your theme. Finish the edit to remember it.');
114
180
  } catch (error) { control.setAttribute('aria-invalid', 'true'); say(error.message, true); }
115
181
  };
116
182
  const download = (data, name, type) => {
@@ -120,22 +186,51 @@ export function connect(host) {
120
186
  };
121
187
  const click = async event => {
122
188
  const button = event.target.closest?.('[data-action]');
123
- if (!button || (button.dataset.action === 'save' && saving)) return;
189
+ if (!button) { view.requestAnimationFrame(paint); return; }
190
+ if (button.dataset.action === 'save' && saving) return;
191
+ flush();
192
+ if (!['compare', 'preview-variation', 'apply-variation', 'cancel-variation'].includes(button.dataset.action)) clearComparison();
124
193
  try {
125
194
  switch (button.dataset.action) {
195
+ case 'undo': case 'redo':
196
+ theme?.[button.dataset.action](); show(theme?.recipe ?? recipe, false); sayApplied('Previous theme decision restored.'); break;
197
+ case 'compare':
198
+ candidate = undefined; comparing = !comparing; theme?.compare(comparing ? entryRecipe : undefined);
199
+ button.setAttribute('aria-pressed', String(comparing)); root.querySelector('[data-variation-confirm]').hidden = true;
200
+ say(comparing ? 'Comparing with the theme when you entered. Your edits remain intact. Choose Compare again to return.' : 'Back to your edited theme.'); break;
201
+ case 'preview-variation': {
202
+ clearComparison();
203
+ const locks = [...root.querySelectorAll('[data-family-lock]')].filter(control => control.querySelector('input')?.checked).map(control => control.dataset.familyLock);
204
+ candidate = nearbyVariation(recipe, root.querySelector('[data-variation-choice]').value, locks);
205
+ theme?.compare(candidate); root.querySelector('[data-variation-confirm]').hidden = false;
206
+ const differences = themeFields.filter(field => recipe.values[field.id] !== candidate.values[field.id]);
207
+ root.querySelector('[data-variation-description]').textContent = differences.length ? differences.map(field => `${field.label}: ${recipe.values[field.id]} → ${candidate.values[field.id]}`).join('; ') : 'No changes: the relevant families are locked or already at their bounds.';
208
+ break;
209
+ }
210
+ case 'apply-variation': {
211
+ const selected = candidate; clearComparison();
212
+ if (selected) show(selected); sayApplied('Variation applied. Undo returns to your previous theme.'); break;
213
+ }
214
+ case 'cancel-variation': clearComparison(); paint(); say('Variation cancelled; your theme is unchanged.'); break;
215
+ case 'light-top': case 'light-left': case 'light-center': {
216
+ const positions = { 'light-top': [.5, 0], 'light-left': [.15, .15], 'light-center': [.5, .5] };
217
+ const [x, y] = positions[button.dataset.action];
218
+ show(refinedRecipe({ ...recipe.values, 'light-x': String(x), 'light-y': String(y) }));
219
+ sayApplied('Light direction applied throughout your theme.'); break;
220
+ }
126
221
  case 'refine-material': case 'refine-palette': {
127
222
  const family = button.dataset.action.slice(7);
128
223
  refinementTrigger = button;
129
224
  root.querySelector('[data-customizer-overview]').hidden = true;
130
225
  for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = section.dataset.refinementView !== family;
131
226
  const section = root.querySelector(`[data-refinement-view="${family}"]`);
132
- section.focus(); section.scrollIntoView({ block: 'start' });
227
+ paint(); section.focus(); section.scrollIntoView({ block: 'start' });
133
228
  break;
134
229
  }
135
230
  case 'back':
136
231
  for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = true;
137
232
  root.querySelector('[data-customizer-overview]').hidden = false;
138
- refinementTrigger?.focus(); break;
233
+ paint(); refinementTrigger?.focus(); break;
139
234
  case 'restore-material': case 'restore-palette': {
140
235
  const family = button.dataset.action.slice(8), state = refinementState(recipe, family);
141
236
  if (state.id !== 'custom') { show(applyRefinement(recipe, family, state.id)); sayApplied(`${family === 'material' ? 'Material' : 'Palette'} preset restored; other theme decisions are unchanged.`); }
@@ -177,6 +272,10 @@ export function connect(host) {
177
272
  finally { if (button.dataset.action === 'save') saving = false; }
178
273
  };
179
274
  const change = async event => {
275
+ if (event.target.matches('[data-light-editor]') || event.target.closest?.('[data-token]')) {
276
+ input(event); flush(); sayApplied('Theme remembered in this browser. Undo restores the previous decision.'); return;
277
+ }
278
+ flush(); clearComparison();
180
279
  if (event.target.matches('[data-unit-for]')) {
181
280
  const field = fields.get(event.target.dataset.unitFor);
182
281
  try {
@@ -192,7 +291,7 @@ export function connect(host) {
192
291
  show(applyRefinement(recipe, family, event.target.value));
193
292
  sayApplied(`${family === 'material' ? 'Material' : 'Coordinated palette'} preset applied and remembered in both modes.`);
194
293
  }
195
- if (event.target.matches('[data-preset]')) { show(structuredClone(startingPoints[event.target.value])); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
294
+ if (event.target.matches('[data-preset]')) { show({ ...structuredClone(startingPoints[event.target.value]), origin: event.target.value }); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
196
295
  if (event.target === file && file.files[0]) {
197
296
  try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
198
297
  catch (error) { say(error.message, true); }
@@ -200,23 +299,52 @@ export function connect(host) {
200
299
  }
201
300
  };
202
301
  const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
203
- const synchronize = () => { if (theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
302
+ const synchronize = () => { if (!pending && theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
303
+ const cancel = event => { if (event.target.matches('[data-light-editor], [data-editor-kind="curve"]')) { flush(false); say('Drag cancelled; the previous value is restored.'); } };
304
+ const keyboard = event => {
305
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z' || event.target.closest?.('input,textarea,select,[contenteditable="true"]')) return;
306
+ event.preventDefault(); flush(); clearComparison(); theme?.[event.shiftKey ? 'redo' : 'undo'](); show(theme?.recipe ?? recipe, false);
307
+ };
204
308
  const ready = event => {
205
309
  if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
206
310
  };
207
311
  const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); view.requestAnimationFrame(filterExamples); };
312
+ let fontRequest = 0;
313
+ const selectFont = async event => {
314
+ const control = event.target.closest?.('[data-font-picker]'); if (!control) return;
315
+ flush(); clearComparison();
316
+ const id = control.dataset.fontPicker, family = event.detail.value;
317
+ const fonts = { ...recipe.fonts };
318
+ if (family === 'system') delete fonts[id]; else fonts[id] = family;
319
+ const next = { ...refinedRecipe({ ...recipe.values, [id]: family === 'system' ? systemFont : googleFontStack(family) }), fonts };
320
+ const status = root.querySelector(`[data-font-status="${id}"]`), request = ++fontRequest;
321
+ show(next); status.textContent = family === 'system' ? 'Using your operating system’s native font.' : `Loading ${family} from Google Fonts…`;
322
+ try {
323
+ await loadGoogleFonts(document, fonts);
324
+ if (alive && request === fontRequest) for (const [field, selected] of Object.entries(fonts)) {
325
+ root.querySelector(`[data-font-status="${field}"]`).textContent = `${selected} loaded. Included in downloaded and locally saved CSS.`;
326
+ }
327
+ } catch (error) {
328
+ if (alive && request === fontRequest) for (const field of Object.keys(fonts)) root.querySelector(`[data-font-status="${field}"]`).textContent = error.message;
329
+ }
330
+ };
208
331
  const selectExample = event => {
209
332
  if (!event.target.matches('[data-add-example]')) return;
210
333
  selectedExample = event.detail.value ?? '';
211
334
  filterExamples();
212
335
  };
336
+ root.addEventListener('toggle', updateSizes, true);
213
337
  root.addEventListener('looma-example-remove', remove);
214
338
  root.addEventListener('input', input);
339
+ root.addEventListener('cancel', cancel);
340
+ root.addEventListener('keydown', keyboard);
215
341
  root.addEventListener('value-change', selectExample);
342
+ root.addEventListener('value-change', selectFont);
216
343
  root.addEventListener('blur', blur, true);
217
344
  root.addEventListener('change', change);
218
345
  root.addEventListener('click', click);
219
346
  view.addEventListener('message', ready);
347
+ view.addEventListener('beforeunload', flush);
220
348
  view.addEventListener(themeEvent, synchronize);
221
349
  show(recipe, false); filterExamples();
222
350
  if (theme?.storageAvailable === false) sayApplied('');
@@ -230,5 +358,5 @@ export function connect(host) {
230
358
  root.querySelector('[data-action="save"]').hidden = false;
231
359
  }).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
232
360
  }
233
- return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('value-change', selectExample); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
361
+ return () => { root.removeEventListener('toggle', updateSizes, true); flush(); clearComparison(); root.removeEventListener('cancel', cancel); root.removeEventListener('keydown', keyboard); alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('value-change', selectExample); root.removeEventListener('value-change', selectFont); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('beforeunload', flush); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
234
362
  }
package/docs/fonts.mjs ADDED
@@ -0,0 +1,45 @@
1
+ /** A focused searchable Google Fonts collection; system fonts never require a request. */
2
+ export const googleFonts = Object.fromEntries([
3
+ ['Inter', 'sans-serif'], ['Roboto', 'sans-serif'], ['Open Sans', 'sans-serif'],
4
+ ['Source Sans 3', 'sans-serif'], ['Lato', 'sans-serif'], ['Nunito Sans', 'sans-serif'],
5
+ ['DM Sans', 'sans-serif'], ['Manrope', 'sans-serif'], ['Work Sans', 'sans-serif'],
6
+ ['IBM Plex Sans', 'sans-serif'], ['Public Sans', 'sans-serif'], ['Atkinson Hyperlegible', 'sans-serif'],
7
+ ['Outfit', 'sans-serif'], ['Merriweather', 'serif'], ['Lora', 'serif'],
8
+ ['Source Serif 4', 'serif'], ['Libre Baskerville', 'serif'], ['Crimson Pro', 'serif'],
9
+ ['EB Garamond', 'serif'], ['Playfair Display', 'serif'], ['Roboto Mono', 'monospace'],
10
+ ['IBM Plex Mono', 'monospace'], ['JetBrains Mono', 'monospace'], ['Fira Code', 'monospace'],
11
+ ].map(([family, category]) => [family, { family, category, weights: ['Lato', 'Atkinson Hyperlegible', 'Libre Baskerville'].includes(family) ? '400;700' : '400;500;600;700' }]));
12
+
13
+ export const systemFont = 'system-ui, sans-serif';
14
+ export const googleFontStack = family => `"${family}", ${googleFonts[family].category}`;
15
+ export function googleFontURL(fonts = {}) {
16
+ const families = [...new Set(Object.values(fonts))].sort();
17
+ if (!families.length) return '';
18
+ return 'https://fonts.googleapis.com/css2?' + families.map(family => {
19
+ if (!Object.hasOwn(googleFonts, family)) throw new Error('Choose a supported Google font.');
20
+ return 'family=' + encodeURIComponent(family).replaceAll('%20', '+') + ':wght@' + googleFonts[family].weights;
21
+ }).join('&') + '&display=swap';
22
+ }
23
+
24
+ /** Load and confirm actual faces before reporting a selected font as ready. */
25
+ const fontLoads = new WeakMap();
26
+ export async function loadGoogleFonts(document, fonts) {
27
+ const url = googleFontURL(fonts);
28
+ let link = document.head.querySelector('link[data-looma-google-fonts]');
29
+ if (!url) { fontLoads.get(link)?.cancel(); link?.remove(); return; }
30
+ if (link?.href !== url) {
31
+ fontLoads.get(link)?.cancel(); link?.remove(); link = document.createElement('link');
32
+ link.rel = 'stylesheet'; link.dataset.loomaGoogleFonts = '';
33
+ let cancel;
34
+ const loaded = new Promise((resolve, reject) => {
35
+ cancel = () => resolve(false);
36
+ link.onload = () => resolve(true); link.onerror = () => reject(new Error('The Google font could not load. Check your connection or select the system font.'));
37
+ });
38
+ fontLoads.set(link, { loaded, cancel }); link.href = url; document.head.append(link);
39
+ }
40
+ if (await fontLoads.get(link)?.loaded === false) return;
41
+ for (const family of new Set(Object.values(fonts))) {
42
+ const faces = await document.fonts.load(`400 16px "${family}"`);
43
+ if (!faces.length) throw new Error(`${family} is unavailable; the fallback is currently displayed.`);
44
+ }
45
+ }
package/docs/index.d.ts CHANGED
@@ -17,8 +17,8 @@ export interface DocumentationCatalog {
17
17
  resources?: { tag: string; package: string; resource: string }[]; components: DocumentationComponent[];
18
18
  }
19
19
  export interface DiscoveryRoot { directory: string; packageName: string; groups?: DocumentationGroup[] }
20
- export interface ThemeRecipe { schemaVersion: 1; values: Record<string, string>; presets?: Partial<Record<'material' | 'palette', string>> }
21
- export interface ThemeField { family: string; id: string; label: string; property: string; type: string; defaultValue: string; description: string; min?: number; max?: number }
20
+ export interface ThemeRecipe { schemaVersion: 1; values: Record<string, string>; origin?: string; fonts?: Partial<Record<'body-font' | 'display-font', string>>; presets?: Partial<Record<'material' | 'palette', string>> }
21
+ export interface ThemeField { family: string; id: string; label: string; property: string | null; type: string; defaultValue: string; description: string; min?: number; max?: number; choices?: Record<string, string> }
22
22
  export interface DocumentationConfiguration {
23
23
  title?: string; base?: string; origin?: string; outDir?: string; content?: string;
24
24
  logo?: string | { light: string; dark?: string }; nav?: { label: string; href: string }[];
@@ -0,0 +1,67 @@
1
+ // Perceptual palette operations use Oklab/OKLCH; output remains portable sRGB hex.
2
+ const linear = value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4;
3
+ const encoded = value => value <= .0031308 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - .055;
4
+ const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));
5
+ export function toLch(hex) {
6
+ const [r, g, b] = [1, 3, 5].map(i => linear(parseInt(hex.slice(i, i + 2), 16) / 255));
7
+ const l = Math.cbrt(.4122214708 * r + .5363325363 * g + .0514459929 * b);
8
+ const m = Math.cbrt(.2119034982 * r + .6806995451 * g + .1073969566 * b);
9
+ const s = Math.cbrt(.0883024619 * r + .2817188376 * g + .6299787005 * b);
10
+ const a = 1.9779984951 * l - 2.428592205 * m + .4505937099 * s;
11
+ const bb = .0259040371 * l + .7827717662 * m - .808675766 * s;
12
+ return [.2104542553 * l + .793617785 * m - .0040720468 * s, Math.hypot(a, bb), (Math.atan2(bb, a) * 180 / Math.PI + 360) % 360];
13
+ }
14
+ const channels = ([lightness, chroma, hue]) => {
15
+ const a = chroma * Math.cos(hue * Math.PI / 180), b = chroma * Math.sin(hue * Math.PI / 180);
16
+ const l = (lightness + .3963377774 * a + .2158037573 * b) ** 3;
17
+ const m = (lightness - .1055613458 * a - .0638541728 * b) ** 3;
18
+ const s = (lightness - .0894841775 * a - 1.291485548 * b) ** 3;
19
+ return [4.0767416621 * l - 3.3077115913 * m + .2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - .3413193965 * s, -.0041960863 * l - .7034186147 * m + 1.707614701 * s];
20
+ };
21
+ export function fromLch([lightness, chroma, hue]) {
22
+ lightness = clamp(lightness); chroma = Math.max(0, chroma);
23
+ let rgb = channels([lightness, chroma, hue]);
24
+ if (rgb.some(n => n < -.000001 || n > 1.000001)) {
25
+ // Reduce chroma at fixed hue/lightness instead of clipping individual channels.
26
+ let low = 0, high = chroma;
27
+ for (let i = 0; i < 18; i++) {
28
+ const middle = (low + high) / 2;
29
+ if (channels([lightness, middle, hue]).every(n => n >= 0 && n <= 1)) low = middle;
30
+ else high = middle;
31
+ }
32
+ rgb = channels([lightness, low, hue]);
33
+ }
34
+ return '#' + rgb.map(n => Math.round(clamp(encoded(n)) * 255).toString(16).padStart(2, '0')).join('');
35
+ }
36
+ export function mixColour(a, b, weight) {
37
+ // Cartesian Oklab mixing avoids a hue discontinuity around 0/360 degrees.
38
+ const cartesian = hex => { const [l, c, h] = toLch(hex); return [l, c * Math.cos(h * Math.PI / 180), c * Math.sin(h * Math.PI / 180)]; };
39
+ const first = cartesian(a), second = cartesian(b), mixed = first.map((n, i) => n * weight + second[i] * (1 - weight));
40
+ return fromLch([mixed[0], Math.hypot(mixed[1], mixed[2]), Math.atan2(mixed[2], mixed[1]) * 180 / Math.PI]);
41
+ }
42
+ export function paletteSeeds(values) {
43
+ const intensity = Number(values['color-intensity']) * 2, warmth = (Number(values.warmth) - .5) * 2;
44
+ const relationship = values['palette-relationship'];
45
+ const brandHue = toLch(values.action)[2];
46
+ return Object.fromEntries(['action', 'canvas', 'surface', 'text'].map(key => {
47
+ if (intensity === 1 && warmth === 0 && relationship === 'preserve') return [key, values[key]];
48
+ let [l, c, h] = toLch(values[key]);
49
+ if (key !== 'action') {
50
+ const tint = Math.max(Math.abs(warmth), relationship === 'preserve' ? 0 : .5);
51
+ if (tint) l = clamp(l, .08 * tint, 1 - .03 * tint);
52
+ if (relationship !== 'preserve') { h = brandHue + (relationship === 'neighboring' ? 30 : 180); c = Math.max(c, .018); }
53
+ if (warmth !== 0) {
54
+ const target = warmth > 0 ? 85 : 255;
55
+ const delta = ((target - h + 540) % 360) - 180;
56
+ h += delta * Math.abs(warmth); c = Math.max(c, .035 * Math.abs(warmth));
57
+ }
58
+ }
59
+ return [key, fromLch([l, c * intensity, h])];
60
+ }));
61
+ }
62
+
63
+ export function companionSurface(seed, mode) {
64
+ const [l, c, h] = toLch(seed);
65
+ // Retain hue and restrained colorfulness; convert the tonal role, not the identity.
66
+ return fromLch([mode === 'dark' ? .16 + (1 - l) * .12 : .94 + (1 - l) * .045, Math.min(c, .045), h]);
67
+ }
package/docs/site.mjs CHANGED
@@ -86,7 +86,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
86
86
  await cp(own('catalog-view.js'), path.join(generated, 'catalog-view.js'));
87
87
  await cp(own('preview.js'), path.join(generated, 'preview.js'));
88
88
  for (const file of ['reference.html', 'reference.js']) await cp(own(file), path.join(generated, file));
89
- for (const file of ['customizer.js', 'theme.mjs', 'theme-session.mjs', 'value-editors.mjs']) await cp(own(file), path.join(generated, file));
89
+ for (const file of ['customizer.js', 'theme.mjs', 'theme-session.mjs', 'value-editors.mjs', 'palette.mjs', 'fonts.mjs']) await cp(own(file), path.join(generated, file));
90
90
  const write = async (file, source) => { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, source); };
91
91
  const examples = [];
92
92
  // HTMLKit route groups retain their labels/order in navigation without changing public URLs.
@@ -4,10 +4,11 @@ const identity = Symbol.for('looma.documentation.theme');
4
4
  export const themeEvent = 'looma-documentation-theme';
5
5
 
6
6
  /** Browser choices belong to one documentation base; repository writes stay explicit. */
7
- export function createThemeSession(view, { base, baseline, systemCSS }) {
7
+ export function createThemeSession(view, { base, baseline, systemCSS, historyLimit = 100 }) {
8
8
  const key = `looma:documentation-theme:v1:${base}`;
9
9
  let original = validateRecipe(baseline);
10
- let selected;
10
+ let selected, draft, comparison;
11
+ const past = [], future = [];
11
12
  let portableCSS = '';
12
13
  let storageAvailable = true;
13
14
  const style = view.document.createElement('style');
@@ -16,29 +17,47 @@ export function createThemeSession(view, { base, baseline, systemCSS }) {
16
17
  const read = value => { try { return value ? validateRecipe(JSON.parse(value)) : undefined; } catch { return undefined; } };
17
18
  try { selected = read(view.localStorage.getItem(key)); } catch { storageAvailable = false; }
18
19
  const paint = () => {
19
- portableCSS = selected ? themeCSS(selected, systemCSS) : '';
20
- view.document.documentElement.toggleAttribute('data-looma-documentation-theme', Boolean(selected));
21
- style.textContent = selected ? themeCSS(selected, systemCSS, { scope: 'documentation' }) : '';
20
+ const displayed = comparison ?? draft ?? selected;
21
+ portableCSS = displayed ? themeCSS(displayed, systemCSS) : '';
22
+ view.document.documentElement.toggleAttribute('data-looma-documentation-theme', Boolean(displayed));
23
+ style.textContent = displayed ? themeCSS(displayed, systemCSS, { scope: 'documentation' }) : '';
22
24
  view.dispatchEvent(new view.CustomEvent(themeEvent, { detail: { css: portableCSS } }));
23
25
  };
24
26
  const persist = () => {
25
27
  try { if (selected) view.localStorage.setItem(key, JSON.stringify(selected)); else view.localStorage.removeItem(key); storageAvailable = true; } catch { storageAvailable = false; }
26
28
  paint();
27
29
  };
30
+ const remember = () => {
31
+ past.push(structuredClone(selected));
32
+ if (past.length > historyLimit) past.shift();
33
+ future.length = 0;
34
+ };
28
35
  const session = {
29
- get recipe() { return structuredClone(selected ?? original); },
36
+ get recipe() { return structuredClone(draft ?? selected ?? original); },
30
37
  get hasOverride() { return selected !== undefined; },
31
38
  get storageAvailable() { return storageAvailable; },
32
39
  get css() { return portableCSS; },
33
- apply(recipe) { selected = validateRecipe(recipe); persist(); },
34
- reset() { selected = undefined; persist(); return structuredClone(original); },
40
+ get canUndo() { return past.length > 0; },
41
+ get canRedo() { return future.length > 0; },
42
+ preview(recipe) { draft = validateRecipe(recipe); paint(); },
43
+ commit() { if (draft) session.apply(draft); },
44
+ cancel() { draft = undefined; paint(); },
45
+ compare(recipe) { comparison = recipe === undefined ? undefined : validateRecipe(recipe); paint(); },
46
+ undo() { if (!past.length) return; future.push(structuredClone(selected)); selected = past.pop(); draft = comparison = undefined; persist(); },
47
+ redo() { if (!future.length) return; past.push(structuredClone(selected)); selected = future.pop(); draft = comparison = undefined; persist(); },
48
+ apply(recipe) {
49
+ const next = validateRecipe(recipe);
50
+ if (JSON.stringify(next) !== JSON.stringify(selected)) remember();
51
+ selected = next; draft = comparison = undefined; persist();
52
+ },
53
+ reset() { if (selected !== undefined) remember(); selected = draft = comparison = undefined; persist(); return structuredClone(original); },
35
54
  baseline(recipe) { original = validateRecipe(recipe); },
36
55
  };
37
56
  view.addEventListener('storage', event => {
38
57
  if (event.key !== key && event.key !== null) return;
39
58
  const next = read(event.newValue);
40
59
  if (event.newValue !== null && !next) return;
41
- selected = next; paint();
60
+ selected = next; draft = comparison = undefined; past.length = future.length = 0; paint();
42
61
  });
43
62
  paint();
44
63
  return session;