@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
package/docs/site.mjs ADDED
@@ -0,0 +1,239 @@
1
+ import { cp, mkdir, readFile, readdir, realpath, rename, rm, symlink, unlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { createRequire } from 'node:module';
5
+ import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { discoverComponents, escapeHtml, mergeCatalogs, readCatalog, readJson } from './catalog.mjs';
7
+ import { cardMarkup, componentPath, exampleKey, previewPath, referencePage } from './render.mjs';
8
+ import { defaultRecipe, themeCSS } from './theme.mjs';
9
+ import { customizerResource } from './customizer-render.mjs';
10
+ import { catalogResource } from './catalog-view.mjs';
11
+ import { cacheDirectory, rejectSymlink, withBuildLock } from './workspace.mjs';
12
+
13
+ const own = name => fileURLToPath(new URL(name, import.meta.url));
14
+ /** Resolve import-only optional tooling peers in the consumer's ESM package scope. */
15
+ export async function tool(name, root) {
16
+ if (!['markupress', '@nextwebwg/htmlkit', 'vite'].includes(name)) throw new Error(`Unknown documentation tool ${name}.`);
17
+ const directory = await cacheDirectory(root, 'tooling');
18
+ const file = path.join(directory, name.replaceAll('/', '-').replace('@', '') + '.mjs');
19
+ await rejectSymlink(file);
20
+ const temporary = file + '.' + randomUUID() + '.tmp';
21
+ try { await writeFile(temporary, `export * from ${JSON.stringify(name)};\n`, { flag: 'wx' }); await rename(temporary, file); }
22
+ finally { await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
23
+ return import(pathToFileURL(file).href);
24
+ }
25
+ const normalizeBase = base => '/' + String(base ?? '/').replace(/^\/+|\/+$/g, '') + (String(base ?? '/').replaceAll('/', '') ? '/' : '');
26
+ const relativeHref = (from, to) => path.relative(path.dirname(from), to).split(path.sep).join('/');
27
+
28
+ async function authoredFiles(directory) {
29
+ const files = [];
30
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
31
+ if (entry.name.startsWith('.')) continue;
32
+ const file = path.join(directory, entry.name);
33
+ if (entry.isDirectory()) files.push(...await authoredFiles(file)); else files.push(file);
34
+ }
35
+ return files;
36
+ }
37
+
38
+ /** Prepare a disposable documentation tree from published catalogs and opted-in project roots. */
39
+ export async function prepareDocumentation({ root, config = {}, catalog }) {
40
+ root = await realpath(path.resolve(root));
41
+ const base = normalizeBase(config.base);
42
+ const catalogs = [catalog ?? await readCatalog()];
43
+ for (const entry of config.catalogs ?? []) {
44
+ const file = entry.startsWith('.') || path.isAbsolute(entry) ? path.resolve(root, entry) : createRequire(path.join(root, 'package.json')).resolve(entry);
45
+ catalogs.push(await readCatalog(file));
46
+ }
47
+ for (const entry of config.components ?? []) catalogs.push(await discoverComponents({ root, ...entry }));
48
+ const combined = mergeCatalogs(catalogs, { categories: config.categories });
49
+ for (const component of combined.components) {
50
+ component.examples = component.examples.length ? component.examples : [{ id: `${component.id}/default`, name: 'default', title: component.title, description: component.description, markup: `<${component.tag}></${component.tag}>`, frameworks: {}, resizable: false }];
51
+ }
52
+ const generated = await cacheDirectory(root, 'generated');
53
+ const output = path.resolve(root, config.outDir ?? '.looma/site');
54
+ if (output === generated || output.startsWith(generated + path.sep)) throw new Error('Choose an output directory outside .looma/generated, which owns disposable source files.');
55
+ await rm(generated, { recursive: true, force: true });
56
+ await mkdir(generated, { recursive: true });
57
+ let content = path.join(generated, 'docs');
58
+ if (config.content) {
59
+ const relative = path.relative(root, path.resolve(root, config.content));
60
+ if (!relative || relative.startsWith('..' + path.sep) || path.isAbsolute(relative) || relative.split(path.sep).includes('.looma')) throw new Error('Authored content must be a directory inside the project, outside .looma.');
61
+ const mirror = path.join(generated, 'project');
62
+ await rm(mirror, { recursive: true, force: true });
63
+ // Preserve every relative dependency path without copying the project or rewriting code examples.
64
+ const mirrorParents = async (source, target, parts) => {
65
+ await mkdir(target, { recursive: true });
66
+ for (const entry of await readdir(source, { withFileTypes: true })) {
67
+ if (['.looma', '.git', 'node_modules', 'package.json'].includes(entry.name)) continue;
68
+ const original = path.join(source, entry.name), mirrored = path.join(target, entry.name);
69
+ if (entry.name === parts[0]) {
70
+ if (parts.length > 1) await mirrorParents(original, mirrored, parts.slice(1));
71
+ } else await symlink(original, mirrored, entry.isDirectory() ? 'dir' : 'file');
72
+ }
73
+ };
74
+ await mirrorParents(root, mirror, relative.split(path.sep));
75
+ content = path.join(mirror, relative);
76
+ }
77
+ const previews = path.join(generated, 'previews');
78
+ await rm(content, { recursive: true, force: true });
79
+ await rm(previews, { recursive: true, force: true });
80
+ await mkdir(path.join(content, 'components'), { recursive: true });
81
+ await mkdir(previews, { recursive: true });
82
+ const card = path.join(generated, 'card.html');
83
+ await cp(own('card.html'), card);
84
+ await cp(own('card.js'), path.join(generated, 'card.js'));
85
+ await writeFile(path.join(generated, 'catalog-view.html'), catalogResource(combined.groups));
86
+ await cp(own('catalog-view.js'), path.join(generated, 'catalog-view.js'));
87
+ await cp(own('preview.js'), path.join(generated, 'preview.js'));
88
+ for (const file of ['reference.html', 'reference.js']) await cp(own(file), path.join(generated, file));
89
+ for (const file of ['customizer.js', 'theme.mjs', 'theme-session.mjs', 'value-editors.mjs']) await cp(own(file), path.join(generated, file));
90
+ const write = async (file, source) => { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, source); };
91
+ const examples = [];
92
+ // HTMLKit route groups retain their labels/order in navigation without changing public URLs.
93
+ const groupDirectories = new Map(combined.groups.map((group, index) => [group.id,
94
+ `${String(index + 1).padStart(2, '0')}.(${group.label.replace(/[\\/()]/g, '-')})`]));
95
+ const generatedReferences = new Set();
96
+ const resourceByTag = new Map(combined.resources.map(resource => [resource.tag, resource]));
97
+ for (const component of combined.components) {
98
+ if (!component.navigationParent) {
99
+ const page = path.join(content, 'components', groupDirectories.get(component.group), component.tag + '.md');
100
+ await write(page, referencePage(component, combined, relativeHref(page, card), base));
101
+ generatedReferences.add(`components/${component.tag}.md`);
102
+ }
103
+ for (const example of component.examples) {
104
+ const key = exampleKey(example.id);
105
+ const file = path.join(previews, key + '.html');
106
+ const tags = [...new Set([...example.markup.matchAll(/<([a-z][\w]*-[\w-]+)\b/g)].map(match => match[1]))];
107
+ const links = tags.filter(tag => resourceByTag.has(tag)).map(tag => `<link rel="component" href="${escapeHtml(resourceByTag.get(tag).resource)}">`).join('\n');
108
+ // Navigation specimens name destinations supplied by their host application. Give those
109
+ // fragments local stand-ins inside the isolated stage; keep real documentation links checked.
110
+ const declaredIds = new Set([...example.markup.matchAll(/\sid=["']([^"']+)["']/g)].map(match => match[1]));
111
+ const destinations = [...new Set([...example.markup.matchAll(/\bhref=["']#([^"']+)["']/g)].map(match => match[1]))].filter(id => !declaredIds.has(id));
112
+ const anchors = destinations.map(id => `<span id="${escapeHtml(id)}" hidden></span>`).join('');
113
+ const setup = path.join(previews, key + '.js');
114
+ const behavior = example.behavior;
115
+ await write(setup, `import { connect as connectPreview } from '../preview.js';\n${behavior ? `import setupExample from ${JSON.stringify(behavior)};\n` : ''}export function connect(host) { const stopPreview = connectPreview(host); ${behavior ? 'const stopExample = setupExample(host.element);' : ''} return () => { ${behavior ? 'stopExample?.();' : ''} stopPreview(); }; }\n`);
116
+ await write(file, ` ${links}\n<template component="looma-preview-${key}" module="./${key}.js"><defs><meta name="hk:navigation" content="hidden"><title>${escapeHtml(example.title)}</title></defs><div data-preview="${escapeHtml(example.id)}"${component.group === 'layout' ? ' data-preview-layout' : ''}>${example.markup}${anchors}</div></template>\n`);
117
+ examples.push({ ...example, file, component: component.id, path: base + previewPath(example).slice(1) });
118
+ }
119
+ }
120
+ const gallery = combined.groups.map(group => {
121
+ const components = combined.components.filter(c => c.group === group.id && !c.navigationParent);
122
+ return components.length ? `<section data-catalog-group="${escapeHtml(group.id)}">\n\n## ${escapeHtml(group.label)}\n\n<ui-grid columns="3" min="sm">${components.map(component => `<div data-category="${escapeHtml(component.group)}" data-catalog-search="${escapeHtml(`${component.tag} ${component.title} ${component.description}`)}">${cardMarkup(component.examples[0], base, component)}</div>`).join('')}</ui-grid>\n\n</section>` : '';
123
+ }).join('\n\n');
124
+ await write(path.join(content, 'components/index.md'), `---\ntitle: Components\n---\n\n# Components\n\nExplore the live examples that also power your theme customizer.\n\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), card)}">\n<link rel="component" href="@threadlabs/looma/components/ui-grid/ui-grid.html">\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), path.join(generated, 'catalog-view.html'))}">\n\n<looma-catalog>\n\n${gallery}\n\n</looma-catalog>\n`);
125
+ await write(path.join(content, 'index.md'), `---\ntitle: ${JSON.stringify(config.title ?? 'Your design system')}\n---\n\n# ${config.title ?? 'Your design system'}\n\nWeave your design system from Looma and your own HTML Next components.\n\n[Explore components](/components/) · [Customize your theme](/customize/)\n`);
126
+ await write(path.join(content, 'design-system.md'), await readFile(own('README.md'), 'utf8'));
127
+ if (config.content) {
128
+ const authored = path.resolve(root, config.content);
129
+ for (const source of await authoredFiles(authored)) {
130
+ const target = path.join(content, path.relative(authored, source));
131
+ const publicPath = path.relative(authored, source).split(path.sep)
132
+ .filter(segment => !/^\d+\.\([^)]*\)$/.test(segment)).map(segment => segment.replace(/^\d+\./, '')).join('/');
133
+ if (generatedReferences.has(publicPath)) throw new Error(`Authored/generated documentation collision: ${source} would replace a generated component reference. Add guidance under another path.`);
134
+ let exists = false;
135
+ try { await readFile(target); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
136
+ // A project's landing page and footer are branding/content, never API overrides.
137
+ if (exists && !['index.md', '_footer.md'].includes(path.relative(content, target))) throw new Error(`Authored/generated documentation collision: ${source} would replace ${target}. Add guidance under another path.`);
138
+ await mkdir(path.dirname(target), { recursive: true }); await cp(source, target);
139
+ }
140
+ }
141
+ const customizer = path.join(generated, 'customizer.html');
142
+ await write(customizer, customizerResource({ catalog: combined, config, base }, config.recipe));
143
+ const customizerPage = path.join(content, 'customize.md');
144
+ await write(customizerPage, `---\ntitle: Customize\n---\n\n# Weave your design system\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
145
+ await write(path.join(generated, 'catalog.json'), JSON.stringify(combined));
146
+ const redirects = {};
147
+ for (const component of combined.components) {
148
+ const target = componentPath(component);
149
+ redirects[`/components/${component.group}/${component.tag}/`] = target;
150
+ if (component.group === 'editor') redirects[`/editor/${component.tag}/`] = target;
151
+ if (component.navigationParent) redirects[`/parts/${component.tag}/`] = target;
152
+ }
153
+ return { root, config, base, catalog: combined, examples, generated, content, previews, redirects };
154
+ }
155
+
156
+ /** Bundle validated theme restoration into HTMLKit's before-paint head hook. */
157
+ async function themeBootstrap(site) {
158
+ const { build } = await tool('vite', site.root);
159
+ const entry = path.join(site.generated, 'theme-bootstrap.mjs');
160
+ const baseline = site.config.recipe ?? { schemaVersion: 1, values: {} };
161
+ await writeFile(entry, `import { installDocumentationTheme } from './theme-session.mjs';\nimport systemCSS from '@threadlabs/looma/theme-system.css?inline';\ninstallDocumentationTheme(window, { base: ${JSON.stringify(site.base)}, baseline: ${JSON.stringify(baseline)}, systemCSS });\n`);
162
+ const bundled = await build({ root: site.root, configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry, name: 'LoomaDocumentationTheme', formats: ['iife'] } } });
163
+ return (Array.isArray(bundled) ? bundled[0] : bundled).output.find(file => file.type === 'chunk').code;
164
+ }
165
+
166
+ /** One plugin powers both the public consumer and local repositories. */
167
+ export function documentationPlugin(site) {
168
+ return {
169
+ name: 'looma-design-system',
170
+ buildEnd: context => isolatePreviewScripts(site, context),
171
+ generate: async () => ({ '/_looma/theme-system.css': await readFile(fileURLToPath(import.meta.resolve('@threadlabs/looma/theme-system.css')), 'utf8') }),
172
+ config: () => ({ pages: [{ dir: path.relative(site.root, site.previews), prefix: '/_looma/preview/', layout: false }], css: [own('documentation.css')] }),
173
+ };
174
+ }
175
+
176
+ export async function documentationOptions(site) {
177
+ const { siteOptions } = await tool('markupress', site.root);
178
+ const styles = ['tokens.css', 'theme-light.css', 'theme-dark.css', 'theme-system.css'].map(file => fileURLToPath(import.meta.resolve(`@threadlabs/looma/${file}`)));
179
+ const options = siteOptions({ root: site.root, contentDir: path.relative(site.root, site.content), title: site.config.title ?? 'Your design system', base: site.base, outDir: site.config.outDir ?? 'site', ...(site.config.origin ? { origin: site.config.origin } : {}), ...(site.config.logo ? { logo: site.config.logo } : {}), ...(site.config.nav ? { nav: site.config.nav } : {}) }, { plugins: [documentationPlugin(site)], css: styles, redirects: site.redirects });
180
+ const savedTheme = path.join(site.generated, 'saved-theme.css');
181
+ await writeFile(savedTheme, site.config.themeCSSContents ?? themeCSS(site.config.recipe ?? defaultRecipe()));
182
+ const themeStyles = [savedTheme, ...await portableThemeStyles(site)];
183
+ return { ...options, plugins: [...options.plugins, { name: 'looma-saved-theme', config: () => ({ css: themeStyles }) },
184
+ // Markupress supplies its head script in its own config hook; compose after it.
185
+ { name: 'looma-browser-theme', config: async current => ({ headScript: [current.headScript, await themeBootstrap(site)].filter(Boolean).join('\n;\n') }) }] };
186
+ }
187
+
188
+ /** Opaque example frames cannot fetch local fonts without host-specific CORS headers.
189
+ * Bundle those font files into theme CSS for both the local server and static output. */
190
+ async function portableThemeStyles(site) {
191
+ const overrides = site.config.overrides ?? [];
192
+ if (!overrides.length) return [];
193
+ const { build } = await tool('vite', site.root);
194
+ const entry = path.join(site.generated, 'theme-entry.css');
195
+ await writeFile(entry, overrides.map(file => `@import ${JSON.stringify(path.resolve(site.root, file))};`).join('\n'));
196
+ const bundled = await build({ root: site.root, base: './', configFile: false, logLevel: 'silent',
197
+ build: { write: false, minify: false, cssMinify: false,
198
+ assetsInlineLimit: file => /\.(woff2?|ttf|otf)$/i.test(file),
199
+ rollupOptions: { input: entry, output: { assetFileNames: 'theme-assets/[name]-[hash][extname]' } } } });
200
+ const styles = [];
201
+ for (const output of (Array.isArray(bundled) ? bundled[0] : bundled).output) {
202
+ if (output.type !== 'asset') continue;
203
+ const file = path.join(site.generated, output.fileName);
204
+ await mkdir(path.dirname(file), { recursive: true });
205
+ await writeFile(file, output.source);
206
+ if (file.endsWith('.css')) styles.push(file);
207
+ }
208
+ return styles;
209
+ }
210
+
211
+ /** Build through HTMLKit, then make opaque preview scripts portable to static hosts without CORS. */
212
+ export async function buildDocumentation({ root = process.cwd(), config = {}, catalog } = {}) {
213
+ return withBuildLock(root, async () => {
214
+ const site = await prepareDocumentation({ root, config, catalog });
215
+ const { buildApplication } = await tool('@nextwebwg/htmlkit', site.root);
216
+ const built = await buildApplication(await documentationOptions(site));
217
+ return { ...built, site };
218
+ });
219
+ }
220
+
221
+ async function isolatePreviewScripts(site, { outDir, writeFile: writeOutput }) {
222
+ const { build } = await tool('vite', site.root);
223
+ for (const example of site.examples) {
224
+ const htmlFile = path.join(outDir, '_looma/preview', exampleKey(example.id), 'index.html');
225
+ let html = await readFile(htmlFile, 'utf8');
226
+ const scripts = [...html.matchAll(/<script\b[^>]*type="module"[^>]*src="([^"]+)"[^>]*><\/script>/g)];
227
+ if (!scripts.length) throw new Error(`No browser module found for isolated preview ${example.id}.`);
228
+ for (const [tag, url] of scripts) {
229
+ const input = path.join(outDir, url.slice(site.base.length));
230
+ const bundle = await build({ configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry: input, name: 'LoomaPreview', formats: ['iife'] }, rollupOptions: { output: { codeSplitting: false } } } });
231
+ const code = (Array.isArray(bundle) ? bundle[0] : bundle).output.find(output => output.type === 'chunk').code;
232
+ const name = `${exampleKey(example.id)}.js`;
233
+ await writeOutput('_looma/runtime/' + name, code);
234
+ html = html.replace(tag, `<script src="${site.base}_looma/runtime/${name}" defer></script>`);
235
+ }
236
+ html = html.replace(/<link\b[^>]*rel="modulepreload"[^>]*>/g, '');
237
+ await writeFile(htmlFile, html);
238
+ }
239
+ }
package/docs/store.mjs ADDED
@@ -0,0 +1,92 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, open, readFile, realpath, rename, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { defaultRecipe, themeCSS, validateRecipe } from './theme.mjs';
6
+
7
+ const missing = error => error.code === 'ENOENT';
8
+ const failure = (message, status = 409) => Object.assign(new Error(message), { status });
9
+ const digest = values => createHash('sha256').update(JSON.stringify(values)).digest('hex');
10
+ const contained = (root, file) => file.startsWith(root + path.sep);
11
+
12
+ /** A local writer owns only two explicitly configured theme files. */
13
+ export async function createThemeStore({ root, recipe = 'looma.theme.json', css = 'looma.theme.css' }) {
14
+ root = await realpath(path.resolve(root));
15
+ const destination = value => {
16
+ if (typeof value !== 'string' || !value || path.isAbsolute(value)) throw failure('Theme paths must be relative paths inside the repository.', 400);
17
+ const file = path.resolve(root, value);
18
+ if (!contained(root, file)) throw failure('Theme paths must stay inside the repository.', 400);
19
+ return file;
20
+ };
21
+ for (const value of [recipe, css]) {
22
+ if (typeof value === 'string' && value.split(/[\\/]/).some(part => ['.looma', '.git', 'node_modules'].includes(part))) throw failure('Theme outputs must stay outside generated files, Git metadata and node_modules.', 400);
23
+ }
24
+ const recipeFile = destination(recipe), cssFile = destination(css);
25
+ if (recipeFile === cssFile || !recipeFile.endsWith('.json') || !cssFile.endsWith('.css')) throw failure('Choose separate .json recipe and .css theme files.', 400);
26
+ const lockFile = destination('.looma/theme-write.lock');
27
+ const safe = async file => {
28
+ const pieces = path.relative(root, file).split(path.sep);
29
+ let cursor = root;
30
+ for (const piece of pieces) {
31
+ cursor = path.join(cursor, piece);
32
+ try { if ((await lstat(cursor)).isSymbolicLink()) throw failure(`Theme paths cannot traverse a symlink: ${path.relative(root, cursor)}.`, 400); }
33
+ catch (error) { if (!missing(error)) throw error; }
34
+ }
35
+ };
36
+ for (const file of [recipeFile, cssFile, lockFile]) await safe(file);
37
+ const bytes = async file => { await safe(file); try { return await readFile(file, 'utf8'); } catch (error) { if (missing(error)) return null; throw error; } };
38
+ const snapshot = async () => {
39
+ const [json, css] = await Promise.all([bytes(recipeFile), bytes(cssFile)]);
40
+ if (css !== null && !css.startsWith('/* Looma shared semantic theme. */')) throw failure('The theme CSS is not a generated Looma file. Choose a new output path and keep manual CSS in an overrides file.');
41
+ return { json, css, recipe: json === null ? defaultRecipe() : validateRecipe(JSON.parse(json)), revision: digest([json, css]) };
42
+ };
43
+ const read = async ({ includeCSS = false } = {}) => {
44
+ await safe(lockFile); await mkdir(path.dirname(lockFile), { recursive: true }); await safe(lockFile);
45
+ let lock;
46
+ try { lock = await open(lockFile, 'wx', 0o600); }
47
+ catch (error) { if (error.code === 'EEXIST') throw failure('A theme editor holds .looma/theme-write.lock. Retry after its save. If the old editor stopped, close it before removing that lock and restarting.'); throw error; }
48
+ try { const { recipe, revision, css } = await snapshot(); return { recipe, revision, ...(includeCSS ? { css } : {}) }; }
49
+ finally { try { await lock.close(); } finally { await unlink(lockFile); } }
50
+ };
51
+ const stage = async (file, data) => {
52
+ await safe(file); await mkdir(path.dirname(file), { recursive: true }); await safe(file);
53
+ const temp = file + '.' + randomUUID() + '.tmp';
54
+ const handle = await open(temp, 'wx', 0o644);
55
+ try { await handle.writeFile(data); await handle.sync(); } catch (error) { await unlink(temp); throw error; } finally { await handle.close(); }
56
+ return temp;
57
+ };
58
+ const save = async input => {
59
+ const next = validateRecipe(input?.recipe);
60
+ if (typeof input?.revision !== 'string') throw failure('A theme revision is required. Reload before saving.', 400);
61
+ await safe(lockFile); await mkdir(path.dirname(lockFile), { recursive: true }); await safe(lockFile);
62
+ let lock;
63
+ try { lock = await open(lockFile, 'wx', 0o600); }
64
+ catch (error) { if (error.code === 'EEXIST') throw failure('Another theme save is in progress. Reload before saving again.'); throw error; }
65
+ const temporary = new Set(); let original; let committedRecipe = false;
66
+ try {
67
+ original = await snapshot();
68
+ if (original.revision !== input.revision) throw failure('Theme files changed outside this editor. Reload before saving; your unsaved preview is still here.');
69
+ const systemCSS = await readFile(fileURLToPath(import.meta.resolve('@threadlabs/looma/theme-system.css')), 'utf8');
70
+ const nextJson = JSON.stringify(next, null, 2) + '\n';
71
+ const nextCSS = themeCSS(next, systemCSS);
72
+ const stagedRecipe = await stage(recipeFile, nextJson); temporary.add(stagedRecipe);
73
+ const stagedCSS = await stage(cssFile, nextCSS); temporary.add(stagedCSS);
74
+ if ((await snapshot()).revision !== original.revision) throw failure('Theme files changed while saving. Reload before saving again.');
75
+ await safe(recipeFile); await safe(cssFile);
76
+ await rename(stagedRecipe, recipeFile); temporary.delete(stagedRecipe); committedRecipe = true;
77
+ // CSS is the visible commit point: applications consume this complete file.
78
+ await rename(stagedCSS, cssFile); temporary.delete(stagedCSS);
79
+ return { recipe: next, revision: digest([nextJson, nextCSS]) };
80
+ } catch (error) {
81
+ if (committedRecipe) {
82
+ if (original.json === null) await unlink(recipeFile);
83
+ else { const restore = await stage(recipeFile, original.json); temporary.add(restore); await rename(restore, recipeFile); temporary.delete(restore); }
84
+ }
85
+ throw error;
86
+ } finally {
87
+ try { await Promise.all([...temporary].map(file => unlink(file).catch(error => { if (!missing(error)) throw error; }))); }
88
+ finally { try { await lock.close(); } finally { await unlink(lockFile); } }
89
+ }
90
+ };
91
+ return { root, recipeFile, cssFile, read, save };
92
+ }
@@ -0,0 +1,51 @@
1
+ import { themeCSS, validateRecipe } from './theme.mjs';
2
+
3
+ const identity = Symbol.for('looma.documentation.theme');
4
+ export const themeEvent = 'looma-documentation-theme';
5
+
6
+ /** Browser choices belong to one documentation base; repository writes stay explicit. */
7
+ export function createThemeSession(view, { base, baseline, systemCSS }) {
8
+ const key = `looma:documentation-theme:v1:${base}`;
9
+ let original = validateRecipe(baseline);
10
+ let selected;
11
+ let storageAvailable = true;
12
+ const style = view.document.createElement('style');
13
+ style.dataset.loomaDocumentationTheme = '';
14
+ view.document.head.append(style);
15
+ const read = value => { try { return value ? validateRecipe(JSON.parse(value)) : undefined; } catch { return undefined; } };
16
+ try { selected = read(view.localStorage.getItem(key)); } catch { storageAvailable = false; }
17
+ const paint = () => {
18
+ view.document.documentElement.toggleAttribute('data-looma-documentation-theme', Boolean(selected));
19
+ style.textContent = selected ? themeCSS(selected, systemCSS, { scope: 'documentation' }) : '';
20
+ view.dispatchEvent(new view.CustomEvent(themeEvent, { detail: { css: selected ? themeCSS(selected, systemCSS) : '' } }));
21
+ };
22
+ const persist = () => {
23
+ try { if (selected) view.localStorage.setItem(key, JSON.stringify(selected)); else view.localStorage.removeItem(key); storageAvailable = true; } catch { storageAvailable = false; }
24
+ paint();
25
+ };
26
+ const session = {
27
+ get recipe() { return structuredClone(selected ?? original); },
28
+ get hasOverride() { return selected !== undefined; },
29
+ get storageAvailable() { return storageAvailable; },
30
+ get css() { return selected ? themeCSS(selected, systemCSS) : ''; },
31
+ apply(recipe) { selected = validateRecipe(recipe); persist(); },
32
+ reset() { selected = undefined; persist(); return structuredClone(original); },
33
+ baseline(recipe) { original = validateRecipe(recipe); },
34
+ };
35
+ view.addEventListener('storage', event => {
36
+ if (event.key !== key && event.key !== null) return;
37
+ const next = read(event.newValue);
38
+ if (event.newValue !== null && !next) return;
39
+ selected = next; paint();
40
+ });
41
+ paint();
42
+ return session;
43
+ }
44
+
45
+ /** The early head script and component modules share one session across client navigation. */
46
+ export function documentationTheme(view) { return view[identity]; }
47
+
48
+ export function installDocumentationTheme(view, config) {
49
+ if (view.parent !== view) return;
50
+ return view[identity] ??= createThemeSession(view, config);
51
+ }
package/docs/theme.mjs ADDED
@@ -0,0 +1,219 @@
1
+ const bounds = {
2
+ 'text-size': [14, 24], 'control-size': [32, 64], 'corner-roundness': [0, 32],
3
+ 'line-weight': [.5, 3], density: [.75, 1.5], 'scale-contrast': [1, 1.5],
4
+ 'space-curve': [.5, 2], 'text-weight': [400, 700], 'outer-depth': [0, 2], 'inner-depth': [0, 2],
5
+ 'response-time': [0, 600],
6
+ };
7
+
8
+ /** The shared authoring schema; ids remain stable in saved recipes. */
9
+ export const themeFields = [
10
+ ['Colour', 'action', 'Action', '--ui-accent', 'colour', '#5b55d6', 'Primary commands and selected controls.'],
11
+ ['Colour', 'canvas', 'Canvas', '--ui-surface', 'colour', '#f6f6f8', 'The ground behind surfaces.'],
12
+ ['Colour', 'surface', 'Surface', '--ui-surface-raised', 'colour', '#ffffff', 'Fields, cards and floating surfaces.'],
13
+ ['Colour', 'text', 'Text', '--ui-text', 'colour', '#1a1a1a', 'Readable content; supporting text follows this and Surface.'],
14
+ ['Type', 'body-font', 'Body font', '--ui-font-sans', 'font', 'system-ui, sans-serif', 'The shared interface typeface.'],
15
+ ['Type', 'display-font', 'Display font', '--ui-font-display', 'font', 'system-ui, sans-serif', 'The heading typeface.'],
16
+ ['Type', 'text-size', 'Text size', '--ui-font-size', 'positive-length', '16px', 'Base type and choice-mark size. Accepts native CSS lengths, such as 1rem.'],
17
+ ['Type', 'text-weight', 'Emphasis weight', '--ui-font-medium', 'weight', '500', 'Control-label emphasis; body and headings keep their roles.'],
18
+ ['Size & spacing', 'control-size', 'Control size', '--ui-control-size', 'positive-length', '40px', 'One shared height for buttons, fields and swatches.'],
19
+ ['Size & spacing', 'density', 'Density', '--ui-density', 'positive', '1', 'More or less breathing room throughout the interface.'],
20
+ ['Size & spacing', 'scale-contrast', 'Size ratio', '--ui-scale-ratio', 'ratio', '1.2', 'The multiplier between small, medium and large controls.'],
21
+ ['Size & spacing', 'space-curve', 'Spacing curve', '--ui-space-curve', 'nonnegative', '1', 'Spacing growth: 1 is linear, 2 is quadratic. Independent of control sizes.'],
22
+ ['Size & spacing', 'corner-roundness', 'Corner roundness', '--ui-radius-md', 'length', '8px', 'One corner family across controls and surfaces.'],
23
+ ['Size & spacing', 'line-weight', 'Line weight', '--ui-border-width', 'length', '1px', 'Shared edge weight; focus stays independently visible.'],
24
+ ['Material', 'outer-depth', 'Outer depth', '--ui-outer-depth', 'nonnegative', '0.6', 'How strongly controls and floating surfaces rise above the page.'],
25
+ ['Material', 'inner-depth', 'Inner depth', '--ui-inner-depth', 'nonnegative', '0.3', 'How strongly fields and pressed controls sit into their surface.'],
26
+ ['Material', 'glassiness', 'Glassiness', '--ui-glassiness', 'fraction', '0', 'Frosted blur, richer background colour and light-catching glass edges. Content stays readable.'],
27
+ ['Material', 'surface-separation', 'Surface separation', '--ui-surface-separation', 'fraction', '0', 'One material offset, scaled by popup purpose and actual size: quiet tooltips through substantial dialogs.'],
28
+ ['Material', 'translucency', 'Overlay translucency', '--ui-overlay-translucency', 'fraction', '0', 'How much background comes through floating surfaces.'],
29
+ ['Material', 'edge-light', 'Edge light', '--ui-edge-light', 'fraction', '0', 'Light caught by surface edges, independent of depth.'],
30
+ ['Motion', 'response-time', 'Response time', '--ui-motion-fast', 'time', '120ms', 'Shared feedback pace; floating transitions take proportionally longer.'],
31
+ ['Motion', 'response-curve', 'Response curve', '--ui-motion-ease', 'easing', 'cubic-bezier(0.2,0,0.2,1)', 'How movement accelerates and settles.'],
32
+ ].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description, ...(bounds[id] ? { min: bounds[id][0], max: bounds[id][1] } : type === 'fraction' ? { min: 0, max: 1 } : {}) }));
33
+
34
+ export const defaultRecipe = () => ({ schemaVersion: 1, values: Object.fromEntries(themeFields.map(field => [field.id, field.defaultValue])) });
35
+ const starting = values => ({ schemaVersion: 1, values: { ...defaultRecipe().values, ...values } });
36
+ export const startingPoints = {
37
+ clear: defaultRecipe(),
38
+ bookish: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-size': '42px', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18', 'edge-light': '1', 'response-time': '150ms', 'response-curve': 'ease' }),
39
+ wiki: starting({ action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-size': '34px', density: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
40
+ editorial: starting({ action: '#182c24', canvas: '#eee9dc', surface: '#fffdf5', text: '#182c24', 'corner-roundness': '0px', 'outer-depth': '0', 'inner-depth': '0', 'body-font': 'Georgia, serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'line-weight': '2px', 'response-time': '90ms', 'response-curve': 'linear' }),
41
+ night: starting({ action: '#a7b8ff', canvas: '#11141c', surface: '#202634', text: '#edf1fb', 'corner-roundness': '12px', 'outer-depth': '1.4', 'inner-depth': '0.45', translucency: '0.2', 'edge-light': '0.65', 'response-time': '210ms', 'response-curve': 'cubic-bezier(0.2,0.8,0.2,1)' }),
42
+ garden: starting({ action: '#256342', canvas: '#eef5e7', surface: '#fcfff6', text: '#173121', 'corner-roundness': '22px', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2', 'control-size': '46px', 'scale-contrast': '1.24', density: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
43
+ };
44
+ // Preserve the public historical startingPoints.library import without a duplicate UI preset.
45
+ Object.defineProperty(startingPoints, 'library', { value: startingPoints.bookish });
46
+ export const startingPointLabels = { clear: 'Clear', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
47
+ const paletteKeys = ['action', 'canvas', 'surface', 'text'];
48
+ // Palette choices move only the shared color seeds; size, material and motion stay independent.
49
+ export const themePalettes = Object.fromEntries([
50
+ ['clear', 'Indigo & mist'], ['bookish', 'Plum & paper'], ['wiki', 'Indigo & chalk'],
51
+ ['editorial', 'Ink & parchment'], ['night', 'Midnight'], ['garden', 'Forest & cream'],
52
+ ].map(([id, label]) => [id, { label, values: Object.fromEntries(paletteKeys.map(key => [key, startingPoints[id].values[key]])) }]));
53
+
54
+ /** Identify a coordinated palette without coupling it to the rest of a recipe. */
55
+ export function paletteFor(input) {
56
+ const { values } = validateRecipe(input);
57
+ return Object.entries(themePalettes).find(([, palette]) => paletteKeys.every(key => values[key].toLowerCase() === palette.values[key].toLowerCase()))?.[0] ?? 'custom';
58
+ }
59
+
60
+ const scalar = /^[+]?(?:\d+(?:\.\d*)?|\.\d+)$/;
61
+ const length = /^(?:0|[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:px|rem|em|ex|ch|cap|ic|lh|rlh|vw|vh|vi|vb|vmin|vmax|svw|svh|lvw|lvh|dvw|dvh|cm|mm|Q|in|pt|pc))$/;
62
+ const font = /^(?:"[^"\\;{}<>@\x00-\x1f]+"|'[^'\\;{}<>@\x00-\x1f]+'|[\p{L}_-][\p{L}\p{N}_ -]*)(?:\s*,\s*(?:"[^"\\;{}<>@\x00-\x1f]+"|'[^'\\;{}<>@\x00-\x1f]+'|[\p{L}_-][\p{L}\p{N}_ -]*))*$/u;
63
+ const time = /^[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:ms|s)$/;
64
+ function easing(value) {
65
+ if (/^(ease|linear|ease-in|ease-out|ease-in-out|step-start|step-end)$/.test(value)) return true;
66
+ const match = /^cubic-bezier\(\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*\)$/.exec(value);
67
+ return Boolean(match && match.slice(1).every(n => Number.isFinite(Number(n))) && [1, 3].every(i => Number(match[i]) >= 0 && Number(match[i]) <= 1) && [2, 4].every(i => Number(match[i]) >= -1 && Number(match[i]) <= 2));
68
+ }
69
+
70
+ // Authoring bounds are shared by every entry path. Relative lengths keep their units;
71
+ // viewport/font-metric lengths are constrained at use time rather than guessed in JS.
72
+
73
+ const pixels = { px: 1, cm: 96 / 2.54, mm: 96 / 25.4, Q: 96 / 101.6, in: 96, pt: 96 / 72, pc: 16 };
74
+ function boundedNumber(field, value) {
75
+ if (field.type === 'time') return parseFloat(value) * (value.endsWith('ms') ? 1 : 1000);
76
+ if (field.type.includes('length')) {
77
+ if (value === '0') return 0;
78
+ const unit = value.match(/[a-z]+$/i)?.[0];
79
+ return Object.hasOwn(pixels, unit) ? parseFloat(value) * pixels[unit] : undefined;
80
+ }
81
+ return Number(value);
82
+ }
83
+
84
+ /** Validate the whole recipe before previewing or writing any output. */
85
+ export function validateRecipe(input) {
86
+ if (!input || input.schemaVersion !== 1 || !input.values || typeof input.values !== 'object' || Array.isArray(input.values)) throw new Error('Unsupported theme recipe version or values.');
87
+ const known = new Set(themeFields.map(field => field.id));
88
+ for (const key of Object.keys(input.values)) if (!known.has(key)) throw new Error(`Unknown theme decision ${key}.`);
89
+ const values = { ...defaultRecipe().values, ...input.values };
90
+ for (const field of themeFields) {
91
+ const value = values[field.id];
92
+ let valid = typeof value === 'string' && value.length > 0;
93
+ if (valid) switch (field.type) {
94
+ case 'colour': valid = /^#[\da-f]{6}$/i.test(value); break;
95
+ case 'font': valid = font.test(value); break;
96
+ case 'length': valid = length.test(value) && Number.isFinite(parseFloat(value)); break;
97
+ case 'positive-length': valid = length.test(value) && Number.isFinite(parseFloat(value)) && parseFloat(value) > 0; break;
98
+ case 'time': valid = time.test(value) && Number.isFinite(parseFloat(value)); break;
99
+ case 'easing': valid = easing(value); break;
100
+ default: {
101
+ const number = Number(value); valid = scalar.test(value) && Number.isFinite(number);
102
+ if (field.type === 'positive') valid &&= number > 0;
103
+ if (field.type === 'ratio') valid &&= number >= 1;
104
+ if (field.type === 'fraction') valid &&= number >= 0 && number <= 1;
105
+ if (field.type === 'weight') valid &&= number >= 1 && number <= 1000;
106
+ }
107
+ }
108
+ if (!valid) throw new Error(`${field.label}: enter a valid ${field.type.replaceAll('-', ' ')} value.`);
109
+ if (Object.hasOwn(bounds, field.id)) {
110
+ const number = boundedNumber(field, value), [min, max] = bounds[field.id];
111
+ if (number !== undefined && (number < min || number > max)) throw new Error(`${field.label}: choose a value between ${min} and ${max}${field.type.includes('length') ? 'px (or equivalent)' : field.type === 'time' ? 'ms' : ''}.`);
112
+ }
113
+ }
114
+ return { schemaVersion: 1, values };
115
+ }
116
+
117
+ /** Match presets by their complete semantic recipe, including locally saved themes. */
118
+ export function startingPointFor(input) {
119
+ const recipe = JSON.stringify(validateRecipe(input));
120
+ return Object.entries(startingPoints).find(([, value]) => JSON.stringify(value) === recipe)?.[0] ?? 'custom';
121
+ }
122
+
123
+ const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
124
+ const mix = (a, b, weight) => '#' + rgb(a).map((n, i) => Math.round(n * weight + rgb(b)[i] * (1 - weight)).toString(16).padStart(2, '0')).join('');
125
+ const luminance = hex => rgb(hex).map(n => { const s = n / 255; return s <= .04045 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4; }).reduce((sum, n, i) => sum + n * [.2126, .7152, .0722][i], 0);
126
+ const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + .05) / (Math.min(luminance(a), luminance(b)) + .05);
127
+ const foreground = (fill, light, dark) => [light, dark, '#ffffff', '#000000'].sort((a, b) => contrast(fill, b) - contrast(fill, a))[0];
128
+
129
+ /** Find the nearest mixture with a known readable endpoint, including every supplied surface. */
130
+ function readableColour(seed, surfaces, minimum, endpoint) {
131
+ if (surfaces.every(surface => contrast(seed, surface) >= minimum)) return seed;
132
+ for (let step = 1; step <= 256; step++) {
133
+ const next = mix(seed, endpoint, 1 - step / 256);
134
+ if (surfaces.every(surface => contrast(next, surface) >= minimum)) return next;
135
+ }
136
+ return endpoint;
137
+ }
138
+
139
+ /** Resolve editable colour seeds into a coordinated set of readable semantic roles. */
140
+ export function deriveTheme(input, { mode } = {}) {
141
+ if (mode !== undefined && !['light', 'dark'].includes(mode)) throw new Error('Unknown theme color mode.');
142
+ const { values } = validateRecipe(input);
143
+ const tokens = Object.fromEntries(themeFields.map(field => [field.property, values[field.id]]));
144
+ for (const field of themeFields.filter(field => field.type.includes('length') && Object.hasOwn(bounds, field.id))) {
145
+ if (boundedNumber(field, values[field.id]) === undefined) {
146
+ const [min, max] = bounds[field.id]; tokens[field.property] = `clamp(${min}px, ${values[field.id]}, ${max}px)`;
147
+ }
148
+ }
149
+ const adjustments = [];
150
+ const nativeInk = foreground(values.canvas, '#ffffff', '#000000');
151
+ const ink = mode === 'light' ? '#000000' : mode === 'dark' ? '#ffffff' : nativeInk;
152
+ const paper = ink === '#000000' ? '#ffffff' : '#000000';
153
+ // Preserve the requested palette in its native mode. The companion keeps a restrained
154
+ // tint of the same surface seeds; contrast resolution below governs all usable roles.
155
+ const companionSurface = mix(values.text, paper, .8);
156
+ const surface = seed => readableColour(nativeInk === ink ? seed : mix(seed, companionSurface, .02), [ink], 7, paper);
157
+ tokens['--ui-surface'] = surface(values.canvas);
158
+ tokens['--ui-surface-raised'] = surface(values.surface);
159
+ tokens['--ui-surface-sunken'] = surface(mix(tokens['--ui-surface-raised'], tokens['--ui-surface'], .92));
160
+ const surfaces = ['--ui-surface', '--ui-surface-raised', '--ui-surface-sunken'].map(name => tokens[name]);
161
+ const tones = { accent: values.action, danger: '#c92d1e', success: '#007a33', warning: '#ba5b0b', info: '#0066cc' };
162
+ for (const [tone, seed] of Object.entries(tones)) {
163
+ const fill = readableColour(seed, surfaces, 5, ink);
164
+ tokens[`--ui-${tone}`] = fill;
165
+ tokens[`--ui-on-${tone}`] = foreground(fill, '#ffffff', '#000000');
166
+ tokens[`--ui-${tone}-on-strong`] = tokens[`--ui-on-${tone}`];
167
+ tokens[`--ui-${tone}-hover`] = mix(fill, ink, .86);
168
+ tokens[`--ui-${tone}-active`] = mix(fill, ink, .74);
169
+ tokens[`--ui-${tone}-soft`] = mix(fill, tokens['--ui-surface-raised'], .08);
170
+ }
171
+ // Readable roles cover the quiet tinted surfaces as well as the neutral family.
172
+ const readingSurfaces = [...surfaces, ...Object.keys(tones).flatMap(tone => surfaces.map(background => mix(tokens[`--ui-${tone}`], background, .12)))];
173
+ for (const tone of Object.keys(tones)) {
174
+ tokens[`--ui-${tone}-subtle-text`] = readableColour(tokens[`--ui-${tone}`], readingSurfaces, 4.5, ink);
175
+ }
176
+ const textSeed = nativeInk === ink ? values.text : mix(values.text, ink, .08);
177
+ tokens['--ui-text'] = readableColour(textSeed, readingSurfaces, 4.5, ink);
178
+ tokens['--ui-text-secondary'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .72), readingSurfaces, 4.5, ink);
179
+ tokens['--ui-text-muted'] = tokens['--ui-text-secondary'];
180
+ tokens['--ui-selection-surface'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .1);
181
+ tokens['--ui-selection-text'] = tokens['--ui-accent-subtle-text'];
182
+ tokens['--ui-selection-border'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .2);
183
+ tokens['--ui-control-surface'] = tokens['--ui-surface-raised'];
184
+ tokens['--ui-control-border'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .43), readingSurfaces, 3, ink);
185
+ tokens['--ui-control-border-hover'] = tokens['--ui-control-border'];
186
+ tokens['--ui-border-strong'] = tokens['--ui-control-border'];
187
+ tokens['--ui-disabled-text'] = tokens['--ui-text-secondary'];
188
+ tokens['--ui-disabled-surface'] = tokens['--ui-surface-sunken'];
189
+ tokens['--ui-disabled-border'] = tokens['--ui-control-border'];
190
+ tokens['--ui-focus-ring'] = readableColour(tokens['--ui-accent'], readingSurfaces, 3, ink);
191
+ tokens['--ui-focus-width'] = 'max(2px, calc(var(--ui-border-width) * 2))';
192
+ tokens['color-scheme'] = ink === '#000000' ? 'light' : 'dark';
193
+ for (const [id, property] of [['action', '--ui-accent'], ['canvas', '--ui-surface'], ['surface', '--ui-surface-raised'], ['text', '--ui-text']]) {
194
+ if (tokens[property].toLowerCase() !== values[id].toLowerCase()) adjustments.push({ id, requested: values[id], resolved: tokens[property], reason: 'Adjusted to keep the shared palette readable.' });
195
+ }
196
+ return { tokens, adjustments };
197
+ }
198
+
199
+ /** Shared public seeds; combine with theme-system.css for a standalone exported theme. */
200
+ export function themeCSS(input, systemCSS = '', { scope = 'portable' } = {}) {
201
+ if (!['portable', 'documentation'].includes(scope)) throw new Error('Unknown theme scope.');
202
+ // The early documentation stylesheet wins over the baseline loaded later without !important.
203
+ const selector = scope === 'documentation'
204
+ ? ':root[data-looma-documentation-theme], :root[data-looma-documentation-theme] [data-theme], :root[data-looma-documentation-theme] [data-contrast]'
205
+ : ':root, [data-theme], [data-contrast]';
206
+ const light = deriveTheme(input, { mode: 'light' }).tokens;
207
+ const dark = deriveTheme(input, { mode: 'dark' }).tokens;
208
+ const palette = [], geometry = [];
209
+ for (const [name, value] of Object.entries(light)) {
210
+ if (name === 'color-scheme') continue;
211
+ if (/^#[\da-f]{6}$/i.test(value)) palette.push(` ${name}: light-dark(${value}, ${dark[name]});`);
212
+ else geometry.push(` ${name}: ${value};`);
213
+ }
214
+ const geometrySelector = scope === 'documentation' ? ':root[data-looma-documentation-theme]' : ':root';
215
+ // Resolve colors at their point of use, so a nearer mode boundary wins independently
216
+ // of the selector specificity needed to restore an early documentation theme.
217
+ // Keep the first comment stable: the local writer recognizes its generated files by this marker.
218
+ return `/* Looma shared semantic theme. */\n/* Always includes light and dark.\n Set data-theme="light" or "dark" on any element to fix that subtree's mode.\n Leave the root attribute unset to follow the system; a switch can set/remove it. */\n${systemCSS}\n${geometrySelector} {\n${geometry.join('\n')}\n}\n${selector} {\n${palette.join('\n')}\n}\n:root:not([data-theme]) { color-scheme: light dark; }\n[data-theme="light"] { color-scheme: light; }\n[data-theme="dark"] { color-scheme: dark; }\n@media (prefers-reduced-motion: reduce) {\n ${selector} { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n`;
219
+ }