@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.
- package/components/ui-avatar/ui-avatar.html +1 -1
- package/components/ui-avatar-group/ui-avatar-group.html +1 -1
- package/components/ui-button/ui-button.html +11 -4
- package/components/ui-color-picker/ui-color-picker.html +1 -1
- package/components/ui-combobox/ui-combobox.html +1 -1
- package/components/ui-curve-editor/ui-curve-editor.html +1 -0
- package/components/ui-curve-editor/ui-curve-editor.js +15 -6
- package/components/ui-input/ui-input.html +4 -1
- package/components/ui-light-editor/ui-light-editor.html +143 -19
- package/components/ui-light-editor/ui-light-editor.js +26 -9
- package/components/ui-select/ui-select.html +10 -6
- package/dist/index.js +1 -1
- package/docs/README.md +33 -7
- package/docs/card.js +1 -0
- package/docs/catalog.json +39 -5
- package/docs/customizer-render.mjs +27 -9
- package/docs/customizer.js +146 -18
- package/docs/fonts.mjs +45 -0
- package/docs/index.d.ts +2 -2
- package/docs/palette.mjs +67 -0
- package/docs/site.mjs +1 -1
- package/docs/theme-session.mjs +28 -9
- package/docs/theme.mjs +67 -26
- package/docs/value-editors.mjs +6 -3
- package/package.json +2 -2
- package/styles/ui-avatar-group.css +1 -1
- package/styles/ui-avatar.css +1 -1
- package/styles/ui-button.css +11 -4
- package/styles/ui-color-picker.css +1 -1
- package/styles/ui-combobox.css +2 -2
- package/styles/ui-input.css +4 -1
- package/styles/ui-light-editor.css +111 -16
- package/styles/ui-select.css +10 -6
- package/theme-system.css +17 -4
- package/tokens.css +28 -15
- package/vanilla/UiCurveEditor.d.ts +1 -0
- package/vanilla/UiCurveEditor.js +1 -1
- package/vanilla/UiLightEditor.d.ts +1 -0
- package/vanilla/UiLightEditor.js +2 -2
- package/vue/UiAvatar.vue +1 -1
- package/vue/UiAvatarGroup.vue +1 -1
- package/vue/UiButton.vue +4 -2
- package/vue/UiColorPicker.vue +1 -1
- package/vue/UiCombobox.vue +2 -2
- package/vue/UiCurveEditor.d.ts +6 -0
- package/vue/UiCurveEditor.vue +2 -0
- package/vue/UiInput.vue +4 -1
- package/vue/UiLightEditor.d.ts +8 -0
- package/vue/UiLightEditor.vue +22 -3
- package/vue/UiSelect.vue +1 -1
- package/vue/chunks/UiAvatar.js +1 -1
- package/vue/chunks/UiAvatarGroup.js +1 -1
- package/vue/chunks/UiButton.js +1 -1
- package/vue/chunks/UiColorPicker.js +1 -1
- package/vue/chunks/UiCombobox.js +1 -1
- package/vue/chunks/UiCurveEditor.js +5 -3
- package/vue/chunks/UiInput.js +1 -1
- package/vue/chunks/UiLightEditor.js +16 -6
- package/vue/chunks/UiSelect.js +1 -1
- package/vue/components.css +43 -16
package/docs/customizer.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 }[];
|
package/docs/palette.mjs
ADDED
|
@@ -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.
|
package/docs/theme-session.mjs
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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;
|