@threadlabs/looma 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +15 -0
  2. package/bin/looma-docs.mjs +3 -0
  3. package/components/shared/overlay.js +8 -2
  4. package/components/shared/surface-material.js +21 -0
  5. package/components/ui-button/ui-button.html +63 -47
  6. package/components/ui-card/ui-card.html +44 -2
  7. package/components/ui-curve-editor/curve.js +10 -0
  8. package/components/ui-curve-editor/ui-curve-editor.html +181 -0
  9. package/components/ui-curve-editor/ui-curve-editor.js +51 -0
  10. package/components/ui-dialog/ui-dialog.html +62 -1
  11. package/components/ui-dialog/ui-dialog.js +9 -1
  12. package/components/ui-icon-button/ui-icon-button.html +75 -23
  13. package/components/ui-menu/ui-menu.html +60 -0
  14. package/components/ui-menu/ui-menu.js +1 -1
  15. package/components/ui-number-input/arithmetic.js +47 -0
  16. package/components/ui-number-input/ui-number-input.html +62 -0
  17. package/components/ui-number-input/ui-number-input.js +44 -0
  18. package/components/ui-popover/ui-popover.html +64 -1
  19. package/components/ui-popover/ui-popover.js +1 -1
  20. package/components/ui-slider/ui-slider.html +84 -0
  21. package/components/ui-slider/ui-slider.js +29 -0
  22. package/components/ui-tabs/ui-tabs.html +6 -1
  23. package/components/ui-text/ui-text.html +31 -6
  24. package/components/ui-tooltip/ui-tooltip.html +60 -0
  25. package/components/ui-tooltip/ui-tooltip.js +1 -1
  26. package/dist/index.js +46 -43
  27. package/docs/README.md +146 -0
  28. package/docs/card.html +77 -0
  29. package/docs/card.js +47 -0
  30. package/docs/catalog-view.js +18 -0
  31. package/docs/catalog-view.mjs +11 -0
  32. package/docs/catalog.json +23984 -0
  33. package/docs/catalog.mjs +131 -0
  34. package/docs/cli.mjs +26 -0
  35. package/docs/commands.mjs +94 -0
  36. package/docs/customizer-render.mjs +66 -0
  37. package/docs/customizer.js +181 -0
  38. package/docs/documentation.css +16 -0
  39. package/docs/examples/ui-action-bar/examples/01-form-footer.behavior.js +7 -0
  40. package/docs/examples/ui-button/examples/08-loading.behavior.js +21 -0
  41. package/docs/examples/ui-checkbox/examples/02-checked-indeterminate-and-disabled.behavior.js +30 -0
  42. package/docs/examples/ui-combobox/examples/06-selected-values.behavior.js +11 -0
  43. package/docs/examples/ui-search-shell/examples/02-label-dismissible-status-and-footer.behavior.js +34 -0
  44. package/docs/examples/ui-toast/examples/01-authored.behavior.js +15 -0
  45. package/docs/examples/ui-toast/examples/02-danger.behavior.js +2 -0
  46. package/docs/examples/ui-tree/examples/03-max-depth-2-and-hover-expand-delay-300.behavior.js +30 -0
  47. package/docs/examples/ui-tree/examples/06-move-without-dragging.behavior.js +2 -0
  48. package/docs/examples/ui-tree/examples/07-multiple-selection.behavior.js +16 -0
  49. package/docs/examples/ui-tree-item/examples/02-container.behavior.js +1 -0
  50. package/docs/index.d.ts +56 -0
  51. package/docs/index.mjs +4 -0
  52. package/docs/local-server.mjs +68 -0
  53. package/docs/preview.js +22 -0
  54. package/docs/reference.html +1 -0
  55. package/docs/reference.js +36 -0
  56. package/docs/render.mjs +93 -0
  57. package/docs/site.mjs +239 -0
  58. package/docs/store.mjs +92 -0
  59. package/docs/theme-session.mjs +51 -0
  60. package/docs/theme.mjs +219 -0
  61. package/docs/value-editors.mjs +33 -0
  62. package/docs/workspace.mjs +32 -0
  63. package/package.json +28 -4
  64. package/styles/ui-button.css +45 -45
  65. package/styles/ui-card.css +47 -0
  66. package/styles/ui-curve-editor.css +109 -0
  67. package/styles/ui-dialog.css +132 -0
  68. package/styles/ui-icon-button.css +63 -20
  69. package/styles/ui-menu.css +72 -0
  70. package/styles/ui-number-input.css +34 -0
  71. package/styles/ui-popover.css +74 -0
  72. package/styles/ui-slider.css +53 -0
  73. package/styles/ui-tabs.css +6 -1
  74. package/styles/ui-text.css +23 -1
  75. package/styles/ui-tooltip.css +72 -0
  76. package/theme-system.css +26 -10
  77. package/tokens.css +8 -2
  78. package/vanilla/UiButton.d.ts +1 -0
  79. package/vanilla/UiButton.js +195 -130
  80. package/vanilla/UiCard.d.ts +1 -1
  81. package/vanilla/UiCard.js +1 -1
  82. package/vanilla/UiCurveEditor.d.ts +28 -0
  83. package/vanilla/UiCurveEditor.js +134 -0
  84. package/vanilla/UiIconButton.d.ts +2 -1
  85. package/vanilla/UiIconButton.js +58 -31
  86. package/vanilla/UiNumberInput.d.ts +33 -0
  87. package/vanilla/UiNumberInput.js +79 -0
  88. package/vanilla/UiSlider.d.ts +33 -0
  89. package/vanilla/UiSlider.js +125 -0
  90. package/vanilla/UiText.d.ts +2 -2
  91. package/vanilla/UiText.js +45 -11
  92. package/vanilla/index.js +3 -0
  93. package/vue/UiButton.d.ts +6 -0
  94. package/vue/UiButton.vue +155 -56
  95. package/vue/UiCard.d.ts +2 -2
  96. package/vue/UiCard.vue +55 -5
  97. package/vue/UiCurveEditor.d.ts +53 -0
  98. package/vue/UiCurveEditor.js +2 -0
  99. package/vue/UiCurveEditor.vue +427 -0
  100. package/vue/UiDialog.vue +177 -1
  101. package/vue/UiIconButton.d.ts +8 -2
  102. package/vue/UiIconButton.vue +164 -34
  103. package/vue/UiMenu.vue +89 -0
  104. package/vue/UiNumberInput.d.ts +83 -0
  105. package/vue/UiNumberInput.js +2 -0
  106. package/vue/UiNumberInput.vue +370 -0
  107. package/vue/UiPopover.vue +92 -1
  108. package/vue/UiSlider.d.ts +83 -0
  109. package/vue/UiSlider.js +2 -0
  110. package/vue/UiSlider.vue +412 -0
  111. package/vue/UiTabs.vue +6 -1
  112. package/vue/UiText.d.ts +7 -5
  113. package/vue/UiText.vue +104 -22
  114. package/vue/UiTooltip.vue +89 -0
  115. package/vue/chunks/UiActionBar.js +1 -1
  116. package/vue/chunks/UiAffordanceScope.js +2 -2
  117. package/vue/chunks/UiAvatar.js +2 -2
  118. package/vue/chunks/UiAvatarGroup.js +2 -2
  119. package/vue/chunks/UiBadge.js +2 -2
  120. package/vue/chunks/UiBreadcrumbItem.js +2 -2
  121. package/vue/chunks/UiBreadcrumbs.js +2 -2
  122. package/vue/chunks/UiButton.js +94 -11
  123. package/vue/chunks/UiCallout.js +2 -2
  124. package/vue/chunks/UiCard.js +6 -5
  125. package/vue/chunks/UiCheckbox.js +2 -2
  126. package/vue/chunks/UiCluster.js +2 -2
  127. package/vue/chunks/UiColorPicker.js +2 -2
  128. package/vue/chunks/UiCombobox.js +2 -2
  129. package/vue/chunks/UiContainer.js +2 -2
  130. package/vue/chunks/UiContextMenu.js +2 -2
  131. package/vue/chunks/UiCover.js +2 -2
  132. package/vue/chunks/UiCurveEditor.js +413 -0
  133. package/vue/chunks/UiDescriptionItem.js +2 -2
  134. package/vue/chunks/UiDescriptionList.js +2 -2
  135. package/vue/chunks/UiDialog.js +4 -4
  136. package/vue/chunks/UiDisclosure.js +2 -2
  137. package/vue/chunks/UiEditable.js +2 -2
  138. package/vue/chunks/UiEditorBlockActions.js +2 -2
  139. package/vue/chunks/UiEditorChipEditor.js +2 -2
  140. package/vue/chunks/UiEditorCodeLanguage.js +2 -2
  141. package/vue/chunks/UiEditorFootnoteEditor.js +2 -2
  142. package/vue/chunks/UiEditorImageToolbar.js +3 -3
  143. package/vue/chunks/UiEditorInsertTableGrid.js +2 -2
  144. package/vue/chunks/UiEditorLinkCard.js +3 -3
  145. package/vue/chunks/UiEditorLinkForm.js +3 -3
  146. package/vue/chunks/UiEditorMentionMenu.js +2 -2
  147. package/vue/chunks/UiEditorMentionMenuItem.js +2 -2
  148. package/vue/chunks/UiEditorSlashMenu.js +2 -2
  149. package/vue/chunks/UiEditorSlashMenuGroup.js +2 -2
  150. package/vue/chunks/UiEditorSlashMenuItem.js +2 -2
  151. package/vue/chunks/UiEditorSurface.js +2 -2
  152. package/vue/chunks/UiEditorTableContextMenu.js +2 -2
  153. package/vue/chunks/UiEditorTableOverlay.js +2 -2
  154. package/vue/chunks/UiEditorTableToolbar.js +2 -2
  155. package/vue/chunks/UiEditorToolbar.js +2 -2
  156. package/vue/chunks/UiFloatingActionButton.js +2 -2
  157. package/vue/chunks/UiFormField.js +2 -2
  158. package/vue/chunks/UiGrid.js +2 -2
  159. package/vue/chunks/UiIcon.js +2 -2
  160. package/vue/chunks/UiIconButton.js +77 -11
  161. package/vue/chunks/UiImage.js +2 -2
  162. package/vue/chunks/UiInput.js +2 -2
  163. package/vue/chunks/UiInputGroup.js +2 -2
  164. package/vue/chunks/UiList.js +2 -2
  165. package/vue/chunks/UiListItem.js +2 -2
  166. package/vue/chunks/UiListbox.js +2 -2
  167. package/vue/chunks/UiMenu.js +3 -3
  168. package/vue/chunks/UiMenuGroup.js +2 -2
  169. package/vue/chunks/UiMenuItem.js +2 -2
  170. package/vue/chunks/UiMeter.js +2 -2
  171. package/vue/chunks/UiNavItem.js +2 -2
  172. package/vue/chunks/UiNumberInput.js +390 -0
  173. package/vue/chunks/UiPageHeader.js +2 -2
  174. package/vue/chunks/UiPopover.js +4 -4
  175. package/vue/chunks/UiRadio.js +2 -2
  176. package/vue/chunks/UiRadioGroup.js +2 -2
  177. package/vue/chunks/UiReel.js +2 -2
  178. package/vue/chunks/UiScrollArea.js +2 -2
  179. package/vue/chunks/UiSearchResultRow.js +2 -2
  180. package/vue/chunks/UiSearchShell.js +2 -2
  181. package/vue/chunks/UiSection.js +2 -2
  182. package/vue/chunks/UiSelect.js +2 -2
  183. package/vue/chunks/UiSeparator.js +2 -2
  184. package/vue/chunks/UiSidebar.js +2 -2
  185. package/vue/chunks/UiSlider.js +396 -0
  186. package/vue/chunks/UiSpinner.js +2 -2
  187. package/vue/chunks/UiStack.js +2 -2
  188. package/vue/chunks/UiStatusMessage.js +2 -2
  189. package/vue/chunks/UiSwatch.js +2 -2
  190. package/vue/chunks/UiSwitch.js +2 -2
  191. package/vue/chunks/UiSwitcher.js +2 -2
  192. package/vue/chunks/UiTable.js +2 -2
  193. package/vue/chunks/UiTabs.js +3 -3
  194. package/vue/chunks/UiText.js +53 -12
  195. package/vue/chunks/UiTextarea.js +2 -2
  196. package/vue/chunks/UiToast.js +2 -2
  197. package/vue/chunks/UiToastRegion.js +2 -2
  198. package/vue/chunks/UiTooltip.js +3 -3
  199. package/vue/chunks/UiTopBar.js +1 -1
  200. package/vue/chunks/UiTree.js +2 -2
  201. package/vue/chunks/UiTreeItem.js +2 -2
  202. package/vue/chunks/_plugin-vue_export-helper.js +68 -2
  203. package/vue/chunks/props.js +24 -1
  204. package/vue/components.css +2215 -1203
  205. package/vue/editor/index.js +623 -211
  206. package/vue/index.d.ts +3 -0
  207. package/vue/index.js +5 -2
@@ -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,66 @@
1
+ import { escapeHtml as escape } from './catalog.mjs';
2
+ import { componentPath, previewPath } from './render.mjs';
3
+ import { valueEditor } from './value-editors.mjs';
4
+ import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes, paletteFor } from './theme.mjs';
5
+
6
+ export function customizerResource(site, input = defaultRecipe()) {
7
+ const recipe = validateRecipe(input);
8
+ const cardFields = ['id', 'title', 'description', 'exampleTitle', 'src', 'resizable', 'category', 'tag', 'nativeRoot', 'href', 'packageName'];
9
+ 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) })));
10
+ const toCard = example => Object.fromEntries(cardFields.map(key => [key, example[key]]));
11
+ const initial = ['ui-button', 'ui-input', 'ui-color-picker', 'ui-checkbox', 'ui-card', 'ui-switch', 'ui-text', 'ui-dialog', 'ui-popover'].map(tag => examples.find(example => example.tag === tag && (tag !== 'ui-button' || example.id.endsWith('/02-variant')) && (tag !== 'ui-text' || example.id.endsWith('/02-variants')) && (tag !== 'ui-card' || example.id.endsWith('/04-glass'))) ?? examples.find(example => example.tag === tag)).filter(Boolean).map(toCard);
12
+ if (!initial.length && examples.length) initial.push(toCard(examples[0]));
13
+ const renderField = field => {
14
+ const value = recipe.values[field.id], editor = valueEditor(field, value);
15
+ const common = `size="sm" data-token="${field.id}" label="${escape(field.label)}"`;
16
+ let control;
17
+ if (field.type === 'easing') {
18
+ control = `<ui-input id="theme-${field.id}" size="sm" data-token="${field.id}" aria-label="${escape(field.label)}" value="${escape(value)}"></ui-input><div data-curve-for="${field.id}"${editor.kind === 'text' ? ' hidden' : ''}><ui-curve-editor ${common} data-editor-kind="curve" value="${escape(value)}"></ui-curve-editor></div>`;
19
+ } else if (editor.kind === 'number' || editor.kind === 'slider') {
20
+ const tag = editor.kind === 'slider' ? 'ui-slider' : 'ui-number-input';
21
+ const numeric = `<${tag} id="theme-${field.id}" ${common} data-editor-kind="${editor.kind}" value="${escape(editor.value)}" unit="${editor.unit}" min="${editor.min}" max="${editor.max}" step="${editor.step}"></${tag}>`;
22
+ control = editor.units ? `<ui-cluster wrap="nowrap" gap="xs">${numeric}<ui-select size="sm" style="--ui-select-inline-size: auto" data-unit-for="${field.id}" aria-label="${escape(field.label)} unit" value="${editor.unit}">${[...new Set([...editor.units, editor.unit])].map(unit => `<option value="${unit}">${unit}</option>`).join('')}</ui-select></ui-cluster>` : numeric;
23
+ } else {
24
+ control = `<ui-cluster wrap="nowrap" gap="xs">${field.type === 'colour' ? `<ui-color-picker size="sm" data-token="${field.id}" value="${escape(value)}" label="${escape(field.label)} colour picker"></ui-color-picker>` : ''}<ui-input id="theme-${field.id}" size="sm" data-token="${field.id}" value="${escape(value)}" aria-label="${escape(field.label)}"></ui-input></ui-cluster>`;
25
+ }
26
+ return `<ui-form-field><label slot="label" for="theme-${field.id}">${escape(field.label)}</label><span slot="help">${escape(field.description)}</span>${control}</ui-form-field>`;
27
+ };
28
+ const supplements = {
29
+ 'Colour': `<ui-form-field><label slot="label" for="theme-palette">Coordinated palette</label><span slot="help">Choose related action, paper and ink colors together. Size, material and motion stay yours. Both light and dark ranges are generated.</span><ui-select id="theme-palette" size="sm" data-palette value="${paletteFor(recipe)}"><option value="custom" disabled>Your palette</option>${Object.entries(themePalettes).map(([id, palette]) => `<option value="${id}">${escape(palette.label)}</option>`).join('')}</ui-select></ui-form-field><ui-cluster>${['light', 'dark'].map(mode => `<ui-card data-theme="${mode}" padding="sm"><ui-text variant="label">${mode === 'light' ? 'Light' : 'Dark'} range</ui-text><div class="palette-range" role="img" aria-label="${mode} canvas, content and action range"><span class="palette-canvas"></span><span class="palette-content"></span><span class="palette-action"></span></div><ui-text variant="caption">Fellowship office</ui-text></ui-card>`).join('')}</ui-cluster><p data-palette-adjustments role="status" aria-live="polite"></p><ui-disclosure summary="Resolved palette values"><p data-palette-adjustment-details></p></ui-disclosure><ui-disclosure summary="Palette seeds"><ui-stack>${themeFields.filter(item => item.family === 'Colour').map(renderField).join('')}</ui-stack></ui-disclosure>`,
30
+ 'Material': `<ui-disclosure summary="Material details"><ui-stack><p>Glassiness and surface separation coordinate the material. These controls preserve older recipes and offer finer adjustments.</p>${themeFields.filter(item => ['translucency', 'edge-light'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
31
+ '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(renderField).join('')}</ui-stack></ui-disclosure>`,
32
+ };
33
+ 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', 'translucency', 'edge-light'].includes(item.id) && family !== 'Colour').map(renderField).join('')}${supplements[family] ?? ''}</ui-stack></ui-disclosure>`).join('');
34
+ 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))}" data-search="${escape(`${example.title} ${example.tag} ${example.description} ${example.exampleTitle}`.toLocaleLowerCase())}">${escape(example.title)} · ${escape(example.exampleTitle)}</option>`).join('')}</optgroup>`).join('');
35
+ const links = ['ui-stack','ui-grid','ui-cluster','ui-form-field','ui-input','ui-select','ui-color-picker','ui-button','ui-disclosure','ui-textarea','ui-number-input','ui-slider','ui-curve-editor','ui-card','ui-text'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
36
+ return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
37
+ <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>
38
+ </defs>
39
+ <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 ?? '')}">
40
+ <ui-stack gap="xl"><p>Shape your design system through shared decisions. These are the actual examples from the component documentation. Changes apply throughout your documentation and are remembered in this browser until reset. Save to repository writes the theme to your project; downloads let you use it elsewhere.</p>
41
+ <div class="customizer-layout">
42
+ <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>
43
+ ${families}
44
+ <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>
45
+ <input type="file" accept="application/json,.json" hidden data-recipe-file>
46
+ <ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
47
+ <ui-disclosure summary="Use your theme"><p>Every export includes light and dark. Leave data-theme off the page to follow system preference, or set it to light or dark on the page or a section. <a href="${escape(site.base)}design-system/">Installation and mode guidance</a></p></ui-disclosure>
48
+ <p role="status" aria-live="polite" data-message>Your theme is remembered in this browser. Repository files change only when you save.</p>
49
+ <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>
50
+ </ui-stack></aside>
51
+ <section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Find components and examples</label><ui-input id="example-query" type="search" data-example-query placeholder="Name, tag or purpose"></ui-input></ui-form-field><p role="status" aria-live="polite" data-example-count></p><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>
52
+ <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>
53
+ <noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
54
+ </ui-stack></section></div></ui-stack></div>
55
+ <style>
56
+ .customizer-layout { display: grid !important; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
57
+ aside { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding-inline-end: var(--ui-space-2) !important; }
58
+ .examples { min-inline-size: 0; }
59
+ .palette-range { display: flex !important; min-block-size: var(--ui-control-size-sm); border: var(--ui-border-width) solid var(--ui-control-border); border-radius: var(--ui-radius-sm); overflow: hidden; }
60
+ .palette-range > span { flex: 1; min-inline-size: var(--ui-space-5); }
61
+ .palette-canvas { background: var(--ui-surface); }
62
+ .palette-content { background: var(--ui-surface-raised); }
63
+ .palette-action { background: var(--ui-accent); }
64
+ @media (max-width: 64rem) { .customizer-layout { grid-template-columns: minmax(0, 1fr); } aside { position: static; max-block-size: none; } }
65
+ </style></template>`;
66
+ }
@@ -0,0 +1,181 @@
1
+ import { documentationTheme, themeEvent } from './theme-session.mjs';
2
+ import { componentRegistry } from '@nextwebwg/html-next/runtime';
3
+ import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, paletteFor, deriveTheme } from './theme.mjs';
4
+
5
+ import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
6
+
7
+ /** The editor changes public theme values; the library owns every control's layout. */
8
+ export function connect(host) {
9
+ const root = host.element;
10
+ const document = root.ownerDocument;
11
+ const view = document.defaultView;
12
+ const message = root.querySelector('[data-message]');
13
+ const file = root.querySelector('[data-recipe-file]');
14
+ const theme = documentationTheme(view);
15
+ let saved = validateRecipe(JSON.parse(root.dataset.recipe));
16
+ let recipe = theme?.recipe ?? structuredClone(saved);
17
+ let revision = root.dataset.revision;
18
+ let capability;
19
+ let changed = false;
20
+ let saving = false;
21
+ let alive = true;
22
+ const abort = new AbortController();
23
+ const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
24
+ const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
25
+ const fields = new Map(themeFields.map(field => [field.id, field]));
26
+ const unitContext = () => ({ remPixels: parseFloat(view.getComputedStyle(document.documentElement).fontSize) });
27
+ const updateControl = (element, value) => {
28
+ const field = fields.get(element.dataset.token);
29
+ if (!field || !element.dataset.editorKind) { componentRegistry.update(element, { value }); return; }
30
+ const editor = valueEditor(field, value, unitContext());
31
+ if (element.dataset.editorKind === 'curve') {
32
+ element.closest('[data-curve-for]').hidden = editor.kind !== 'curve';
33
+ if (editor.kind === 'curve') componentRegistry.update(element, { value });
34
+ } else componentRegistry.update(element, Object.fromEntries(['value', 'unit', 'min', 'max', 'step'].map(key => [key, editor[key]])));
35
+ };
36
+ const updateUnits = () => {
37
+ for (const control of root.querySelectorAll('[data-unit-for]')) {
38
+ const field = fields.get(control.dataset.unitFor), editor = valueEditor(field, recipe.values[field.id], unitContext());
39
+ if (![...control.options].some(option => option.value === editor.unit)) control.add(new view.Option(editor.unit, editor.unit));
40
+ componentRegistry.update(control, { value: editor.unit });
41
+ }
42
+ };
43
+ const paint = () => {
44
+ const css = themeCSS(recipe);
45
+ updateControl(root.querySelector('[data-palette]'), paletteFor(recipe));
46
+ const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
47
+ const report = root.querySelector('[data-palette-adjustments]');
48
+ report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your seed colors stay editable; resolved values are shown below.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
49
+ root.querySelector('[data-palette-adjustment-details]').textContent = adjustments.length ? adjustments.map(item => `${item.mode} ${fields.get(item.id).label.toLowerCase()} ${item.requested} → ${item.resolved}`).join('; ') + '. Seed colors remain editable.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
50
+ for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css: theme?.css ?? css } }));
51
+ updateControl(root.querySelector('[data-css]'), css);
52
+ };
53
+ const show = (next, persist = true) => {
54
+ recipe = validateRecipe(next);
55
+ if (persist) theme?.apply(recipe);
56
+ for (const control of root.querySelectorAll('[data-token]')) updateControl(control, recipe.values[control.dataset.token]);
57
+ updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
58
+ updateUnits();
59
+ changed = JSON.stringify(recipe) !== JSON.stringify(saved);
60
+ paint();
61
+ };
62
+ const filterExamples = () => {
63
+ const words = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
64
+ const select = root.querySelector('[data-add-example]');
65
+ const options = [...select.options];
66
+ for (const option of options) option.hidden = !words.every(word => option.dataset.search.includes(word));
67
+ for (const group of select.querySelectorAll('optgroup')) group.hidden = [...group.children].every(option => option.hidden);
68
+ if (select.selectedOptions[0]?.hidden) select.value = options.find(option => !option.hidden)?.value ?? '';
69
+ for (const card of root.querySelectorAll('[data-example-card]')) card.closest('section').hidden = !words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
70
+ const matches = options.filter(option => !option.hidden).length;
71
+ root.querySelector('[data-example-count]').textContent = matches ? `${matches} matching examples` : 'No examples match. Try another name or purpose.';
72
+ componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: matches === 0 });
73
+ };
74
+ const input = event => {
75
+ if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
76
+ const control = event.target.closest?.('[data-token]');
77
+ if (!control) return;
78
+ const field = fields.get(control.dataset.token);
79
+ const raw = event.detail?.value ?? control.value;
80
+ const value = control.dataset.editorKind ? editorValue(field, raw) : String(raw);
81
+ const next = { schemaVersion: 1, values: { ...recipe.values, [control.dataset.token]: value } };
82
+ try {
83
+ recipe = validateRecipe(next);
84
+ theme?.apply(recipe); changed = true;
85
+ updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
86
+ control.removeAttribute('aria-invalid');
87
+ for (const sibling of root.querySelectorAll('[data-token]')) if (sibling.dataset.token === control.dataset.token) updateControl(sibling, value);
88
+ updateUnits();
89
+ paint(); sayApplied('Theme remembered in this browser. Download it or save it to your repository.');
90
+ } catch (error) { control.setAttribute('aria-invalid', 'true'); say(error.message, true); }
91
+ };
92
+ const download = (data, name, type) => {
93
+ const url = URL.createObjectURL(new Blob([data], { type }));
94
+ const link = document.createElement('a'); link.href = url; link.download = name; link.click();
95
+ setTimeout(() => URL.revokeObjectURL(url), 0);
96
+ };
97
+ const click = async event => {
98
+ const button = event.target.closest?.('[data-action]');
99
+ if (!button || (button.dataset.action === 'save' && saving)) return;
100
+ try {
101
+ switch (button.dataset.action) {
102
+ case 'add': {
103
+ const select = root.querySelector('[data-add-example]');
104
+ const option = select.selectedOptions[0];
105
+ const selected = JSON.parse(option.dataset.example);
106
+ const card = Object.fromEntries(['id','title','description','exampleTitle','src','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
107
+ if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [...host.state.cards, card];
108
+ view.requestAnimationFrame(() => { paint(); filterExamples(); }); break;
109
+ }
110
+ case 'css': {
111
+ const response = await fetch(root.dataset.base + '_looma/theme-system.css', { signal: abort.signal });
112
+ if (!response.ok) throw new Error('Could not load the shared theme recipe.');
113
+ download(themeCSS(recipe, await response.text()), 'looma.theme.css', 'text/css'); say('Theme CSS downloaded.'); break;
114
+ }
115
+ case 'recipe': download(JSON.stringify(recipe, null, 2) + '\n', 'looma.theme.json', 'application/json'); say('Editable recipe downloaded.'); break;
116
+ case 'import': file.click(); break;
117
+ case 'reset': show(theme?.reset() ?? structuredClone(saved), false); say(theme?.storageAvailable ? 'Browser customization reset to the saved theme.' : 'Changes reset for this page. Browser storage is unavailable, so a previous browser theme may return after reload.'); break;
118
+ case 'save': {
119
+ if (!capability) throw new Error('Repository saving is available only in the local documentation server.');
120
+ const submitted = structuredClone(recipe);
121
+ saving = true;
122
+ say('Saving your theme and rebuilding the documentation…');
123
+ 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 });
124
+ const result = await response.json();
125
+ if (!response.ok) throw new Error(result.error ?? 'Could not save this theme.');
126
+ revision = result.revision; saved = submitted; theme?.baseline(saved);
127
+ changed = JSON.stringify(recipe) !== JSON.stringify(saved);
128
+ if (changed) { say('Saved the submitted theme. Your newer edits are still an unsaved preview.'); break; }
129
+ say('Saved to your repository. Reloading your design system…');
130
+ view.location.reload(); break;
131
+ }
132
+ }
133
+ } catch (error) { if (error.name !== 'AbortError' && alive) say(error.message, true); }
134
+ finally { if (button.dataset.action === 'save') saving = false; }
135
+ };
136
+ const change = async event => {
137
+ if (event.target.matches('[data-unit-for]')) {
138
+ const field = fields.get(event.target.dataset.unitFor);
139
+ try {
140
+ let current = recipe.values[field.id];
141
+ try { current = convertValueUnit(current, event.target.value, unitContext()); }
142
+ catch { current = convertValueUnit(view.getComputedStyle(document.documentElement).getPropertyValue(field.property).trim(), event.target.value, unitContext()); }
143
+ show({ ...recipe, values: { ...recipe.values, [field.id]: current } });
144
+ sayApplied(`${field.label} units updated and remembered in this browser.`);
145
+ } catch (error) { updateUnits(); say(error.message, true); }
146
+ }
147
+ if (event.target.matches('[data-palette]')) { show({ ...recipe, values: { ...recipe.values, ...themePalettes[event.target.value].values } }); sayApplied('Coordinated palette applied. Both color modes are remembered with your existing size, material and motion.'); }
148
+ if (event.target.matches('[data-preset]')) { show(structuredClone(startingPoints[event.target.value])); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
149
+ if (event.target === file && file.files[0]) {
150
+ try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
151
+ catch (error) { say(error.message, true); }
152
+ file.value = '';
153
+ }
154
+ };
155
+ const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
156
+ const synchronize = () => { if (theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
157
+ const ready = event => {
158
+ if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
159
+ };
160
+ const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); };
161
+ root.addEventListener('looma-example-remove', remove);
162
+ root.addEventListener('input', input);
163
+ root.addEventListener('blur', blur, true);
164
+ root.addEventListener('change', change);
165
+ root.addEventListener('click', click);
166
+ view.addEventListener('message', ready);
167
+ view.addEventListener(themeEvent, synchronize);
168
+ show(recipe, false); filterExamples();
169
+ if (theme?.storageAvailable === false) sayApplied('');
170
+ if (root.dataset.mode === 'local') {
171
+ fetch(root.dataset.base + '_looma/session', { signal: abort.signal }).then(async response => {
172
+ if (!response.ok) throw new Error('Local theme saving is unavailable.');
173
+ const local = await response.json(); if (!alive) return;
174
+ capability = local.capability;
175
+ saved = validateRecipe(local.recipe); revision = local.revision; theme?.baseline(saved);
176
+ if (!changed && !theme?.hasOverride) show(saved, false);
177
+ root.querySelector('[data-action="save"]').hidden = false;
178
+ }).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
179
+ }
180
+ return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
181
+ }
@@ -0,0 +1,16 @@
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~="looma-catalog"]) { --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
+ [data-preview-layout] { justify-items: stretch; }
9
+ 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; }
10
+
11
+ /* These headings are authored page content projected through the example card's heading slot. */
12
+ .looma-reference-examples { padding-block-start: var(--ui-space-5); }
13
+ .looma-example-heading h3 { margin: 0; font-size: var(--ui-font-size-lg); line-height: var(--ui-line-height-tight); }
14
+ .looma-example-heading p { margin: var(--ui-space-2) 0 0; font-size: var(--ui-font-size-sm); color: var(--ui-text-secondary); }
15
+ html[data-preview-reference], html[data-preview-reference] body { background: var(--ui-surface); }
16
+ html[data-preview-reference] [data-preview] { min-block-size: 11rem; }
@@ -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
+ }