@threadlabs/looma 0.33.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +15 -0
  2. package/bin/looma-docs.mjs +3 -0
  3. package/docs/README.md +116 -0
  4. package/docs/card.html +63 -0
  5. package/docs/card.js +32 -0
  6. package/docs/catalog-view.js +18 -0
  7. package/docs/catalog-view.mjs +11 -0
  8. package/docs/catalog.json +22974 -0
  9. package/docs/catalog.mjs +131 -0
  10. package/docs/cli.mjs +26 -0
  11. package/docs/commands.mjs +94 -0
  12. package/docs/customizer-render.mjs +40 -0
  13. package/docs/customizer.js +118 -0
  14. package/docs/documentation.css +8 -0
  15. package/docs/examples/ui-action-bar/examples/01-form-footer.behavior.js +7 -0
  16. package/docs/examples/ui-button/examples/08-loading.behavior.js +21 -0
  17. package/docs/examples/ui-checkbox/examples/02-checked-indeterminate-and-disabled.behavior.js +30 -0
  18. package/docs/examples/ui-combobox/examples/06-selected-values.behavior.js +11 -0
  19. package/docs/examples/ui-search-shell/examples/02-label-dismissible-status-and-footer.behavior.js +34 -0
  20. package/docs/examples/ui-toast/examples/01-authored.behavior.js +15 -0
  21. package/docs/examples/ui-toast/examples/02-danger.behavior.js +2 -0
  22. package/docs/examples/ui-tree/examples/03-max-depth-2-and-hover-expand-delay-300.behavior.js +30 -0
  23. package/docs/examples/ui-tree/examples/06-move-without-dragging.behavior.js +2 -0
  24. package/docs/examples/ui-tree/examples/07-multiple-selection.behavior.js +16 -0
  25. package/docs/examples/ui-tree-item/examples/02-container.behavior.js +1 -0
  26. package/docs/index.d.ts +54 -0
  27. package/docs/index.mjs +4 -0
  28. package/docs/local-server.mjs +68 -0
  29. package/docs/preview.js +20 -0
  30. package/docs/reference.html +1 -0
  31. package/docs/reference.js +36 -0
  32. package/docs/render.mjs +84 -0
  33. package/docs/site.mjs +198 -0
  34. package/docs/store.mjs +92 -0
  35. package/docs/theme.mjs +100 -0
  36. package/docs/workspace.mjs +32 -0
  37. package/package.json +28 -4
@@ -0,0 +1,131 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ export const catalogVersion = 1;
6
+ export const escapeHtml = value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
7
+ export const titleOf = tag => tag.replace(/^ui-/, '').split('-').map(word => word[0].toUpperCase() + word.slice(1)).join(' ');
8
+ export async function readJson(file, fallback) {
9
+ try { return JSON.parse(await readFile(file, 'utf8')); }
10
+ catch (error) { if (error.code === 'ENOENT' && fallback !== undefined) return fallback; throw error; }
11
+ }
12
+
13
+ /** Authored examples are data during discovery; controllers are never evaluated. */
14
+ export async function readExamples(directory, componentId) {
15
+ let files;
16
+ try { files = (await readdir(directory)).sort(); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
17
+ return Promise.all(files.filter(file => /^[^.]+\.html$/.test(file)).map(async file => {
18
+ const name = file.slice(0, -5);
19
+ const source = await readFile(path.join(directory, file), 'utf8');
20
+ const meta = /^<!--\r?\n([\s\S]*?)\r?\n-->\s*/.exec(source);
21
+ if (!meta) throw new Error(`${path.join(directory, file)}: example requires title/description metadata.`);
22
+ const fields = Object.fromEntries(meta[1].split(/\r?\n/).map(line => {
23
+ const at = line.indexOf(':'); return at < 0 ? [] : [line.slice(0, at).trim(), line.slice(at + 1).trim()];
24
+ }).filter(pair => pair.length));
25
+ const frameworks = {};
26
+ for (const [target, extension, language] of [['html-next', '.snippet.html', 'html'], ['vue', '.vue', 'vue']]) {
27
+ if (files.includes(name + extension)) frameworks[target] = { language, code: await readFile(path.join(directory, name + extension), 'utf8') };
28
+ }
29
+ const behavior = files.includes(name + '.behavior.ts') ? path.join(directory, name + '.behavior.ts') : files.includes(name + '.behavior.js') ? path.join(directory, name + '.behavior.js') : undefined;
30
+ return { id: `${componentId}/${name}`, name, title: fields.title ?? name, description: fields.description ?? '', markup: source.slice(meta[0].length).trim(), resizable: fields.preview === 'resizable', bindings: fields.bind ?? '', frameworks, ...(behavior ? { behavior } : {}) };
31
+ }));
32
+ }
33
+
34
+ async function resources(directory) {
35
+ const result = [];
36
+ for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
37
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'examples') continue;
38
+ const file = path.join(directory, entry.name);
39
+ if (entry.isDirectory()) result.push(...await resources(file));
40
+ else if (entry.name.endsWith('.html')) result.push(file);
41
+ }
42
+ return result;
43
+ }
44
+
45
+ /** Discover only explicitly selected roots through HTML Next's public parser. */
46
+ export async function discoverComponents({ root, directory, packageName, groups = [] }) {
47
+ if (!packageName || !directory) throw new Error('A discovery root requires packageName and directory.');
48
+ const { parseComponentResource, typeScriptType } = await import('@nextwebwg/html-next');
49
+ const components = [];
50
+ for (const source of await resources(path.resolve(root, directory))) {
51
+ const html = await readFile(source, 'utf8');
52
+ if (!/<template\b[^>]*\bcomponent=/.test(html)) continue;
53
+ const parsed = parseComponentResource(html, source);
54
+ const metadata = await readJson(path.join(path.dirname(source), 'docs.json'), {});
55
+ for (const definition of parsed.definitions) {
56
+ const contract = definition.contract;
57
+ const docs = metadata.components?.[contract.tag] ?? metadata;
58
+ const id = `${packageName}/${contract.tag}`;
59
+ const properties = Object.entries(contract.props).map(([name, prop]) => ({ name, type: typeScriptType(prop.type), description: prop.description ?? '', ...(Object.hasOwn(prop, 'default') ? { default: prop.default } : {}), ...(prop.values ? { options: prop.values } : {}) }));
60
+ const attributes = properties.map(prop => ({ ...prop, property: prop.name, name: prop.name.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`) }));
61
+ const events = (definition.declarations ?? []).filter(item => item.kind === 'event').map(event => ({ name: event.name, detailType: event.shape ? typeScriptType(event.shape) : String(event.type), detailDocs: docs.events?.[event.name] ?? '' }));
62
+ const slots = (definition.slots ?? []).map(slot => ({ name: slot.name ?? 'default', description: docs.slots?.[slot.name ?? 'default'] ?? '' }));
63
+ const tokens = [...new Set([...definition.css.matchAll(/var\(\s*(--[\w-]+)/g)].map(match => match[1]))].sort();
64
+ components.push({ id, package: packageName, tag: contract.tag, title: docs.title ?? titleOf(contract.tag), description: docs.description ?? contract.summary ?? '', group: docs.group ?? 'project', ...(docs.navigationParent ? { navigationParent: docs.navigationParent } : {}), root: contract.nativeElement, source, resource: source, targets: ['html-next'], attributes, properties, events, slots, designTokens: { sources: [path.relative(root, source)], component: [], shared: tokens.map(name => ({ name, fallbacks: [] })) }, examples: await readExamples(path.join(path.dirname(source), 'examples'), id) });
65
+ }
66
+ }
67
+ const categories = components.some(component => component.group === 'project') && !groups.some(group => group.id === 'project') ? [...groups, { id: 'project', label: 'Project components' }] : groups;
68
+ return { schemaVersion: catalogVersion, package: packageName, groups: categories, components };
69
+ }
70
+
71
+ /** Merge deterministically, reporting both owners instead of silently shadowing resources. */
72
+ export function mergeCatalogs(catalogs, { categories = {} } = {}) {
73
+ const resourceByTag = new Map();
74
+ const exampleOwners = new Map();
75
+ const groups = new Map(); const byId = new Map(); const byTag = new Map();
76
+ for (const catalog of catalogs) {
77
+ if (catalog.schemaVersion !== catalogVersion) throw new Error(`Unsupported documentation catalog version ${catalog.schemaVersion} for ${catalog.package}.`);
78
+ for (const group of catalog.groups ?? []) {
79
+ const mapped = categories[group.id] ?? group.id;
80
+ if (typeof mapped !== 'string' || !/^[a-z][a-z0-9-]*$/.test(mapped)) throw new Error(`Invalid category identity ${JSON.stringify(mapped)} in ${catalog.package}.`);
81
+ const previous = groups.get(mapped);
82
+ if (previous && previous.label !== group.label && mapped === group.id) throw new Error(`Conflicting category ${mapped}: ${previous.label} and ${group.label}. Configure a category mapping explicitly.`);
83
+ if (!previous) groups.set(mapped, { ...group, id: mapped });
84
+ }
85
+ for (const entry of catalog.components) {
86
+ // HTML Next's public component-tag grammar also keeps generated filenames inside their tree.
87
+ if (typeof entry.tag !== 'string' || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/.test(entry.tag)) throw new Error(`Invalid component tag ${JSON.stringify(entry.tag)} in ${catalog.package}.`);
88
+ if (typeof entry.id !== 'string' || !entry.id) throw new Error(`Missing component identity for ${entry.tag} in ${catalog.package}.`);
89
+ const component = { ...entry, group: categories[entry.group] ?? entry.group };
90
+ const previous = byTag.get(component.tag) ?? byId.get(component.id);
91
+ if (previous) throw new Error(`Duplicate component ${component.tag}: ${previous.id} (${previous.source ?? previous.resource}) and ${component.id} (${component.source ?? component.resource}).`);
92
+ byId.set(component.id, component); byTag.set(component.tag, component);
93
+ if (!Array.isArray(component.examples)) throw new Error(`Component ${component.id} requires an examples array.`);
94
+ for (const example of component.examples) {
95
+ if (typeof example.id !== 'string' || !example.id || typeof example.markup !== 'string') throw new Error(`Invalid example identity or markup in ${component.id}.`);
96
+ if (!example.id.startsWith(component.id + '/')) throw new Error(`Example ${example.id} must belong to ${component.id}.`);
97
+ const previous = exampleOwners.get(example.id);
98
+ if (previous) throw new Error(`Duplicate example ${example.id}: ${previous} and ${component.id}.`);
99
+ exampleOwners.set(example.id, component.id);
100
+ }
101
+ }
102
+ for (const entry of [...catalog.resources ?? [], ...catalog.components]) {
103
+ const previous = resourceByTag.get(entry.tag);
104
+ if (previous && (previous.package !== entry.package || previous.resource !== entry.resource)) throw new Error(`Conflicting resource ${entry.tag}: ${previous.resource} and ${entry.resource}.`);
105
+ resourceByTag.set(entry.tag, { tag: entry.tag, package: entry.package, resource: entry.resource });
106
+ }
107
+ }
108
+ const ownerOf = (component, trail = []) => {
109
+ if (trail.includes(component.id)) throw new Error(`Compound parent cycle: ${[...trail, component.id].join(' → ')}.`);
110
+ if (!component.navigationParent) return component;
111
+ const parent = byId.get(component.navigationParent) ?? byTag.get(component.navigationParent);
112
+ if (!parent) throw new Error(`Missing compound parent ${component.navigationParent} for ${component.id}.`);
113
+ return ownerOf(parent, [...trail, component.id]);
114
+ };
115
+ for (const component of byId.values()) {
116
+ if (component.navigationParent) {
117
+ const owner = ownerOf(component);
118
+ component.navigationParent = owner.tag;
119
+ component.group = owner.group;
120
+ }
121
+ if (!groups.has(component.group)) throw new Error(`Unknown category ${component.group} for ${component.id}. Declare its category or map it to an existing one.`);
122
+ }
123
+ return { schemaVersion: catalogVersion, resources: [...resourceByTag.values()], groups: [...groups.values()], components: [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)) };
124
+ }
125
+
126
+ /** Read a published catalog without importing the optional documentation server. */
127
+ export async function readCatalog(specifier = new URL('./catalog.json', import.meta.url)) {
128
+ const catalog = await readJson(specifier instanceof URL ? fileURLToPath(specifier) : specifier);
129
+ if (catalog.schemaVersion !== catalogVersion) throw new Error(`Unsupported documentation catalog version ${catalog.schemaVersion}.`);
130
+ return catalog;
131
+ }
package/docs/cli.mjs ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { initializeDocumentation, buildProjectDocumentation, devProjectDocumentation, previewProjectDocumentation } from './commands.mjs';
4
+
5
+ try {
6
+ const { values, positionals } = parseArgs({ allowPositionals: true, options: { root: { type: 'string' }, port: { type: 'string' }, title: { type: 'string' }, help: { type: 'boolean', short: 'h' } } });
7
+ const [command = 'help'] = positionals;
8
+ if (values.help || command === 'help') {
9
+ console.log('looma-docs init | dev | build | preview\n\n --root <project> Project directory\n --port <number> Local server port (default 3000)\n --title <name> Documentation title for init\n\nInstall the optional documentation peers: markupress, @nextwebwg/htmlkit and vite.');
10
+ } else {
11
+ const options = { ...(values.root ? { root: values.root } : {}), ...(values.title ? { title: values.title } : {}) };
12
+ if (values.port) { const port = Number(values.port); if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Port must be an integer from 0 to 65535.'); options.port = port; }
13
+ if (command === 'init') {
14
+ const result = await initializeDocumentation(options);
15
+ console.log(`Created ${result.files.join(', ')}.\nImport ${result.applicationImport} after Looma styles in your application.\nRun looma-docs dev to weave your design system.`);
16
+ } else if (command === 'build') {
17
+ const built = await buildProjectDocumentation(options); console.log(`Built ${built.routes.length} documentation routes in ${built.outDir}.`);
18
+ } else if (command === 'dev' || command === 'preview') {
19
+ const server = await (command === 'dev' ? devProjectDocumentation : previewProjectDocumentation)(options);
20
+ console.log(server.url);
21
+ let closing = false;
22
+ const close = async () => { if (closing) return; closing = true; await server.close(); process.exitCode = 0; };
23
+ process.once('SIGINT', close); process.once('SIGTERM', close);
24
+ } else throw new Error(`Unknown documentation command ${command}. Use looma-docs --help.`);
25
+ }
26
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
@@ -0,0 +1,94 @@
1
+ import { watch } from 'node:fs';
2
+ import { lstat, open, readFile, realpath, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { buildDocumentation, tool } from './site.mjs';
6
+ import { createThemeStore } from './store.mjs';
7
+ import { serveLocalDocumentation } from './local-server.mjs';
8
+ import { defaultRecipe, themeCSS } from './theme.mjs';
9
+
10
+ export const configName = 'looma.docs.json';
11
+ export async function readConfiguration(root = process.cwd()) {
12
+ root = await realpath(path.resolve(root));
13
+ try { return { root, config: JSON.parse(await readFile(path.join(root, configName), 'utf8')) }; }
14
+ catch (error) { if (error.code === 'ENOENT') throw new Error(`No ${configName} in this project. Run looma-docs init first.`); throw error; }
15
+ }
16
+
17
+ /** Initialization creates explicit owned files, leaving the application's imports to its author. */
18
+ export async function initializeDocumentation({ root = process.cwd(), title = 'Your design system' } = {}) {
19
+ root = await realpath(path.resolve(root));
20
+ const config = { title, base: '/', outDir: '.looma/site', theme: { recipe: 'looma.theme.json', css: 'looma.theme.css' }, components: [] };
21
+ const recipe = defaultRecipe();
22
+ const system = await readFile(fileURLToPath(import.meta.resolve('@threadlabs/looma/theme-system.css')), 'utf8');
23
+ const files = new Map([[configName, JSON.stringify(config, null, 2) + '\n'], [config.theme.recipe, JSON.stringify(recipe, null, 2) + '\n'], [config.theme.css, themeCSS(recipe, system)]]);
24
+ for (const name of files.keys()) {
25
+ try { await lstat(path.join(root, name)); throw new Error(`${name} already exists. Initialization will not replace it.`); }
26
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
27
+ }
28
+ const created = [];
29
+ try {
30
+ for (const [name, source] of files) {
31
+ const file = path.join(root, name); const handle = await open(file, 'wx', 0o644); created.push(file);
32
+ try { await handle.writeFile(source); } finally { await handle.close(); }
33
+ }
34
+ } catch (error) { await Promise.all(created.map(file => unlink(file))); throw error; }
35
+ return { config, files: [...files.keys()], applicationImport: './' + config.theme.css };
36
+ }
37
+
38
+ async function themeConfiguration(root, config, local = false) {
39
+ if (!config.theme && !local) return config;
40
+ const store = await createThemeStore({ root, ...config.theme });
41
+ const current = await store.read({ includeCSS: true });
42
+ return { ...config, recipe: current.recipe, revision: current.revision, ...(current.css !== null ? { themeCSSContents: current.css } : {}), local };
43
+ }
44
+ export async function buildProjectDocumentation({ root = process.cwd(), config, catalog } = {}) {
45
+ if (!config) ({ root, config } = await readConfiguration(root));
46
+ return buildDocumentation({ root, config: await themeConfiguration(root, config), catalog });
47
+ }
48
+ export async function previewProjectDocumentation({ root = process.cwd(), config, port = 3000 } = {}) {
49
+ if (!config) ({ root, config } = await readConfiguration(root));
50
+ const { previewApplication } = await tool('@nextwebwg/htmlkit', root);
51
+ return previewApplication({ root, outDir: config.outDir ?? '.looma/site', base: config.base ?? '/', host: '127.0.0.1', port });
52
+ }
53
+
54
+ /** HTMLKit serves the build; this local-only gateway adds the constrained repository writer. */
55
+ export async function devProjectDocumentation({ root = process.cwd(), config, catalog, port = 3000, onError = console.error } = {}) {
56
+ const configured = Boolean(config);
57
+ if (!config) ({ root, config } = await readConfiguration(root));
58
+ const store = await createThemeStore({ root, ...config.theme });
59
+ let closed = false, dirty = true, building, timer;
60
+ const rebuild = () => {
61
+ clearTimeout(timer);
62
+ if (building) return building;
63
+ building = (async () => {
64
+ while (dirty && !closed) {
65
+ dirty = false;
66
+ if (!configured) {
67
+ const next = (await readConfiguration(root)).config;
68
+ if (['base', 'outDir', 'theme'].some(key => JSON.stringify(next[key]) !== JSON.stringify(config[key]))) throw new Error('The server address, output or theme-file configuration changed. Restart looma-docs dev to use it.');
69
+ config = next;
70
+ }
71
+ await buildDocumentation({ root, config: await themeConfiguration(root, config, true), catalog });
72
+ }
73
+ })().finally(() => { building = undefined; });
74
+ return building;
75
+ };
76
+ await rebuild();
77
+ const upstream = await previewProjectDocumentation({ root, config, port: 0 });
78
+ let gateway;
79
+ try {
80
+ gateway = await serveLocalDocumentation({ upstream: upstream.url, store, base: config.base ?? '/', port, onSaved: async () => { dirty = true; await rebuild(); } });
81
+ } catch (error) { await upstream.close(); throw error; }
82
+ const watcher = watch(root, { recursive: true }, (_event, filename) => {
83
+ if (!filename || closed) return;
84
+ const relative = String(filename).split(path.sep).join('/');
85
+ if (relative.split('/').some(part => part === 'node_modules' || part === '.git' || part === '.looma')) return;
86
+ const output = config.outDir ?? '.looma/site';
87
+ if (relative === output || relative.startsWith(output + '/')) return;
88
+ const inputs = [configName, config.theme?.recipe ?? 'looma.theme.json', config.theme?.css ?? 'looma.theme.css', config.content, ...(config.components ?? []).map(entry => entry.directory), ...(config.overrides ?? [])].filter(Boolean).map(value => value.replace(/^\.\//, ''));
89
+ if (!inputs.some(input => relative === input || relative.startsWith(input + '/'))) return;
90
+ dirty = true;
91
+ clearTimeout(timer); timer = setTimeout(() => { void rebuild().catch(onError); }, 150);
92
+ });
93
+ return { url: gateway.url, close: async () => { closed = true; clearTimeout(timer); watcher.close(); await building?.catch(() => {}); await gateway.close(); await upstream.close(); } };
94
+ }
@@ -0,0 +1,40 @@
1
+ import { escapeHtml as escape } from './catalog.mjs';
2
+ import { componentPath, previewPath } from './render.mjs';
3
+ import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe } from './theme.mjs';
4
+
5
+ export function customizerResource(site, input = defaultRecipe()) {
6
+ const recipe = validateRecipe(input);
7
+ const cardFields = ['id', 'title', 'description', 'exampleTitle', 'src', 'resizable', 'category', 'tag', 'nativeRoot', 'href', 'packageName'];
8
+ const examples = site.catalog.components.flatMap(component => component.examples.map(example => ({ id: example.id, title: component.title, description: component.description, exampleTitle: example.title, src: site.base + previewPath(example).slice(1), resizable: Boolean(example.resizable), category: component.group, tag: component.tag, packageName: component.package, nativeRoot: component.root ? `<${component.root}>` : '', href: site.base + componentPath(component).slice(1) })));
9
+ const toCard = example => Object.fromEntries(cardFields.map(key => [key, example[key]]));
10
+ const initial = ['ui-button', 'ui-input', 'ui-color-picker', 'ui-checkbox', 'ui-card', 'ui-switch'].map(tag => examples.find(example => example.tag === tag)).filter(Boolean).map(toCard);
11
+ if (!initial.length && examples.length) initial.push(toCard(examples[0]));
12
+ const field = field => `<ui-form-field><label slot="label" for="theme-${field.id}">${escape(field.label)}</label><span slot="help">${escape(field.description)}</span><ui-cluster wrap="nowrap" gap="xs">${field.type === 'colour' ? `<ui-color-picker size="sm" data-token="${field.id}" value="${escape(recipe.values[field.id])}" label="${escape(field.label)} colour picker"></ui-color-picker>` : ''}<ui-input id="theme-${field.id}" size="sm" data-token="${field.id}" value="${escape(recipe.values[field.id])}" aria-label="${escape(field.label)}" type="${['positive','ratio','nonnegative','fraction','weight'].includes(field.type) ? 'number' : 'text'}"${['positive','ratio','nonnegative','fraction','weight'].includes(field.type) ? ' step="any"' : ''}></ui-input></ui-cluster></ui-form-field>`;
13
+ const families = [...new Set(themeFields.map(field => field.family))].map(family => `<ui-disclosure summary="${family}"${family === 'Colour' ? ' open' : ''}><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve'].includes(item.id)).map(field).join('')}${family === 'Size & spacing' ? `<ui-cluster><ui-button size="sm">Small</ui-button><ui-button>Medium</ui-button><ui-button size="lg">Large</ui-button></ui-cluster><ui-disclosure summary="How the scale grows"><ui-stack><p>Control size anchors one shared height. Size ratio separates small from large. Density and spacing curve change breathing room independently.</p>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(field).join('')}</ui-stack></ui-disclosure>` : ''}</ui-stack></ui-disclosure>`).join('');
14
+ const options = site.catalog.groups.map(group => `<optgroup label="${escape(group.label)}">${examples.filter(example => example.category === group.id).map(example => `<option value="${escape(example.id)}" data-example="${escape(JSON.stringify(example))}">${escape(example.title)} · ${escape(example.exampleTitle)}</option>`).join('')}</optgroup>`).join('');
15
+ const links = ['ui-stack','ui-grid','ui-cluster','ui-form-field','ui-input','ui-select','ui-color-picker','ui-button','ui-disclosure','ui-textarea'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
16
+ return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
17
+ <state name="cards" type="list" value="${escape(JSON.stringify(initial))}"><prop type="object">${cardFields.map(name => `<prop name="${name}" required type="${name === 'resizable' ? 'boolean' : 'string'}"></prop>`).join('')}</prop></state>
18
+ </defs>
19
+ <div data-looma-customizer data-recipe="${escape(JSON.stringify(recipe))}" data-base="${escape(site.base)}" data-mode="${site.config.local ? 'local' : 'public'}" data-revision="${escape(site.config.revision ?? '')}">
20
+ <ui-stack gap="xl"><p>Shape your design system through shared decisions. These are the actual examples from the component documentation. Unsaved changes affect the cards; saving applies the theme throughout your documentation.</p>
21
+ <div class="customizer-layout">
22
+ <aside><ui-stack><ui-form-field><label slot="label" for="theme-start">Starting point</label><ui-select id="theme-start" data-preset value="${startingPointFor(recipe)}"><option value="custom" disabled>Your theme</option>${Object.entries(startingPointLabels).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}</ui-select></ui-form-field>
23
+ ${families}
24
+ <ui-cluster><ui-button data-action="css" variant="solid">Download CSS</ui-button><ui-button data-action="recipe">Download recipe</ui-button><ui-button data-action="import">Import recipe</ui-button><ui-button data-action="reset" variant="ghost">Reset changes</ui-button></ui-cluster>
25
+ <input type="file" accept="application/json,.json" hidden data-recipe-file>
26
+ <ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
27
+ <p role="status" aria-live="polite" data-message>Preview changes on the documentation cards.</p>
28
+ <ui-disclosure summary="Theme CSS"><ui-textarea data-css readonly rows="12" aria-label="Generated theme CSS" value="${escape(themeCSS(recipe))}"></ui-textarea></ui-disclosure>
29
+ </ui-stack></aside>
30
+ <section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-choice">Add a documentation example</label><ui-select id="example-choice" data-add-example>${options}</ui-select></ui-form-field><ui-button data-action="add">Add example card</ui-button>
31
+ <ui-grid columns="2" min="sm"><section $each="card of $cards" $key="$card.id"><looma-example-card ${cardFields.filter(name => name !== 'id').map(name => `from:${name.replace(/[A-Z]/g, letter => '-' + letter.toLowerCase())}="$card.${name}"`).join(' ')} from:identity="$card.id" loading="eager" removable></looma-example-card></section></ui-grid>
32
+ <noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
33
+ </ui-stack></section></div></ui-stack></div>
34
+ <style>
35
+ .customizer-layout { display: grid !important; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
36
+ aside { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding-inline-end: var(--ui-space-2) !important; }
37
+ .examples { min-inline-size: 0; }
38
+ @media (max-width: 64rem) { .customizer-layout { grid-template-columns: minmax(0, 1fr); } aside { position: static; max-block-size: none; } }
39
+ </style></template>`;
40
+ }
@@ -0,0 +1,118 @@
1
+ import { componentRegistry } from '@nextwebwg/html-next/runtime';
2
+ import { startingPoints, startingPointFor, themeCSS, validateRecipe } from './theme.mjs';
3
+
4
+ /** The editor changes public theme values; the library owns every control's layout. */
5
+ export function connect(host) {
6
+ const root = host.element;
7
+ const document = root.ownerDocument;
8
+ const view = document.defaultView;
9
+ const message = root.querySelector('[data-message]');
10
+ const file = root.querySelector('[data-recipe-file]');
11
+ let recipe = validateRecipe(JSON.parse(root.dataset.recipe));
12
+ let saved = structuredClone(recipe);
13
+ let revision = root.dataset.revision;
14
+ let capability;
15
+ let changed = false;
16
+ let saving = false;
17
+ let alive = true;
18
+ const abort = new AbortController();
19
+ const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
20
+ const updateControl = (element, value) => componentRegistry.update(element, { value });
21
+ const paint = () => {
22
+ const css = themeCSS(recipe);
23
+ for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
24
+ updateControl(root.querySelector('[data-css]'), css);
25
+ };
26
+ const show = next => {
27
+ recipe = validateRecipe(next);
28
+ for (const control of root.querySelectorAll('[data-token]')) updateControl(control, recipe.values[control.dataset.token]);
29
+ updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
30
+ changed = JSON.stringify(recipe) !== JSON.stringify(saved);
31
+ paint();
32
+ };
33
+ const input = event => {
34
+ const control = event.target.closest?.('[data-token]');
35
+ if (!control) return;
36
+ const value = event.detail?.value ?? control.value;
37
+ const next = { schemaVersion: 1, values: { ...recipe.values, [control.dataset.token]: value } };
38
+ try {
39
+ recipe = validateRecipe(next); changed = true;
40
+ updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
41
+ control.removeAttribute('aria-invalid');
42
+ for (const sibling of root.querySelectorAll('[data-token]')) if (sibling !== control && sibling.dataset.token === control.dataset.token) updateControl(sibling, value);
43
+ paint(); say('Unsaved preview. Download the theme or save it to your repository.');
44
+ } catch (error) { control.setAttribute('aria-invalid', 'true'); say(error.message, true); }
45
+ };
46
+ const download = (data, name, type) => {
47
+ const url = URL.createObjectURL(new Blob([data], { type }));
48
+ const link = document.createElement('a'); link.href = url; link.download = name; link.click();
49
+ setTimeout(() => URL.revokeObjectURL(url), 0);
50
+ };
51
+ const click = async event => {
52
+ const button = event.target.closest?.('[data-action]');
53
+ if (!button || (button.dataset.action === 'save' && saving)) return;
54
+ try {
55
+ switch (button.dataset.action) {
56
+ case 'add': {
57
+ const select = root.querySelector('[data-add-example]');
58
+ const option = select.selectedOptions[0];
59
+ const selected = JSON.parse(option.dataset.example);
60
+ const card = Object.fromEntries(['id','title','description','exampleTitle','src','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
61
+ if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [...host.state.cards, card];
62
+ view.requestAnimationFrame(paint); break;
63
+ }
64
+ case 'css': {
65
+ const response = await fetch(root.dataset.base + '_looma/theme-system.css', { signal: abort.signal });
66
+ if (!response.ok) throw new Error('Could not load the shared theme recipe.');
67
+ download(themeCSS(recipe, await response.text()), 'looma.theme.css', 'text/css'); say('Theme CSS downloaded.'); break;
68
+ }
69
+ case 'recipe': download(JSON.stringify(recipe, null, 2) + '\n', 'looma.theme.json', 'application/json'); say('Editable recipe downloaded.'); break;
70
+ case 'import': file.click(); break;
71
+ case 'reset': show(structuredClone(saved)); say('Returned to the saved theme.'); break;
72
+ case 'save': {
73
+ if (!capability) throw new Error('Repository saving is available only in the local documentation server.');
74
+ const submitted = structuredClone(recipe);
75
+ saving = true;
76
+ say('Saving your theme and rebuilding the documentation…');
77
+ 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 });
78
+ const result = await response.json();
79
+ if (!response.ok) throw new Error(result.error ?? 'Could not save this theme.');
80
+ revision = result.revision; saved = submitted;
81
+ changed = JSON.stringify(recipe) !== JSON.stringify(saved);
82
+ if (changed) { say('Saved the submitted theme. Your newer edits are still an unsaved preview.'); break; }
83
+ say('Saved to your repository. Reloading your design system…');
84
+ view.location.reload(); break;
85
+ }
86
+ }
87
+ } catch (error) { if (error.name !== 'AbortError' && alive) say(error.message, true); }
88
+ finally { if (button.dataset.action === 'save') saving = false; }
89
+ };
90
+ const change = async event => {
91
+ if (event.target.matches('[data-preset]')) { show(structuredClone(startingPoints[event.target.value])); say('Starting point applied to every preview card.'); }
92
+ if (event.target === file && file.files[0]) {
93
+ try { show(JSON.parse(await file.files[0].text())); say('Recipe imported as an unsaved preview.'); }
94
+ catch (error) { say(error.message, true); }
95
+ file.value = '';
96
+ }
97
+ };
98
+ const ready = event => {
99
+ if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
100
+ };
101
+ const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); };
102
+ root.addEventListener('looma-example-remove', remove);
103
+ root.addEventListener('input', input);
104
+ root.addEventListener('change', change);
105
+ root.addEventListener('click', click);
106
+ view.addEventListener('message', ready);
107
+ paint();
108
+ if (root.dataset.mode === 'local') {
109
+ fetch(root.dataset.base + '_looma/session', { signal: abort.signal }).then(async response => {
110
+ if (!response.ok) throw new Error('Local theme saving is unavailable.');
111
+ const session = await response.json(); if (!alive) return;
112
+ capability = session.capability;
113
+ if (!changed) { saved = validateRecipe(session.recipe); revision = session.revision; show(saved); }
114
+ root.querySelector('[data-action="save"]').hidden = false;
115
+ }).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
116
+ }
117
+ return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); };
118
+ }
@@ -0,0 +1,8 @@
1
+ /* Documentation presentation owns only its native preview frame and code overflow. Controls use Looma. */
2
+ [data-example-card] iframe { display: block; max-inline-size: 100%; border: 0; background: transparent; }
3
+ [data-example-card] pre { overflow: auto; }
4
+ /* The customizer is an application page; use Markupress's public width token instead of patching its layout. */
5
+ :root:has([data-looma-customizer]), :root:has([data-component-card]) { --markupress-content-width: 110rem; }
6
+ body:has([data-preview]) { background: var(--ui-surface); color: var(--ui-text); font-family: var(--ui-font-sans); font-size: var(--ui-font-size); line-height: var(--ui-line-height); }
7
+ [data-preview] { display: grid; place-items: center; gap: var(--ui-space-4); min-block-size: 13rem; padding: 1.35rem; box-sizing: border-box; }
8
+ body:has([data-preview]) { margin: 0; background: linear-gradient(90deg, rgb(43 37 56 / .035) 1px, transparent 1px), linear-gradient(rgb(43 37 56 / .035) 1px, transparent 1px), color-mix(in srgb, var(--ui-accent) 5%, var(--ui-surface)); background-size: 22px 22px; }
@@ -0,0 +1,7 @@
1
+ /** Keep the form demonstration on this page while exercising native submit behavior. */
2
+ export default function setupExample(root) {
3
+ const form = root.querySelector("form");
4
+ const submit = (event) => event.preventDefault();
5
+ form?.addEventListener("submit", submit);
6
+ return () => form?.removeEventListener("submit", submit);
7
+ }
@@ -0,0 +1,21 @@
1
+ import { componentRegistry } from "@nextwebwg/html-next/runtime";
2
+ /** Simulates a save so the example shows the transition into and out of loading. */
3
+ export default function saveChanges(root) {
4
+ const button = root.querySelector("#docs-save-changes");
5
+ if (!button)
6
+ return () => { };
7
+ let timer;
8
+ const onClick = () => {
9
+ componentRegistry.update(button, { loading: true });
10
+ timer = setTimeout(() => {
11
+ componentRegistry.update(button, { loading: false });
12
+ timer = undefined;
13
+ }, 1500);
14
+ };
15
+ button.addEventListener("click", onClick);
16
+ return () => {
17
+ button.removeEventListener("click", onClick);
18
+ if (timer !== undefined)
19
+ clearTimeout(timer);
20
+ };
21
+ }
@@ -0,0 +1,30 @@
1
+ import { componentRegistry } from "@nextwebwg/html-next/runtime";
2
+ /** Keeps the summary checkbox in step with three independently checked items. */
3
+ export default function connectSelection(root) {
4
+ const all = root.querySelector("#docs-checkbox-all");
5
+ const items = Array.from(root.querySelectorAll(".docs-checkbox-item"));
6
+ if (!all || !items.length)
7
+ return () => { };
8
+ const checked = (item) => Boolean(item.querySelector('input[type="checkbox"]')?.checked);
9
+ const onItemChange = (event) => {
10
+ if (!(event instanceof CustomEvent) || typeof event.detail?.checked !== "boolean")
11
+ return;
12
+ const count = items.filter(checked).length;
13
+ componentRegistry.update(all, { checked: count === items.length, indeterminate: count > 0 && count < items.length });
14
+ };
15
+ const onAllChange = (event) => {
16
+ if (!(event instanceof CustomEvent) || typeof event.detail?.checked !== "boolean")
17
+ return;
18
+ for (const item of items)
19
+ componentRegistry.update(item, { checked: event.detail.checked });
20
+ componentRegistry.update(all, { indeterminate: false });
21
+ };
22
+ all.addEventListener("change", onAllChange);
23
+ for (const item of items)
24
+ item.addEventListener("change", onItemChange);
25
+ return () => {
26
+ all.removeEventListener("change", onAllChange);
27
+ for (const item of items)
28
+ item.removeEventListener("change", onItemChange);
29
+ };
30
+ }
@@ -0,0 +1,11 @@
1
+ import { componentRegistry } from "@nextwebwg/html-next/runtime";
2
+ /** Takes each change the user makes, as a consumer that owns selectedValues does. */
3
+ export default function keepSelection(root) {
4
+ const onChange = (event) => {
5
+ const { selectedValues } = event.detail;
6
+ // A rendered component's data-* attributes only record its options; the runtime's prop channel sets them.
7
+ componentRegistry.update(event.target, { selectedValues });
8
+ };
9
+ root.addEventListener("selected-values-change", onChange);
10
+ return () => root.removeEventListener("selected-values-change", onChange);
11
+ }
@@ -0,0 +1,34 @@
1
+ /** Filters the example's results as the query changes; a real application would query its index. */
2
+ export default function filterResults(root) {
3
+ const update = () => {
4
+ const input = root.querySelector("#docs-search-input");
5
+ const status = root.querySelector("#docs-search-status");
6
+ const empty = root.querySelector("#docs-search-empty");
7
+ const results = [
8
+ root.querySelector("#docs-result-design-tokens"),
9
+ root.querySelector("#docs-result-token-overrides"),
10
+ root.querySelector("#docs-result-button-variants")
11
+ ].filter((result) => result !== null);
12
+ if (!input || !status || !empty || results.length === 0)
13
+ return;
14
+ const query = input.value.trim().toLowerCase();
15
+ let visible = 0;
16
+ for (const result of results) {
17
+ result.hidden = !result.textContent?.toLowerCase().includes(query);
18
+ if (!result.hidden)
19
+ visible += 1;
20
+ }
21
+ status.textContent = `${visible} result${visible === 1 ? "" : "s"}`;
22
+ empty.hidden = visible !== 0;
23
+ };
24
+ const onInput = (event) => {
25
+ if (event.target?.id === "docs-search-input")
26
+ update();
27
+ };
28
+ root.addEventListener("input", onInput);
29
+ const frame = requestAnimationFrame(update);
30
+ return () => {
31
+ cancelAnimationFrame(frame);
32
+ root.removeEventListener("input", onInput);
33
+ };
34
+ }
@@ -0,0 +1,15 @@
1
+ /** Shows a hidden authored toast when the example's button is pressed; the toast hides itself when dismissed. */
2
+ export function showToastOnClick(buttonId, toastId) {
3
+ return (root) => {
4
+ const button = root.querySelector(`#${buttonId}`);
5
+ const toast = root.querySelector(`#${toastId}`);
6
+ if (!button || !toast)
7
+ return () => { };
8
+ const show = () => {
9
+ toast.hidden = false;
10
+ };
11
+ button.addEventListener("click", show);
12
+ return () => button.removeEventListener("click", show);
13
+ };
14
+ }
15
+ export default showToastOnClick("show-saved-toast", "saved-toast");
@@ -0,0 +1,2 @@
1
+ import { showToastOnClick } from "./01-authored.behavior.js";
2
+ export default showToastOnClick("show-failed-toast", "failed-toast");
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Applies a tree's `reorder` requests, as an application does: the tree reports a drop, and the
3
+ * application moves the item. The tree re-derives levels from the DOM.
4
+ */
5
+ export default function applyTreeReorder(root) {
6
+ const onReorder = (event) => {
7
+ const { sourceId, targetId, position } = event.detail;
8
+ const tree = event.target?.closest('[role="tree"]');
9
+ const item = (id) => tree?.querySelector(`[role="treeitem"][data-item-id="${CSS.escape(id)}"]`);
10
+ const source = item(sourceId);
11
+ const target = item(targetId);
12
+ if (!source || !target || source === target || source.contains(target))
13
+ return;
14
+ if (position === "before")
15
+ target.before(source);
16
+ else if (position === "after")
17
+ target.after(source);
18
+ else {
19
+ const group = target.querySelector(':scope > [role="group"]');
20
+ if (!group)
21
+ return;
22
+ // Stay inside the group's slot range: before its closing marker when there is one.
23
+ const last = group.lastChild;
24
+ const marker = last && (last.nodeType === Node.PROCESSING_INSTRUCTION_NODE || last.nodeType === Node.COMMENT_NODE) ? last : null;
25
+ group.insertBefore(source, marker);
26
+ }
27
+ };
28
+ root.addEventListener("reorder", onReorder, true);
29
+ return () => root.removeEventListener("reorder", onReorder, true);
30
+ }
@@ -0,0 +1,2 @@
1
+ // Apply reorder requests so the click and keyboard example changes the authored tree.
2
+ export { default } from "./03-max-depth-2-and-hover-expand-delay-300.behavior.js";
@@ -0,0 +1,16 @@
1
+ import { componentRegistry } from "@nextwebwg/html-next/runtime";
2
+ /** Apply Tree's controlled selection request to the authored items in this example. */
3
+ export default function applyTreeSelection(root) {
4
+ const onSelect = (event) => {
5
+ const { ids } = event.detail;
6
+ const tree = event.target?.closest('[role="tree"]');
7
+ if (!tree)
8
+ return;
9
+ const selected = new Set(ids);
10
+ for (const item of tree.querySelectorAll('[role="treeitem"][data-item-id]')) {
11
+ componentRegistry.update(item, { selected: selected.has(item.dataset.itemId ?? "") });
12
+ }
13
+ };
14
+ root.addEventListener("select", onSelect, true);
15
+ return () => root.removeEventListener("select", onSelect, true);
16
+ }
@@ -0,0 +1 @@
1
+ export { default } from "../../ui-tree/examples/03-max-depth-2-and-hover-expand-delay-300.behavior.js";