@threadlabs/looma-docs 0.0.0-stage → 0.52.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/LICENSE +21 -0
- package/README.md +11 -2
- package/bin/looma-docs.mjs +3 -0
- package/docs/README.md +89 -0
- package/docs/card.html +79 -0
- package/docs/card.js +18 -0
- package/docs/catalog-view.js +24 -0
- package/docs/catalog-view.mjs +13 -0
- package/docs/catalog.mjs +135 -0
- package/docs/cli.mjs +26 -0
- package/docs/commands.mjs +94 -0
- package/docs/customizer-render.mjs +157 -0
- package/docs/customizer.js +494 -0
- package/docs/documentation.css +42 -0
- package/docs/example-invocations.mjs +63 -0
- package/docs/fonts.mjs +73 -0
- package/docs/framework-examples.d.mts +9 -0
- package/docs/framework-examples.mjs +225 -0
- package/docs/guides/01.theme-your-system.md +64 -0
- package/docs/guides/02.add-your-components.md +96 -0
- package/docs/guides/03.publish-your-system.md +71 -0
- package/docs/guides/04.theme-decisions.md +141 -0
- package/docs/guides/05.(Theme reference)/01.theme-colors.md +95 -0
- package/docs/guides/05.(Theme reference)/02.theme-typography.md +73 -0
- package/docs/guides/05.(Theme reference)/03.theme-spacing.md +103 -0
- package/docs/guides/05.(Theme reference)/04.theme-material.md +90 -0
- package/docs/guides/05.(Theme reference)/05.theme-motion.md +57 -0
- package/docs/guides/assets/fellowship-dark.png +0 -0
- package/docs/guides/assets/fellowship-light.png +0 -0
- package/docs/index.d.ts +59 -0
- package/docs/index.mjs +4 -0
- package/docs/local-server.mjs +65 -0
- package/docs/navigation.mjs +40 -0
- package/docs/palette.mjs +68 -0
- package/docs/pastoral.svg +54 -0
- package/docs/portrait.svg +15 -0
- package/docs/reference.html +1 -0
- package/docs/reference.js +36 -0
- package/docs/render.mjs +97 -0
- package/docs/site.mjs +244 -0
- package/docs/store.mjs +92 -0
- package/docs/theme-session.mjs +72 -0
- package/docs/theme.mjs +669 -0
- package/docs/value-editors.mjs +37 -0
- package/docs/workspace.mjs +32 -0
- package/package.json +53 -3
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { googleFontStack, systemFont, loadGoogleFonts, loadGoogleFontPreview, googleFonts } from './fonts.mjs';
|
|
2
|
+
import { documentationTheme, themeEvent } from './theme-session.mjs';
|
|
3
|
+
import { componentRegistry } from '@nextwebwg/html-next/runtime';
|
|
4
|
+
import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, statusPresets, applyRefinement, refinementState, deriveTheme } from './theme.mjs';
|
|
5
|
+
|
|
6
|
+
import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
|
|
7
|
+
|
|
8
|
+
/** The editor changes public theme values; the library owns every control's layout. */
|
|
9
|
+
export function connect(host) {
|
|
10
|
+
const root = host.element;
|
|
11
|
+
const document = root.ownerDocument;
|
|
12
|
+
const view = document.defaultView;
|
|
13
|
+
const message = root.querySelector('[data-message]');
|
|
14
|
+
const file = root.querySelector('[data-recipe-file]');
|
|
15
|
+
const theme = documentationTheme(view);
|
|
16
|
+
const motionPreference = view.matchMedia('(prefers-reduced-motion: reduce)');
|
|
17
|
+
let saved = validateRecipe(JSON.parse(root.dataset.recipe));
|
|
18
|
+
let recipe = theme?.recipe ?? structuredClone(saved);
|
|
19
|
+
let revision = root.dataset.revision;
|
|
20
|
+
let capability;
|
|
21
|
+
let changed = false;
|
|
22
|
+
let saving = false;
|
|
23
|
+
let alive = true;
|
|
24
|
+
let selectedExample = '';
|
|
25
|
+
const catalogExamples = [...root.querySelectorAll('[data-add-example] option[data-example]')].map(option => JSON.parse(option.dataset.example));
|
|
26
|
+
const removedExamples = new Set();
|
|
27
|
+
let previewQuery = '', previewLimit = 9;
|
|
28
|
+
let refinementTrigger, overviewScroll = 0, motionAnimation, motionSignature;
|
|
29
|
+
let previewFrame = 0, pending = false, gestureStart;
|
|
30
|
+
const abort = new AbortController();
|
|
31
|
+
const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
|
|
32
|
+
const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
|
|
33
|
+
const fields = new Map(themeFields.map(field => [field.id, field]));
|
|
34
|
+
const unitContext = () => ({ remPixels: parseFloat(view.getComputedStyle(document.documentElement).fontSize) });
|
|
35
|
+
const editorFor = (field, value) => valueEditor(field, value, { remPixels: parseFloat(recipe.values['text-size']) });
|
|
36
|
+
let boundsBase, lastLabelSize;
|
|
37
|
+
const updateControl = (element, value) => {
|
|
38
|
+
if (element.matches('[data-choice]')) element.dataset.selectedValue = value;
|
|
39
|
+
const field = fields.get(element.dataset.token);
|
|
40
|
+
if (field?.type === 'optional-colour' && value === field.defaultValue) value = '';
|
|
41
|
+
if (!field || !element.dataset.editorKind) { componentRegistry.update(element, { value }); return; }
|
|
42
|
+
const editor = editorFor(field, value);
|
|
43
|
+
if (element.dataset.editorKind === 'curve') {
|
|
44
|
+
element.closest('[data-curve-for]').hidden = editor.kind !== 'curve';
|
|
45
|
+
if (editor.kind === 'curve') componentRegistry.update(element, { value });
|
|
46
|
+
} else componentRegistry.update(element, Object.fromEntries(['value', 'unit', 'min', 'max', 'step'].map(key => [key, editor[key]])));
|
|
47
|
+
};
|
|
48
|
+
const updateUnits = () => {
|
|
49
|
+
for (const control of root.querySelectorAll('[data-unit-for]')) {
|
|
50
|
+
const field = fields.get(control.dataset.unitFor), editor = editorFor(field, recipe.values[field.id]);
|
|
51
|
+
if (![...control.options].some(option => option.value === editor.unit)) control.add(new view.Option(editor.unit, editor.unit));
|
|
52
|
+
componentRegistry.update(control, { value: editor.unit });
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const updateSizes = () => {
|
|
56
|
+
const samples = [...root.querySelectorAll('[data-size-sample]')].filter(sample => sample.getClientRects().length);
|
|
57
|
+
if (!samples.length) return;
|
|
58
|
+
const base = parseFloat(view.getComputedStyle(document.documentElement).fontSize);
|
|
59
|
+
const rounded = value => Number(value.toFixed(2));
|
|
60
|
+
for (const sample of samples) {
|
|
61
|
+
const button = sample.querySelector('button');
|
|
62
|
+
if (button) {
|
|
63
|
+
const height = button.getBoundingClientRect().height;
|
|
64
|
+
sample.querySelector('[data-size-readout]').textContent = ` · ${rounded(height / base)}rem (${rounded(height)}px)`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const header = document.querySelector('[component~="markupress-header"]');
|
|
69
|
+
const previews = [...root.querySelectorAll('[data-editor-preview], .palette-preview')];
|
|
70
|
+
let layoutFrame = 0;
|
|
71
|
+
const updatePreviewLayout = () => {
|
|
72
|
+
root.style.setProperty('--_customizer-summary-height', `${root.querySelector('[data-customizer-summary]').getBoundingClientRect().height}px`);
|
|
73
|
+
const chrome = header?.getBoundingClientRect().height ?? 0;
|
|
74
|
+
root.style.setProperty('--_editor-chrome-height', `${chrome}px`);
|
|
75
|
+
for (const preview of previews) preview.toggleAttribute('data-page-flow', preview.offsetHeight > (view.innerHeight - chrome) / 2);
|
|
76
|
+
};
|
|
77
|
+
// Layout writes must run outside ResizeObserver delivery, including when a preview appears.
|
|
78
|
+
const schedulePreviewLayout = () => {
|
|
79
|
+
if (!layoutFrame) layoutFrame = view.requestAnimationFrame(() => {
|
|
80
|
+
layoutFrame = 0;
|
|
81
|
+
updatePreviewLayout();
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
const replayMotion = () => {
|
|
85
|
+
const element = root.querySelector('[data-motion-object]');
|
|
86
|
+
const duration = parseFloat(convertValueUnit(recipe.values['response-time'], 'ms', unitContext()));
|
|
87
|
+
motionAnimation?.cancel();
|
|
88
|
+
const available = Math.max(0, element.parentElement.clientWidth - element.offsetWidth - 2 * parseFloat(view.getComputedStyle(element.parentElement).paddingInlineStart));
|
|
89
|
+
const start = available * .3, end = available * .7;
|
|
90
|
+
element.style.transform = `translateX(${start}px)`;
|
|
91
|
+
if (motionPreference.matches) return;
|
|
92
|
+
motionAnimation = element.animate([{ transform: `translateX(${start}px)` }, { transform: `translateX(${end}px)` }], { duration: Math.max(0, duration) * 3, easing: recipe.values['response-curve'], iterations: 2, direction: 'alternate', fill: 'none' });
|
|
93
|
+
};
|
|
94
|
+
const paint = () => {
|
|
95
|
+
const values = recipe.values;
|
|
96
|
+
for (const sample of root.querySelectorAll('[data-extra-accent]')) sample.hidden = values[sample.dataset.extraAccent] === 'none';
|
|
97
|
+
for (const readout of root.querySelectorAll('[data-palette-roles]')) {
|
|
98
|
+
const { tokens } = deriveTheme(recipe, { mode: readout.dataset.paletteRoles });
|
|
99
|
+
readout.textContent = `Shared border ${tokens['--ui-control-border']} · minimum ${values['neutral-border-contrast']}:1. `;
|
|
100
|
+
}
|
|
101
|
+
const followsBody = values['label-size'] === 'inherit';
|
|
102
|
+
if (!followsBody) lastLabelSize = values['label-size'];
|
|
103
|
+
const labelSize = root.querySelector('[data-label-size-value]');
|
|
104
|
+
labelSize.hidden = followsBody;
|
|
105
|
+
const labelValue = followsBody ? lastLabelSize ?? values['text-size'] : values['label-size'];
|
|
106
|
+
componentRegistry.update(labelSize, { value: labelValue, unit: labelValue.match(/[a-z]+$/i)?.[0] ?? 'px' });
|
|
107
|
+
componentRegistry.update(root.querySelector('[data-label-size-source]'), { value: followsBody ? 'inherit' : 'custom' });
|
|
108
|
+
const summaries = {
|
|
109
|
+
palette: `${themePalettes[refinementState(recipe, 'palette').id]?.label ?? 'Custom colors'}${refinementState(recipe, 'palette').customized ? ' · customized' : ''}`,
|
|
110
|
+
material: `${Math.round(Number(values.finish) * 100)}% finish · ${Math.round(Number(values.translucency) * 100)}% transparent`,
|
|
111
|
+
typography: `${recipe.fonts?.['body-font'] ?? values['body-font'].split(',')[0]} · ${values['text-size']}`,
|
|
112
|
+
layout: `${Number(values.rhythm) * Number(values.spacing) < .95 ? 'Compact' : Number(values.rhythm) * Number(values.spacing) > 1.2 ? 'Relaxed' : 'Comfortable'} · ${parseFloat(values['corner-roundness']) === 0 ? 'Square' : 'Rounded'} corners`,
|
|
113
|
+
motion: `${values['response-time']} · ${values['response-curve'].startsWith('cubic-bezier') ? 'Custom curve' : values['response-curve']}`,
|
|
114
|
+
};
|
|
115
|
+
root.querySelector('[data-active-summary]').textContent = summaries[view.location.hash.slice(1)] ?? Object.values(summaries).slice(0,2).join(' · ');
|
|
116
|
+
for (const [family, text] of Object.entries(summaries)) root.querySelector(`[data-summary="${family}"]`).textContent = text;
|
|
117
|
+
root.querySelector('[data-motion-readout]').textContent = `${values['response-time']} feedback · ${parseFloat(convertValueUnit(values['response-time'], 'ms', unitContext())) * 3}ms travel`;
|
|
118
|
+
componentRegistry.update(root.querySelector('[data-action="replay-motion"]'), { disabled: motionPreference.matches });
|
|
119
|
+
root.querySelector('[data-motion-preference]').textContent = motionPreference.matches ? 'Reduced motion is on. The study stays still.' : 'Travel is three times the feedback duration, like a larger surface transition.';
|
|
120
|
+
|
|
121
|
+
const css = theme?.css || themeCSS(recipe);
|
|
122
|
+
if (boundsBase !== recipe.values['text-size']) {
|
|
123
|
+
boundsBase = recipe.values['text-size'];
|
|
124
|
+
for (const control of root.querySelectorAll('[data-token]')) {
|
|
125
|
+
const field = fields.get(control.dataset.token);
|
|
126
|
+
if (field?.type.includes('length') && field.id !== 'text-size') updateControl(control, recipe.values[field.id]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const family of ['material','palette','status']) {
|
|
130
|
+
const state = refinementState(recipe, family), presets = { material: materialPresets, palette: themePalettes, status: statusPresets }[family];
|
|
131
|
+
for (const select of root.querySelectorAll(`[data-refinement-preset="${family}"]`)) {
|
|
132
|
+
for (const option of select.querySelectorAll('option')) if (presets[option.value]) {
|
|
133
|
+
const label = presets[option.value].label + (state.id === option.value && state.customized ? ' · customized' : '');
|
|
134
|
+
if (option.textContent !== label) option.textContent = label;
|
|
135
|
+
}
|
|
136
|
+
updateControl(select, state.id);
|
|
137
|
+
const origin = startingPoints[recipe.origin ?? startingPointFor(recipe)];
|
|
138
|
+
const muted = Boolean(origin && Object.keys(Object.values(presets)[0].values).every(key => origin.values[key] === recipe.values[key]));
|
|
139
|
+
componentRegistry.update(select, { muted });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const control of root.querySelectorAll('[data-font-picker]')) {
|
|
143
|
+
control.querySelector('option[value=custom]').textContent = recipe.values[control.dataset.fontPicker].split(',')[0].replaceAll('"', '').trim();
|
|
144
|
+
componentRegistry.update(control, { value: control.dataset.fontPicker === 'display-font' && recipe.values['display-font'] === recipe.values['body-font'] ? 'inherit' : recipe.fonts?.[control.dataset.fontPicker] ?? (recipe.values[control.dataset.fontPicker] === systemFont ? 'system' : 'custom') });
|
|
145
|
+
}
|
|
146
|
+
componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
|
|
147
|
+
root.querySelector('[data-glass-hint]').hidden = !(Number(recipe.values.glassiness) > 0 && Number(recipe.values.translucency) === 0);
|
|
148
|
+
const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
|
|
149
|
+
const report = root.querySelector('[data-palette-adjustments]');
|
|
150
|
+
report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your base colors stay editable; resolved values are shown below.' : 'Both ranges preserve your base colors with readable text and essential outlines.';
|
|
151
|
+
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('; ') + '. Base colors remain editable.' : 'Both ranges preserve your base colors with readable text and essential outlines.';
|
|
152
|
+
const cssControl = root.querySelector('[data-css]');
|
|
153
|
+
if (cssControl.getClientRects().length) updateControl(cssControl, css);
|
|
154
|
+
for (const action of ['undo','redo']) componentRegistry.update(root.querySelector(`[data-action="${action}"]`), { disabled: !theme?.[action === 'undo' ? 'canUndo' : 'canRedo'] });
|
|
155
|
+
for (const button of root.querySelectorAll('[data-relationship]')) button.setAttribute('aria-pressed', String(button.dataset.relationship === recipe.values['palette-relationship']));
|
|
156
|
+
for (const sample of root.querySelectorAll('[data-relationship-preview]')) {
|
|
157
|
+
if (!sample.getClientRects().length) continue;
|
|
158
|
+
const tokens = deriveTheme({ ...recipe, values: { ...recipe.values, 'palette-relationship': sample.dataset.relationshipPreview } }, { mode: 'light' }).tokens;
|
|
159
|
+
for (const [role, token] of [['canvas','--ui-surface'],['content','--ui-surface-raised'],['action','--ui-accent']]) sample.style.setProperty('--_palette-' + role, tokens[token]);
|
|
160
|
+
}
|
|
161
|
+
for (const indicator of root.querySelectorAll('[data-relationship-selected]')) indicator.hidden = indicator.dataset.relationshipSelected !== recipe.values['palette-relationship'];
|
|
162
|
+
root.querySelector('[data-material-dependencies]').textContent = Number(recipe.values['light-strength']) === 0
|
|
163
|
+
? '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.'
|
|
164
|
+
: 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.'
|
|
165
|
+
: 'Finish catches the light. Spread shapes its reflection; strength affects reflection, edge light and glow together.';
|
|
166
|
+
const gaps = root.querySelector('[data-spacing-samples]');
|
|
167
|
+
gaps.setAttribute('aria-label', `Spacing progression with curve ${recipe.values['space-curve']}`);
|
|
168
|
+
updateSizes();
|
|
169
|
+
updatePreviewLayout();
|
|
170
|
+
const nextMotion = `${values['response-time']}/${values['response-curve']}`;
|
|
171
|
+
if (!root.querySelector('[data-refinement-view="motion"]').hidden && motionSignature !== nextMotion) {
|
|
172
|
+
motionSignature = nextMotion; replayMotion();
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
const show = (next, persist = true) => {
|
|
176
|
+
recipe = validateRecipe(next);
|
|
177
|
+
if (persist) theme?.apply(recipe);
|
|
178
|
+
for (const control of root.querySelectorAll('[data-token]')) updateControl(control, recipe.values[control.dataset.token]);
|
|
179
|
+
updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
|
|
180
|
+
updateUnits();
|
|
181
|
+
changed = JSON.stringify(recipe) !== JSON.stringify(saved);
|
|
182
|
+
paint();
|
|
183
|
+
};
|
|
184
|
+
const preview = next => {
|
|
185
|
+
const validated = validateRecipe(next);
|
|
186
|
+
if (JSON.stringify(validated.values) === JSON.stringify(recipe.values)) return;
|
|
187
|
+
if (!pending) gestureStart = structuredClone(recipe);
|
|
188
|
+
recipe = validated; pending = true;
|
|
189
|
+
if (!previewFrame) previewFrame = view.requestAnimationFrame(() => {
|
|
190
|
+
previewFrame = 0;
|
|
191
|
+
theme?.preview(recipe);
|
|
192
|
+
show(recipe, false);
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
const flush = (commit = true) => {
|
|
196
|
+
if (previewFrame) view.cancelAnimationFrame(previewFrame);
|
|
197
|
+
previewFrame = 0;
|
|
198
|
+
if (!pending) return;
|
|
199
|
+
pending = false;
|
|
200
|
+
const netChange = JSON.stringify(recipe.values) !== JSON.stringify(gestureStart.values);
|
|
201
|
+
if (commit && netChange) theme?.apply(recipe); else theme?.cancel();
|
|
202
|
+
show(theme?.recipe ?? (netChange && commit ? recipe : gestureStart), false);
|
|
203
|
+
gestureStart = undefined;
|
|
204
|
+
};
|
|
205
|
+
const filterExamples = () => {
|
|
206
|
+
const query = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim();
|
|
207
|
+
if (query !== previewQuery) { previewQuery = query; previewLimit = 9; }
|
|
208
|
+
const words = query.split(/\s+/).filter(Boolean), seen = new Set(), identities = new Set(), matches = [];
|
|
209
|
+
const include = (example, chosen = false) => {
|
|
210
|
+
const text = [example.title, example.tag, example.description, example.exampleTitle, example.category].join(' ').toLocaleLowerCase();
|
|
211
|
+
if (removedExamples.has(example.id) || identities.has(example.id) || (!chosen && seen.has(example.tag)) || !words.every(word => text.includes(word))) return;
|
|
212
|
+
identities.add(example.id); seen.add(example.tag); matches.push(example);
|
|
213
|
+
};
|
|
214
|
+
host.state.cards.forEach(example => include(example, true));
|
|
215
|
+
catalogExamples.forEach(example => include(example));
|
|
216
|
+
host.state.previews = matches.slice(0, previewLimit);
|
|
217
|
+
const visible = Math.min(matches.length, previewLimit);
|
|
218
|
+
root.querySelector('[data-example-count]').textContent = visible ? `Showing ${visible} of ${matches.length} preview${matches.length === 1 ? '' : 's'}` : 'No components match. Try another name, tag or purpose.';
|
|
219
|
+
root.querySelector('[data-action="more-examples"]').hidden = visible >= matches.length;
|
|
220
|
+
view.requestAnimationFrame(paint);
|
|
221
|
+
componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: !selectedExample || host.state.cards.some(item => item.id === selectedExample) });
|
|
222
|
+
};
|
|
223
|
+
const refinedRecipe = values => {
|
|
224
|
+
if (recipe.values['display-font'] === recipe.values['body-font'] && values['display-font'] === recipe.values['display-font']) values = { ...values, 'display-font': values['body-font'] };
|
|
225
|
+
const presets = { ...recipe.presets };
|
|
226
|
+
for (const family of ['material', 'palette', 'status']) {
|
|
227
|
+
const state = refinementState(recipe, family);
|
|
228
|
+
if (!presets[family] && state.id !== 'custom') presets[family] = state.id;
|
|
229
|
+
}
|
|
230
|
+
const origin = recipe.origin ?? (startingPointFor(recipe) === 'custom' ? undefined : startingPointFor(recipe));
|
|
231
|
+
const fonts = { ...recipe.fonts };
|
|
232
|
+
for (const key of Object.keys(fonts)) if (values[key] !== googleFontStack(fonts[key])) delete fonts[key];
|
|
233
|
+
return { ...recipe, fonts, ...(origin ? { origin } : {}), presets, values };
|
|
234
|
+
};
|
|
235
|
+
const input = event => {
|
|
236
|
+
if (event.target.matches('[data-label-size-value]')) {
|
|
237
|
+
try { preview(refinedRecipe({ ...recipe.values, 'label-size': String(event.detail?.value ?? event.target.value) })); }
|
|
238
|
+
catch (error) { say(error.message, true); }
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
|
|
242
|
+
if (event.target.matches('[data-light-editor]')) {
|
|
243
|
+
try {
|
|
244
|
+
preview(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
|
|
245
|
+
} catch (error) { say(error.message, true); }
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
const control = event.target.closest?.('[data-token]');
|
|
249
|
+
if (!control) return;
|
|
250
|
+
const field = fields.get(control.dataset.token);
|
|
251
|
+
const raw = event.detail?.value ?? control.value;
|
|
252
|
+
const value = field.type === 'optional-colour' && raw === '' ? field.defaultValue : control.dataset.editorKind ? editorValue(field, raw) : String(raw);
|
|
253
|
+
const next = refinedRecipe({ ...recipe.values, [control.dataset.token]: value });
|
|
254
|
+
try {
|
|
255
|
+
preview(next); changed = JSON.stringify(recipe) !== JSON.stringify(saved);
|
|
256
|
+
updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
|
|
257
|
+
control.removeAttribute('aria-invalid');
|
|
258
|
+
for (const sibling of root.querySelectorAll('[data-token]')) if (sibling.dataset.token === control.dataset.token) updateControl(sibling, value);
|
|
259
|
+
updateUnits();
|
|
260
|
+
say('Previewing your theme. Finish the edit to remember it.');
|
|
261
|
+
} catch (error) { control.setAttribute('aria-invalid', 'true'); say(error.message, true); }
|
|
262
|
+
};
|
|
263
|
+
const download = (data, name, type) => {
|
|
264
|
+
const url = URL.createObjectURL(new Blob([data], { type }));
|
|
265
|
+
const link = document.createElement('a'); link.href = url; link.download = name; link.click();
|
|
266
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
267
|
+
};
|
|
268
|
+
const renderView = () => {
|
|
269
|
+
const family = view.location.hash.slice(1);
|
|
270
|
+
const section = [...root.querySelectorAll('[data-refinement-view]')].find(element => element.dataset.refinementView === family);
|
|
271
|
+
const overview = root.querySelector('[data-customizer-overview]');
|
|
272
|
+
if (section) refinementTrigger = root.querySelector(`[data-action="refine-${family}"]`);
|
|
273
|
+
for (const element of root.querySelectorAll('[data-refinement-view]')) element.hidden = element !== section;
|
|
274
|
+
overview.hidden = Boolean(section);
|
|
275
|
+
for (const button of root.querySelectorAll('[data-customizer-summary] [data-action^="refine-"]')) button.setAttribute('aria-pressed', String(button.dataset.action === `refine-${family}`));
|
|
276
|
+
motionAnimation?.cancel();
|
|
277
|
+
if (family === 'motion') motionSignature = undefined;
|
|
278
|
+
paint();
|
|
279
|
+
if (section) { section.focus(); section.scrollIntoView({ block: 'start' }); }
|
|
280
|
+
else { (['refine-components','refine-export'].includes(refinementTrigger?.dataset.action) ? root.querySelector('#theme-tools') : refinementTrigger)?.focus({ preventScroll: true }); view.scrollTo({ top: overviewScroll, behavior: 'instant' }); }
|
|
281
|
+
};
|
|
282
|
+
const navigate = family => {
|
|
283
|
+
flush();
|
|
284
|
+
view.history.pushState(null, '', view.location.pathname + view.location.search + (family ? '#' + family : ''));
|
|
285
|
+
renderView();
|
|
286
|
+
};
|
|
287
|
+
const click = async event => {
|
|
288
|
+
const button = event.target.closest?.('[data-action]');
|
|
289
|
+
if (!button) { view.requestAnimationFrame(paint); return; }
|
|
290
|
+
if (button.dataset.action === 'save' && saving) return;
|
|
291
|
+
if (button.matches('a')) event.preventDefault();
|
|
292
|
+
flush();
|
|
293
|
+
try {
|
|
294
|
+
switch (button.dataset.action) {
|
|
295
|
+
case 'replay-motion': replayMotion(); break;
|
|
296
|
+
case 'undo': case 'redo':
|
|
297
|
+
theme?.[button.dataset.action](); show(theme?.recipe ?? recipe, false); sayApplied('Previous theme decision restored.'); break;
|
|
298
|
+
case 'refine-material': case 'refine-palette': case 'refine-typography': case 'refine-layout': case 'refine-motion': case 'refine-export': case 'refine-components': {
|
|
299
|
+
const family = button.dataset.action.slice(7);
|
|
300
|
+
refinementTrigger = button;
|
|
301
|
+
if (!root.querySelector('[data-customizer-overview]').hidden) overviewScroll = view.scrollY;
|
|
302
|
+
navigate(family);
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
case 'back': navigate(''); break;
|
|
306
|
+
case 'use-glass': show(applyRefinement(recipe, 'material', 'glass')); sayApplied('Frosted glass applied. Your colors and type are unchanged.'); break;
|
|
307
|
+
case 'add': {
|
|
308
|
+
const option = [...root.querySelectorAll('[data-add-example] option')].find(option => option.value === selectedExample);
|
|
309
|
+
if (!option) break;
|
|
310
|
+
const selected = JSON.parse(option.dataset.example);
|
|
311
|
+
const card = Object.fromEntries(['id','title','description','exampleTitle','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
|
|
312
|
+
removedExamples.delete(card.id);
|
|
313
|
+
if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [card, ...host.state.cards];
|
|
314
|
+
updateControl(root.querySelector('[data-example-query]'), ''); previewQuery = '';
|
|
315
|
+
previewLimit = Math.max(previewLimit, host.state.cards.length);
|
|
316
|
+
say(`${card.title} · ${card.exampleTitle} added to preview.`);
|
|
317
|
+
view.requestAnimationFrame(() => { paint(); filterExamples(); }); break;
|
|
318
|
+
}
|
|
319
|
+
case 'more-examples': previewLimit += 9; filterExamples(); break;
|
|
320
|
+
case 'css': {
|
|
321
|
+
const response = await fetch(root.dataset.base + '_looma/theme-system.css', { signal: abort.signal });
|
|
322
|
+
if (!response.ok) throw new Error('Could not load the shared theme recipe.');
|
|
323
|
+
download(themeCSS(recipe, await response.text()), 'looma.theme.css', 'text/css'); say('Theme CSS downloaded.'); break;
|
|
324
|
+
}
|
|
325
|
+
case 'recipe': download(JSON.stringify(recipe, null, 2) + '\n', 'looma.theme.json', 'application/json'); say('Editable recipe downloaded.'); break;
|
|
326
|
+
case 'import': file.click(); break;
|
|
327
|
+
case 'reset': {
|
|
328
|
+
const origin = recipe.origin ?? startingPointFor(recipe);
|
|
329
|
+
show(startingPoints[origin] ? { ...structuredClone(startingPoints[origin]), origin } : structuredClone(saved));
|
|
330
|
+
sayApplied('Restored this theme’s starting values. Undo brings your changes back.'); break;
|
|
331
|
+
}
|
|
332
|
+
case 'palette-relationship': show(refinedRecipe({ ...recipe.values, 'palette-relationship': button.dataset.relationship })); sayApplied('Color relationship applied to both modes.'); break;
|
|
333
|
+
case 'save': {
|
|
334
|
+
if (!capability) throw new Error('Repository saving is available only in the local documentation server.');
|
|
335
|
+
const submitted = structuredClone(recipe);
|
|
336
|
+
saving = true;
|
|
337
|
+
say('Saving your theme and rebuilding the documentation…');
|
|
338
|
+
const response = await fetch(root.dataset.base + '_looma/theme', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Looma-Capability': capability }, body: JSON.stringify({ recipe: submitted, revision }), signal: abort.signal });
|
|
339
|
+
const result = await response.json();
|
|
340
|
+
if (!response.ok) throw new Error(result.error ?? 'Could not save this theme.');
|
|
341
|
+
revision = result.revision; saved = submitted; theme?.baseline(saved);
|
|
342
|
+
changed = JSON.stringify(recipe) !== JSON.stringify(saved);
|
|
343
|
+
if (changed) { say('Saved the submitted theme. Your newer edits are still an unsaved preview.'); break; }
|
|
344
|
+
say('Saved to your repository. Reloading your design system…');
|
|
345
|
+
view.location.reload(); break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch (error) { if (error.name !== 'AbortError' && alive) say(error.message, true); }
|
|
349
|
+
finally { if (button.dataset.action === 'save') saving = false; }
|
|
350
|
+
};
|
|
351
|
+
const change = async event => {
|
|
352
|
+
const value = event.detail?.value ?? event.target.value;
|
|
353
|
+
if (event.target.matches('[data-light-editor], [data-label-size-value]') || event.target.closest?.('[data-token]')) {
|
|
354
|
+
input(event); flush(); sayApplied('Saved in this browser.'); return;
|
|
355
|
+
}
|
|
356
|
+
flush();
|
|
357
|
+
if (event.target.matches('[data-label-size-source]')) {
|
|
358
|
+
show(refinedRecipe({ ...recipe.values, 'label-size': value === 'inherit' ? 'inherit' : lastLabelSize ?? recipe.values['text-size'] }));
|
|
359
|
+
sayApplied('Field label size updated and remembered in this browser.');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (event.target.matches('[data-unit-for]')) {
|
|
363
|
+
const field = fields.get(event.target.dataset.unitFor);
|
|
364
|
+
try {
|
|
365
|
+
let current = recipe.values[field.id];
|
|
366
|
+
try { current = convertValueUnit(current, event.target.value, unitContext()); }
|
|
367
|
+
catch { current = convertValueUnit(view.getComputedStyle(document.documentElement).getPropertyValue(field.property).trim(), event.target.value, unitContext()); }
|
|
368
|
+
show({ ...recipe, values: { ...recipe.values, [field.id]: current } });
|
|
369
|
+
sayApplied(`${field.label} units updated and remembered in this browser.`);
|
|
370
|
+
} catch (error) { updateUnits(); say(error.message, true); }
|
|
371
|
+
}
|
|
372
|
+
if (event.target.matches('[data-refinement-preset]')) {
|
|
373
|
+
const family = event.target.dataset.refinementPreset;
|
|
374
|
+
show(applyRefinement(recipe, family, value));
|
|
375
|
+
sayApplied(`${{ material: 'Material', palette: 'Coordinated palette', status: 'Status color relationship' }[family]} preset applied and remembered in both modes.`);
|
|
376
|
+
}
|
|
377
|
+
if (event.target.matches('[data-preset]')) { show({ ...structuredClone(startingPoints[value]), origin: value }); sayApplied('Saved in this browser.'); }
|
|
378
|
+
if (event.target === file && file.files[0]) {
|
|
379
|
+
try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied. Saved in this browser.'); }
|
|
380
|
+
catch (error) { say(error.message, true); }
|
|
381
|
+
file.value = '';
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
const choose = event => {
|
|
385
|
+
if (!event.target.matches('[data-choice]') || event.detail.kind !== 'selection') return;
|
|
386
|
+
event.target.dataset.selectedValue = event.detail.value;
|
|
387
|
+
change(event);
|
|
388
|
+
};
|
|
389
|
+
const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
|
|
390
|
+
const synchronize = () => { if (!pending && theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
|
|
391
|
+
const cancel = event => { if (event.target.matches('[data-light-editor], [data-editor-kind="curve"]')) { flush(false); say('Drag cancelled; the previous value is restored.'); } };
|
|
392
|
+
const keyboard = event => {
|
|
393
|
+
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z' || event.target.closest?.('input,textarea,[contenteditable="true"]')) return;
|
|
394
|
+
event.preventDefault(); flush(); theme?.[event.shiftKey ? 'redo' : 'undo'](); show(theme?.recipe ?? recipe, false);
|
|
395
|
+
};
|
|
396
|
+
const remove = event => {
|
|
397
|
+
const example = catalogExamples.find(card => card.id === event.detail.id);
|
|
398
|
+
const chosen = host.state.cards.some(card => card.id === event.detail.id);
|
|
399
|
+
host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id);
|
|
400
|
+
const siblingRemains = host.state.cards.some(card => card.tag === example?.tag);
|
|
401
|
+
for (const card of catalogExamples) if (card.tag === example?.tag && (!siblingRemains || card.id === event.detail.id)) removedExamples.add(card.id);
|
|
402
|
+
if (chosen) previewLimit = Math.max(9, previewLimit - 1);
|
|
403
|
+
view.requestAnimationFrame(filterExamples);
|
|
404
|
+
};
|
|
405
|
+
let fontRequest = 0;
|
|
406
|
+
const selectFont = async event => {
|
|
407
|
+
const control = event.target.closest?.('[data-font-picker]'); if (!control) return;
|
|
408
|
+
flush();
|
|
409
|
+
const id = control.dataset.fontPicker, family = event.detail.value;
|
|
410
|
+
const fonts = { ...recipe.fonts };
|
|
411
|
+
if (family === 'system' || family === 'inherit') delete fonts[id]; else fonts[id] = family;
|
|
412
|
+
const value = family === 'inherit' ? recipe.values['body-font'] : family === 'system' ? systemFont : googleFontStack(family);
|
|
413
|
+
const linked = recipe.values['display-font'] === recipe.values['body-font'];
|
|
414
|
+
if (id === 'body-font' && linked) delete fonts['display-font'];
|
|
415
|
+
const next = { ...refinedRecipe({ ...recipe.values, [id]: value }), fonts };
|
|
416
|
+
if (id === 'display-font' && family === 'inherit' && fonts['body-font']) next.fonts['display-font'] = fonts['body-font'];
|
|
417
|
+
const status = root.querySelector(`[data-font-status="${id}"]`), request = ++fontRequest;
|
|
418
|
+
show(next); status.hidden = false; status.textContent = family === 'inherit' ? 'Headings use the main font.' : family === 'system' ? 'Using your operating system’s native font.' : `Loading ${family} from Google Fonts…`;
|
|
419
|
+
try {
|
|
420
|
+
await loadGoogleFonts(document, fonts);
|
|
421
|
+
if (alive && request === fontRequest) for (const [field, selected] of Object.entries(fonts)) {
|
|
422
|
+
const target = root.querySelector(`[data-font-status="${field}"]`); target.hidden = false; target.textContent = `${selected} loaded.`;
|
|
423
|
+
}
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (alive && request === fontRequest) for (const field of Object.keys(fonts)) { const target=root.querySelector(`[data-font-status="${field}"]`); target.hidden=false; target.textContent=error.message; }
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
const selectExample = event => {
|
|
429
|
+
if (!event.target.matches('[data-add-example]')) return;
|
|
430
|
+
selectedExample = event.detail.value ?? '';
|
|
431
|
+
filterExamples();
|
|
432
|
+
};
|
|
433
|
+
const motionPreferenceChanged = () => { motionAnimation?.cancel(); paint(); };
|
|
434
|
+
// Observe visible option names only; preview faces never replace selected full fonts.
|
|
435
|
+
const fontOptions = new Map();
|
|
436
|
+
const fontObserver = new view.IntersectionObserver(entries => {
|
|
437
|
+
for (const { target, isIntersecting } of entries) {
|
|
438
|
+
if (!isIntersecting) continue;
|
|
439
|
+
fontObserver.unobserve(target);
|
|
440
|
+
const family = fontOptions.get(target);
|
|
441
|
+
loadGoogleFontPreview(document, family).then(alias => {
|
|
442
|
+
if (alive && root.contains(target) && fontOptions.get(target) === family)
|
|
443
|
+
target.style.setProperty('--_font-preview-family', `"${alias}", ${googleFonts[family].category}`);
|
|
444
|
+
}).catch(() => {}); // A failed decorative preview keeps the readable inherited font.
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
const observeFontOptions = () => {
|
|
448
|
+
for (const [node] of fontOptions) if (!root.contains(node)) { fontObserver.unobserve(node); fontOptions.delete(node); }
|
|
449
|
+
for (const node of root.querySelectorAll('[data-font-preview]')) {
|
|
450
|
+
const family = node.dataset.fontPreview;
|
|
451
|
+
if (fontOptions.get(node) === family) continue;
|
|
452
|
+
fontObserver.unobserve(node); node.style.removeProperty('--_font-preview-family');
|
|
453
|
+
fontOptions.delete(node);
|
|
454
|
+
if (Object.hasOwn(googleFonts, family)) { fontOptions.set(node, family); fontObserver.observe(node); }
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
const fontMutations = new view.MutationObserver(observeFontOptions);
|
|
458
|
+
for (const picker of root.querySelectorAll('[data-font-picker]'))
|
|
459
|
+
fontMutations.observe(picker, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-font-preview'] });
|
|
460
|
+
observeFontOptions();
|
|
461
|
+
const previewObserver = new view.ResizeObserver(schedulePreviewLayout);
|
|
462
|
+
for (const element of [root.querySelector('[data-customizer-summary]'), ...previews, ...(header ? [header] : [])]) previewObserver.observe(element);
|
|
463
|
+
view.addEventListener('resize', schedulePreviewLayout);
|
|
464
|
+
motionPreference.addEventListener('change', motionPreferenceChanged);
|
|
465
|
+
root.addEventListener('toggle', updateSizes, true);
|
|
466
|
+
root.addEventListener('looma-example-remove', remove);
|
|
467
|
+
root.addEventListener('input', input);
|
|
468
|
+
root.addEventListener('cancel', cancel);
|
|
469
|
+
root.addEventListener('keydown', keyboard);
|
|
470
|
+
root.addEventListener('value-change', choose);
|
|
471
|
+
root.addEventListener('value-change', selectExample);
|
|
472
|
+
root.addEventListener('value-change', selectFont);
|
|
473
|
+
root.addEventListener('blur', blur, true);
|
|
474
|
+
root.addEventListener('change', change);
|
|
475
|
+
root.addEventListener('click', click);
|
|
476
|
+
view.addEventListener('popstate', renderView);
|
|
477
|
+
view.addEventListener('hashchange', renderView);
|
|
478
|
+
view.addEventListener('beforeunload', flush);
|
|
479
|
+
view.addEventListener(themeEvent, synchronize);
|
|
480
|
+
show(recipe, false); filterExamples();
|
|
481
|
+
if (view.location.hash) renderView();
|
|
482
|
+
if (theme?.storageAvailable === false) sayApplied('');
|
|
483
|
+
if (root.dataset.mode === 'local') {
|
|
484
|
+
fetch(root.dataset.base + '_looma/session', { signal: abort.signal }).then(async response => {
|
|
485
|
+
if (!response.ok) throw new Error('Local theme saving is unavailable.');
|
|
486
|
+
const local = await response.json(); if (!alive) return;
|
|
487
|
+
capability = local.capability;
|
|
488
|
+
saved = validateRecipe(local.recipe); revision = local.revision; theme?.baseline(saved);
|
|
489
|
+
if (!changed && !theme?.hasOverride) show(saved, false);
|
|
490
|
+
root.querySelector('[data-action="save"]').hidden = false;
|
|
491
|
+
}).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
|
|
492
|
+
}
|
|
493
|
+
return () => { fontMutations.disconnect(); fontObserver.disconnect(); fontOptions.clear(); previewObserver.disconnect(); if (layoutFrame) view.cancelAnimationFrame(layoutFrame); view.removeEventListener('resize', schedulePreviewLayout); motionPreference.removeEventListener('change', motionPreferenceChanged); root.removeEventListener('toggle', updateSizes, true); motionAnimation?.cancel(); flush(); 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', choose); 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('popstate', renderView); view.removeEventListener('hashchange', renderView); view.removeEventListener(themeEvent, synchronize); };
|
|
494
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
:root { --markupress-header-notice-display: none; }
|
|
2
|
+
:root[data-looma-documentation-theme] { --markupress-header-notice-display: inline-flex; }
|
|
3
|
+
/* Documentation presentation owns its native example stage. Controls use Looma. */
|
|
4
|
+
[data-example-card] pre { overflow: auto; }
|
|
5
|
+
/* The customizer is an application page; use Markupress's public width token instead of patching its layout. */
|
|
6
|
+
:root:has([data-looma-customizer]), :root:has([component~="looma-catalog"]) { --markupress-content-width: 110rem; }
|
|
7
|
+
[data-preview] { display: grid; grid-template-columns: minmax(0, 1fr) !important; place-items: center; gap: var(--ui-space-4); min-inline-size: 0; min-block-size: 13rem; padding: var(--ui-space-4); box-sizing: border-box; color: var(--ui-text); font-family: var(--ui-font-sans); font-size: var(--ui-font-size); line-height: var(--ui-line-height); background: var(--ui-surface-raised); }
|
|
8
|
+
[data-preview-layout] { justify-items: stretch; }
|
|
9
|
+
|
|
10
|
+
/* These headings are authored page content projected through the example card's heading slot. */
|
|
11
|
+
.looma-reference-examples { padding-block-start: var(--ui-space-5); }
|
|
12
|
+
.looma-example-heading h3 { margin: 0; font-size: var(--ui-font-size-lg); line-height: var(--ui-line-height-tight); }
|
|
13
|
+
.looma-example-heading p { margin: var(--ui-space-2) 0 0; font-size: var(--ui-font-size-sm); color: var(--ui-text-secondary); }
|
|
14
|
+
[data-example-card][reference] [data-preview] { min-block-size: 11rem; background: var(--ui-surface-raised); }
|
|
15
|
+
|
|
16
|
+
/* Guides share Markupress's shell proportions and keep prose at a reading measure. */
|
|
17
|
+
:root:has([data-looma-guide]) {
|
|
18
|
+
--markupress-page-width: 96rem;
|
|
19
|
+
--markupress-content-width: 68rem;
|
|
20
|
+
--markupress-sidebar-width: 15rem;
|
|
21
|
+
--markupress-outline-width: 11rem;
|
|
22
|
+
}
|
|
23
|
+
[data-looma-guide] > p { max-inline-size: 68ch; }
|
|
24
|
+
[data-looma-guide] > h1 { max-inline-size: 20ch; margin-block-end: var(--ui-space-4); }
|
|
25
|
+
[data-looma-guide] > h1 + p { max-inline-size: 60ch; font-size: var(--ui-font-size-lg); color: var(--ui-text-secondary); margin-block-end: var(--ui-space-6); }
|
|
26
|
+
[data-looma-guide] > h2 { margin-block: calc(var(--ui-space-6) * 1.5) var(--ui-space-4); padding-block-start: var(--ui-space-5); border-block-start: var(--ui-border-width) solid var(--ui-border); font-size: 1.4em; }
|
|
27
|
+
.looma-guide-start > h2 { margin-block: 0 var(--ui-space-4); font-size: 1.4em; }
|
|
28
|
+
.looma-guide-start > :last-child { margin-block-end: 0; }
|
|
29
|
+
.looma-guide-figure { margin: 0; }
|
|
30
|
+
.looma-guide-figure > p { margin: 0; }
|
|
31
|
+
.looma-guide-figure img { display: block; inline-size: 100%; block-size: auto; border-radius: var(--ui-radius-sm); }
|
|
32
|
+
.looma-guide-figure > figcaption { margin-block-start: var(--ui-space-3); font-size: var(--ui-font-size-sm); color: var(--ui-text-muted); }
|
|
33
|
+
[data-looma-guide] > table { inline-size: 100%; margin-block: var(--ui-space-5) var(--ui-space-6); }
|
|
34
|
+
[data-looma-guide] > table th { background: transparent; color: var(--ui-text-muted); font-size: .85em; font-weight: var(--ui-font-medium); }
|
|
35
|
+
[data-looma-guide] > table :is(th, td) { padding: var(--ui-space-4) var(--ui-space-3); }
|
|
36
|
+
[data-looma-guide] > table td:first-child { font-weight: var(--ui-font-medium); }
|
|
37
|
+
[data-looma-guide] > table code { font-size: .85em; }
|
|
38
|
+
@media (max-width: 48rem) {
|
|
39
|
+
[data-looma-guide] > h1 { font-size: 2.25rem; }
|
|
40
|
+
[data-looma-guide] > h1 + p { font-size: 1.05em; }
|
|
41
|
+
[data-looma-guide] > table :is(th, td) { padding-inline: var(--ui-space-2); }
|
|
42
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { parseFragment } from 'parse5';
|
|
2
|
+
import { parseTypedValue } from '@nextwebwg/html-next';
|
|
3
|
+
|
|
4
|
+
export const componentName = tag => tag.split('-').map(word => word[0].toUpperCase() + word.slice(1)).join('');
|
|
5
|
+
export const camel = name => name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
6
|
+
export const nativeBooleans = new Set(['allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate', 'open', 'playsinline', 'readonly', 'required', 'reversed', 'selected']);
|
|
7
|
+
const scalarTypes = new Set(['boolean', 'number', 'string']);
|
|
8
|
+
|
|
9
|
+
/** Parse authored invocations once; every printer consumes the same props, slot groups and text. */
|
|
10
|
+
export function exampleInvocations(markup, components) {
|
|
11
|
+
const byTag = new Map(components.map(component => [component.tag, component]));
|
|
12
|
+
const used = new Set();
|
|
13
|
+
|
|
14
|
+
function readAttribute(input, component, attributes) {
|
|
15
|
+
const contract = component?.attributes?.find(value => value.name === input.name);
|
|
16
|
+
// Catalogs carry the parser's type node. `type` remains the human-readable API description.
|
|
17
|
+
let type = contract?.valueType ?? (scalarTypes.has(contract?.type) ? contract.type : undefined);
|
|
18
|
+
if (type?.kind === 'selected') {
|
|
19
|
+
const selector = component.attributes.find(candidate => (candidate.property ?? candidate.name) === type.from);
|
|
20
|
+
const supplied = attributes.find(candidate => candidate.name === selector?.name);
|
|
21
|
+
const selected = supplied ? readAttribute(supplied, component, attributes).value : selector?.default;
|
|
22
|
+
type = type.options.find(option => option.value === selected)?.type;
|
|
23
|
+
}
|
|
24
|
+
let value = input.value;
|
|
25
|
+
if (type) {
|
|
26
|
+
const parsed = parseTypedValue(value, type, `${component.tag}.${contract.property ?? input.name}`, 'html');
|
|
27
|
+
if (!parsed.ok) throw new TypeError(parsed.issues.map(issue => `${issue.path}: ${issue.message}`).join('; '));
|
|
28
|
+
value = parsed.value;
|
|
29
|
+
} else if (!contract && nativeBooleans.has(input.name)) value = true;
|
|
30
|
+
return { name: input.name, property: contract?.property ?? input.name, value };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function visit(node, omitSlot = false, preserve = false) {
|
|
34
|
+
if (node.nodeName === '#text') return { kind: 'text', value: node.value, preserve };
|
|
35
|
+
if (node.nodeName === '#comment') return { kind: 'comment', value: node.data };
|
|
36
|
+
const component = byTag.get(node.tagName);
|
|
37
|
+
if (component) used.add(component);
|
|
38
|
+
const children = [];
|
|
39
|
+
const slots = new Map();
|
|
40
|
+
for (const child of node.content?.childNodes ?? node.childNodes ?? []) {
|
|
41
|
+
const slotName = component && child.attrs?.find(attribute => attribute.name === 'slot')?.value;
|
|
42
|
+
const projected = visit(child, Boolean(slotName), preserve || ['pre', 'textarea'].includes(node.tagName));
|
|
43
|
+
if (slotName) {
|
|
44
|
+
let slot = slots.get(slotName);
|
|
45
|
+
if (!slot) {
|
|
46
|
+
slot = { kind: 'slot', name: slotName, owner: component, children: [] };
|
|
47
|
+
slots.set(slotName, slot);
|
|
48
|
+
children.push(slot);
|
|
49
|
+
}
|
|
50
|
+
if (child.tagName === 'template') slot.children.push(...projected.children);
|
|
51
|
+
else slot.children.push(projected);
|
|
52
|
+
} else children.push(projected);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
kind: 'element', tag: component ? componentName(component.tag) : node.tagName, component,
|
|
56
|
+
attributes: node.attrs.filter(attribute => !omitSlot || attribute.name !== 'slot').map(value => readAttribute(value, component, node.attrs)),
|
|
57
|
+
children, slots: [...slots.values()],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const nodes = parseFragment(markup).childNodes.map(node => visit(node));
|
|
62
|
+
return { nodes, components: [...used] };
|
|
63
|
+
}
|