@threadlabs/looma 0.35.0 → 0.36.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 (66) hide show
  1. package/components/ui-button/ui-button.html +11 -4
  2. package/components/ui-color-picker/ui-color-picker.html +1 -1
  3. package/components/ui-combobox/ui-combobox.html +1 -1
  4. package/components/ui-curve-editor/ui-curve-editor.html +1 -0
  5. package/components/ui-curve-editor/ui-curve-editor.js +15 -6
  6. package/components/ui-input/ui-input.html +4 -1
  7. package/components/ui-light-editor/ui-light-editor.html +143 -19
  8. package/components/ui-light-editor/ui-light-editor.js +26 -9
  9. package/components/ui-reel/ui-reel.html +2 -1
  10. package/components/ui-select/ui-select.html +10 -6
  11. package/components/ui-tabs/ui-tabs.html +2 -3
  12. package/components/ui-tabs/ui-tabs.js +0 -9
  13. package/components/ui-tooltip/ui-tooltip.html +2 -1
  14. package/dist/index.js +1 -1
  15. package/docs/README.md +36 -8
  16. package/docs/card.html +7 -6
  17. package/docs/card.js +1 -0
  18. package/docs/catalog-view.mjs +2 -1
  19. package/docs/catalog.json +46 -12
  20. package/docs/customizer-render.mjs +38 -18
  21. package/docs/customizer.js +128 -21
  22. package/docs/fonts.mjs +45 -0
  23. package/docs/index.d.ts +2 -2
  24. package/docs/palette.mjs +67 -0
  25. package/docs/site.mjs +5 -4
  26. package/docs/theme-session.mjs +28 -9
  27. package/docs/theme.mjs +69 -27
  28. package/docs/value-editors.mjs +6 -4
  29. package/package.json +2 -2
  30. package/styles/ui-button.css +11 -4
  31. package/styles/ui-color-picker.css +1 -1
  32. package/styles/ui-combobox.css +2 -2
  33. package/styles/ui-input.css +4 -1
  34. package/styles/ui-light-editor.css +111 -16
  35. package/styles/ui-select.css +10 -6
  36. package/styles/ui-tabs.css +2 -3
  37. package/styles/ui-tooltip.css +2 -1
  38. package/theme-system.css +14 -2
  39. package/tokens.css +11 -1
  40. package/vanilla/UiCurveEditor.d.ts +1 -0
  41. package/vanilla/UiCurveEditor.js +1 -1
  42. package/vanilla/UiLightEditor.d.ts +1 -0
  43. package/vanilla/UiLightEditor.js +2 -2
  44. package/vue/UiButton.vue +4 -2
  45. package/vue/UiColorPicker.vue +1 -1
  46. package/vue/UiCombobox.vue +2 -2
  47. package/vue/UiCurveEditor.d.ts +6 -0
  48. package/vue/UiCurveEditor.vue +2 -0
  49. package/vue/UiInput.vue +4 -1
  50. package/vue/UiLightEditor.d.ts +8 -0
  51. package/vue/UiLightEditor.vue +22 -3
  52. package/vue/UiReel.vue +1 -1
  53. package/vue/UiSelect.vue +1 -1
  54. package/vue/UiTabs.vue +2 -3
  55. package/vue/UiTooltip.vue +2 -1
  56. package/vue/chunks/UiButton.js +1 -1
  57. package/vue/chunks/UiColorPicker.js +1 -1
  58. package/vue/chunks/UiCombobox.js +1 -1
  59. package/vue/chunks/UiCurveEditor.js +5 -3
  60. package/vue/chunks/UiInput.js +1 -1
  61. package/vue/chunks/UiLightEditor.js +16 -6
  62. package/vue/chunks/UiReel.js +2 -2
  63. package/vue/chunks/UiSelect.js +1 -1
  64. package/vue/chunks/UiTabs.js +1 -1
  65. package/vue/chunks/UiTooltip.js +1 -1
  66. package/vue/components.css +47 -19
@@ -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,6 +22,8 @@ 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.');
@@ -29,7 +32,7 @@ export function connect(host) {
29
32
  const updateControl = (element, value) => {
30
33
  const field = fields.get(element.dataset.token);
31
34
  if (!field || !element.dataset.editorKind) { componentRegistry.update(element, { value }); return; }
32
- const editor = valueEditor(field, value, unitContext());
35
+ const editor = valueEditor(field, value);
33
36
  if (element.dataset.editorKind === 'curve') {
34
37
  element.closest('[data-curve-for]').hidden = editor.kind !== 'curve';
35
38
  if (editor.kind === 'curve') componentRegistry.update(element, { value });
@@ -37,7 +40,7 @@ export function connect(host) {
37
40
  };
38
41
  const updateUnits = () => {
39
42
  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());
43
+ const field = fields.get(control.dataset.unitFor), editor = valueEditor(field, recipe.values[field.id]);
41
44
  if (![...control.options].some(option => option.value === editor.unit)) control.add(new view.Option(editor.unit, editor.unit));
42
45
  componentRegistry.update(control, { value: editor.unit });
43
46
  }
@@ -52,13 +55,24 @@ export function connect(host) {
52
55
  }
53
56
  componentRegistry.update(root.querySelector(`[data-action="restore-${family}"]`), { disabled: state.id === 'custom' || !state.customized });
54
57
  }
58
+ 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
59
  componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
56
60
  const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
57
61
  const report = root.querySelector('[data-palette-adjustments]');
58
62
  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
63
  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);
64
+ for (const card of root.querySelectorAll('[data-example-card]')) if (card.getClientRects().length) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
65
+ const cssControl = root.querySelector('[data-css]');
66
+ if (cssControl.getClientRects().length) updateControl(cssControl, css);
67
+ for (const action of ['undo','redo']) componentRegistry.update(root.querySelector(`[data-action="${action}"]`), { disabled: !theme?.[action === 'undo' ? 'canUndo' : 'canRedo'] });
68
+ const origin = recipe.origin ?? startingPointFor(recipe);
69
+ root.querySelector('[data-theme-origin]').textContent = startingPointLabels[origin] ? `${startingPointLabels[origin]}${startingPointFor(recipe) === origin ? '' : ' · customized'}` : 'Your theme';
70
+ root.querySelector('[data-material-dependencies]').textContent = Number(recipe.values['light-strength']) === 0
71
+ ? '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.'
72
+ : 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.'
73
+ : 'Finish catches the light. Spread shapes its reflection; strength affects reflection, edge light and glow together.';
74
+ const gaps = root.querySelector('[data-spacing-samples]');
75
+ gaps.setAttribute('aria-label', `Spacing progression with curve ${recipe.values['space-curve']}`);
62
76
  };
63
77
  const show = (next, persist = true) => {
64
78
  recipe = validateRecipe(next);
@@ -69,12 +83,43 @@ export function connect(host) {
69
83
  changed = JSON.stringify(recipe) !== JSON.stringify(saved);
70
84
  paint();
71
85
  };
86
+ const clearComparison = () => {
87
+ if (!comparing && !candidate) return;
88
+ comparing = false; candidate = undefined; theme?.compare();
89
+ root.querySelector('[data-action="compare"]').setAttribute('aria-pressed', 'false');
90
+ root.querySelector('[data-variation-confirm]').hidden = true;
91
+ };
92
+ const preview = next => {
93
+ const validated = validateRecipe(next);
94
+ if (JSON.stringify(validated.values) === JSON.stringify(recipe.values)) return;
95
+ clearComparison();
96
+ if (!pending) gestureStart = structuredClone(recipe);
97
+ recipe = validated; pending = true;
98
+ if (!previewFrame) previewFrame = view.requestAnimationFrame(() => {
99
+ previewFrame = 0;
100
+ theme?.preview(recipe);
101
+ show(recipe, false);
102
+ });
103
+ };
104
+ const flush = (commit = true) => {
105
+ if (previewFrame) view.cancelAnimationFrame(previewFrame);
106
+ previewFrame = 0;
107
+ if (!pending) return;
108
+ pending = false;
109
+ const netChange = JSON.stringify(recipe.values) !== JSON.stringify(gestureStart.values);
110
+ if (commit && netChange) theme?.apply(recipe); else theme?.cancel();
111
+ show(theme?.recipe ?? (netChange && commit ? recipe : gestureStart), false);
112
+ gestureStart = undefined;
113
+ };
72
114
  const filterExamples = () => {
73
115
  const words = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
74
- const options = [...root.querySelectorAll('[data-add-example] option')];
75
- for (const card of root.querySelectorAll('[data-example-card]')) card.closest('section').hidden = !words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
76
- const matches = options.filter(option => words.every(word => option.dataset.search.includes(word))).length;
77
- root.querySelector('[data-example-count]').textContent = matches ? `${matches} matching examples` : 'No examples match. Try another name or purpose.';
116
+ let visible = 0;
117
+ for (const card of root.querySelectorAll('[data-customizer-overview] .examples [data-example-card]')) {
118
+ const matches = words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
119
+ card.closest('section').hidden = !matches;
120
+ if (matches) visible++;
121
+ }
122
+ root.querySelector('[data-example-count]').textContent = visible ? `${visible} example${visible === 1 ? '' : 's'} in preview` : 'No previews match. Clear the filter or add another example.';
78
123
  componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: !selectedExample || host.state.cards.some(item => item.id === selectedExample) });
79
124
  };
80
125
  const refinedRecipe = values => {
@@ -83,14 +128,16 @@ export function connect(host) {
83
128
  const state = refinementState(recipe, family);
84
129
  if (!presets[family] && state.id !== 'custom') presets[family] = state.id;
85
130
  }
86
- return { ...recipe, presets, values };
131
+ const origin = recipe.origin ?? (startingPointFor(recipe) === 'custom' ? undefined : startingPointFor(recipe));
132
+ const fonts = { ...recipe.fonts };
133
+ for (const key of Object.keys(fonts)) if (values[key] !== googleFontStack(fonts[key])) delete fonts[key];
134
+ return { ...recipe, fonts, ...(origin ? { origin } : {}), presets, values };
87
135
  };
88
136
  const input = event => {
89
137
  if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
90
138
  if (event.target.matches('[data-light-editor]')) {
91
139
  try {
92
- show(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
93
- sayApplied('Light position remembered throughout your design system.');
140
+ preview(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
94
141
  } catch (error) { say(error.message, true); }
95
142
  return;
96
143
  }
@@ -101,13 +148,12 @@ export function connect(host) {
101
148
  const value = control.dataset.editorKind ? editorValue(field, raw) : String(raw);
102
149
  const next = refinedRecipe({ ...recipe.values, [control.dataset.token]: value });
103
150
  try {
104
- recipe = validateRecipe(next);
105
- theme?.apply(recipe); changed = true;
151
+ preview(next); changed = JSON.stringify(recipe) !== JSON.stringify(saved);
106
152
  updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
107
153
  control.removeAttribute('aria-invalid');
108
154
  for (const sibling of root.querySelectorAll('[data-token]')) if (sibling.dataset.token === control.dataset.token) updateControl(sibling, value);
109
155
  updateUnits();
110
- paint(); sayApplied('Theme remembered in this browser. Download it or save it to your repository.');
156
+ say('Previewing your theme. Finish the edit to remember it.');
111
157
  } catch (error) { control.setAttribute('aria-invalid', 'true'); say(error.message, true); }
112
158
  };
113
159
  const download = (data, name, type) => {
@@ -117,22 +163,51 @@ export function connect(host) {
117
163
  };
118
164
  const click = async event => {
119
165
  const button = event.target.closest?.('[data-action]');
120
- if (!button || (button.dataset.action === 'save' && saving)) return;
166
+ if (!button) { view.requestAnimationFrame(paint); return; }
167
+ if (button.dataset.action === 'save' && saving) return;
168
+ flush();
169
+ if (!['compare', 'preview-variation', 'apply-variation', 'cancel-variation'].includes(button.dataset.action)) clearComparison();
121
170
  try {
122
171
  switch (button.dataset.action) {
172
+ case 'undo': case 'redo':
173
+ theme?.[button.dataset.action](); show(theme?.recipe ?? recipe, false); sayApplied('Previous theme decision restored.'); break;
174
+ case 'compare':
175
+ candidate = undefined; comparing = !comparing; theme?.compare(comparing ? entryRecipe : undefined);
176
+ button.setAttribute('aria-pressed', String(comparing)); root.querySelector('[data-variation-confirm]').hidden = true;
177
+ say(comparing ? 'Comparing with the theme when you entered. Your edits remain intact. Choose Compare again to return.' : 'Back to your edited theme.'); break;
178
+ case 'preview-variation': {
179
+ clearComparison();
180
+ const locks = [...root.querySelectorAll('[data-family-lock]')].filter(control => control.querySelector('input')?.checked).map(control => control.dataset.familyLock);
181
+ candidate = nearbyVariation(recipe, root.querySelector('[data-variation-choice]').value, locks);
182
+ theme?.compare(candidate); root.querySelector('[data-variation-confirm]').hidden = false;
183
+ const differences = themeFields.filter(field => recipe.values[field.id] !== candidate.values[field.id]);
184
+ 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.';
185
+ break;
186
+ }
187
+ case 'apply-variation': {
188
+ const selected = candidate; clearComparison();
189
+ if (selected) show(selected); sayApplied('Variation applied. Undo returns to your previous theme.'); break;
190
+ }
191
+ case 'cancel-variation': clearComparison(); paint(); say('Variation cancelled; your theme is unchanged.'); break;
192
+ case 'light-top': case 'light-left': case 'light-center': {
193
+ const positions = { 'light-top': [.5, 0], 'light-left': [.15, .15], 'light-center': [.5, .5] };
194
+ const [x, y] = positions[button.dataset.action];
195
+ show(refinedRecipe({ ...recipe.values, 'light-x': String(x), 'light-y': String(y) }));
196
+ sayApplied('Light direction applied throughout your theme.'); break;
197
+ }
123
198
  case 'refine-material': case 'refine-palette': {
124
199
  const family = button.dataset.action.slice(7);
125
200
  refinementTrigger = button;
126
201
  root.querySelector('[data-customizer-overview]').hidden = true;
127
202
  for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = section.dataset.refinementView !== family;
128
203
  const section = root.querySelector(`[data-refinement-view="${family}"]`);
129
- section.focus(); section.scrollIntoView({ block: 'start' });
204
+ paint(); section.focus(); section.scrollIntoView({ block: 'start' });
130
205
  break;
131
206
  }
132
207
  case 'back':
133
208
  for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = true;
134
209
  root.querySelector('[data-customizer-overview]').hidden = false;
135
- refinementTrigger?.focus(); break;
210
+ paint(); refinementTrigger?.focus(); break;
136
211
  case 'restore-material': case 'restore-palette': {
137
212
  const family = button.dataset.action.slice(8), state = refinementState(recipe, family);
138
213
  if (state.id !== 'custom') { show(applyRefinement(recipe, family, state.id)); sayApplied(`${family === 'material' ? 'Material' : 'Palette'} preset restored; other theme decisions are unchanged.`); }
@@ -174,6 +249,10 @@ export function connect(host) {
174
249
  finally { if (button.dataset.action === 'save') saving = false; }
175
250
  };
176
251
  const change = async event => {
252
+ if (event.target.matches('[data-light-editor]') || event.target.closest?.('[data-token]')) {
253
+ input(event); flush(); sayApplied('Theme remembered in this browser. Undo restores the previous decision.'); return;
254
+ }
255
+ flush(); clearComparison();
177
256
  if (event.target.matches('[data-unit-for]')) {
178
257
  const field = fields.get(event.target.dataset.unitFor);
179
258
  try {
@@ -189,7 +268,7 @@ export function connect(host) {
189
268
  show(applyRefinement(recipe, family, event.target.value));
190
269
  sayApplied(`${family === 'material' ? 'Material' : 'Coordinated palette'} preset applied and remembered in both modes.`);
191
270
  }
192
- if (event.target.matches('[data-preset]')) { show(structuredClone(startingPoints[event.target.value])); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
271
+ 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.'); }
193
272
  if (event.target === file && file.files[0]) {
194
273
  try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
195
274
  catch (error) { say(error.message, true); }
@@ -197,11 +276,35 @@ export function connect(host) {
197
276
  }
198
277
  };
199
278
  const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
200
- const synchronize = () => { if (theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
279
+ const synchronize = () => { if (!pending && theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
280
+ const cancel = event => { if (event.target.matches('[data-light-editor], [data-editor-kind="curve"]')) { flush(false); say('Drag cancelled; the previous value is restored.'); } };
281
+ const keyboard = event => {
282
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z' || event.target.closest?.('input,textarea,select,[contenteditable="true"]')) return;
283
+ event.preventDefault(); flush(); clearComparison(); theme?.[event.shiftKey ? 'redo' : 'undo'](); show(theme?.recipe ?? recipe, false);
284
+ };
201
285
  const ready = event => {
202
286
  if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
203
287
  };
204
288
  const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); view.requestAnimationFrame(filterExamples); };
289
+ let fontRequest = 0;
290
+ const selectFont = async event => {
291
+ const control = event.target.closest?.('[data-font-picker]'); if (!control) return;
292
+ flush(); clearComparison();
293
+ const id = control.dataset.fontPicker, family = event.detail.value;
294
+ const fonts = { ...recipe.fonts };
295
+ if (family === 'system') delete fonts[id]; else fonts[id] = family;
296
+ const next = { ...refinedRecipe({ ...recipe.values, [id]: family === 'system' ? systemFont : googleFontStack(family) }), fonts };
297
+ const status = root.querySelector(`[data-font-status="${id}"]`), request = ++fontRequest;
298
+ show(next); status.textContent = family === 'system' ? 'Using your operating system’s native font.' : `Loading ${family} from Google Fonts…`;
299
+ try {
300
+ await loadGoogleFonts(document, fonts);
301
+ if (alive && request === fontRequest) for (const [field, selected] of Object.entries(fonts)) {
302
+ root.querySelector(`[data-font-status="${field}"]`).textContent = `${selected} loaded. Included in downloaded and locally saved CSS.`;
303
+ }
304
+ } catch (error) {
305
+ if (alive && request === fontRequest) for (const field of Object.keys(fonts)) root.querySelector(`[data-font-status="${field}"]`).textContent = error.message;
306
+ }
307
+ };
205
308
  const selectExample = event => {
206
309
  if (!event.target.matches('[data-add-example]')) return;
207
310
  selectedExample = event.detail.value ?? '';
@@ -209,11 +312,15 @@ export function connect(host) {
209
312
  };
210
313
  root.addEventListener('looma-example-remove', remove);
211
314
  root.addEventListener('input', input);
315
+ root.addEventListener('cancel', cancel);
316
+ root.addEventListener('keydown', keyboard);
212
317
  root.addEventListener('value-change', selectExample);
318
+ root.addEventListener('value-change', selectFont);
213
319
  root.addEventListener('blur', blur, true);
214
320
  root.addEventListener('change', change);
215
321
  root.addEventListener('click', click);
216
322
  view.addEventListener('message', ready);
323
+ view.addEventListener('beforeunload', flush);
217
324
  view.addEventListener(themeEvent, synchronize);
218
325
  show(recipe, false); filterExamples();
219
326
  if (theme?.storageAvailable === false) sayApplied('');
@@ -227,5 +334,5 @@ export function connect(host) {
227
334
  root.querySelector('[data-action="save"]').hidden = false;
228
335
  }).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
229
336
  }
230
- 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); };
337
+ return () => { 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); };
231
338
  }
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.
@@ -120,11 +120,12 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
120
120
  }
121
121
  const gallery = combined.groups.map(group => {
122
122
  const components = combined.components.filter(c => c.group === group.id && !c.navigationParent);
123
- return components.length ? `<section data-catalog-group="${escapeHtml(group.id)}">\n\n## ${escapeHtml(group.label)}\n\n<ui-grid columns="3" min="sm">${components.map(component => `<div data-category="${escapeHtml(component.group)}" data-catalog-search="${escapeHtml(`${component.tag} ${component.title} ${component.description}`)}">${cardMarkup(component.examples[0], base, component)}</div>`).join('')}</ui-grid>\n\n</section>` : '';
123
+ // A standalone opening tag makes this a Markdown HTML block, keeping native card wrappers inside Grid.
124
+ return components.length ? `<section data-catalog-group="${escapeHtml(group.id)}">\n\n## ${escapeHtml(group.label)}\n\n<ui-grid columns="3" min="sm">\n${components.map(component => `<div data-category="${escapeHtml(component.group)}" data-catalog-search="${escapeHtml(`${component.tag} ${component.title} ${component.description}`)}">${cardMarkup(component.examples[0], base, component)}</div>`).join('\n')}\n</ui-grid>\n\n</section>` : '';
124
125
  }).join('\n\n');
125
126
  await write(path.join(content, 'components/index.md'), `---\ntitle: Components\n---\n\n# Components\n\nExplore the live examples that also power your theme customizer.\n\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), card)}">\n<link rel="component" href="@threadlabs/looma/components/ui-grid/ui-grid.html">\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), path.join(generated, 'catalog-view.html'))}">\n\n<looma-catalog>\n\n${gallery}\n\n</looma-catalog>\n`);
126
127
  await write(path.join(content, 'index.md'), `---\ntitle: ${JSON.stringify(config.title ?? 'Your design system')}\n---\n\n# ${config.title ?? 'Your design system'}\n\nWeave your design system from Looma and your own HTML Next components.\n\n[Explore components](/components/) · [Customize your theme](/customize/)\n`);
127
- if (combined.components.some(component => component.group === 'editor')) await write(path.join(content, 'editor.md'), `---\ntitle: Editor\n---\n\n# Editor\n\nWriting tools and editor components, separate from the general component reference.\n\n${combined.components.filter(component => component.group === 'editor' && !component.navigationParent).map(component => `- [${component.title}](${componentPath(component)})`).join('\n')}\n`);
128
+ if (combined.components.some(component => component.group === 'editor')) await write(path.join(content, 'editor.md'), `---\ntitle: Editor\nsidebar: false\n---\n\n# Editor\n\nWriting tools and editor components, separate from the general component reference.\n\n${combined.components.filter(component => component.group === 'editor' && !component.navigationParent).map(component => `- [${component.title}](${componentPath(component)})`).join('\n')}\n`);
128
129
  await write(path.join(content, 'design-system.md'), await readFile(own('README.md'), 'utf8'));
129
130
  if (config.content) {
130
131
  const authored = path.resolve(root, config.content);
@@ -143,7 +144,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
143
144
  const customizer = path.join(generated, 'customizer.html');
144
145
  await write(customizer, customizerResource({ catalog: combined, config, base }, config.recipe));
145
146
  const customizerPage = path.join(content, 'customize.md');
146
- await write(customizerPage, `---\ntitle: Customize\n---\n\n# Weave your design system\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
147
+ await write(customizerPage, `---\ntitle: Customize\nsidebar: false\noutline: false\nprevious: false\nnext: false\n---\n\n# Weave your design system\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
147
148
  await write(path.join(generated, 'catalog.json'), JSON.stringify(combined));
148
149
  const redirects = {};
149
150
  for (const component of combined.components) {
@@ -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;