@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.
- 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-reel/ui-reel.html +2 -1
- package/components/ui-select/ui-select.html +10 -6
- package/components/ui-tabs/ui-tabs.html +2 -3
- package/components/ui-tabs/ui-tabs.js +0 -9
- package/components/ui-tooltip/ui-tooltip.html +2 -1
- package/dist/index.js +1 -1
- package/docs/README.md +36 -8
- package/docs/card.html +7 -6
- package/docs/card.js +1 -0
- package/docs/catalog-view.mjs +2 -1
- package/docs/catalog.json +46 -12
- package/docs/customizer-render.mjs +38 -18
- package/docs/customizer.js +128 -21
- package/docs/fonts.mjs +45 -0
- package/docs/index.d.ts +2 -2
- package/docs/palette.mjs +67 -0
- package/docs/site.mjs +5 -4
- package/docs/theme-session.mjs +28 -9
- package/docs/theme.mjs +69 -27
- package/docs/value-editors.mjs +6 -4
- package/package.json +2 -2
- 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/styles/ui-tabs.css +2 -3
- package/styles/ui-tooltip.css +2 -1
- package/theme-system.css +14 -2
- package/tokens.css +11 -1
- 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/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/UiReel.vue +1 -1
- package/vue/UiSelect.vue +1 -1
- package/vue/UiTabs.vue +2 -3
- package/vue/UiTooltip.vue +2 -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/UiReel.js +2 -2
- package/vue/chunks/UiSelect.js +1 -1
- package/vue/chunks/UiTabs.js +1 -1
- package/vue/chunks/UiTooltip.js +1 -1
- package/vue/components.css +47 -19
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,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
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
for (const card of root.querySelectorAll('[data-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 }[];
|
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.
|
|
@@ -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
|
-
|
|
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) {
|
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;
|