@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,34 @@
1
+ /** Filters the example's results as the query changes; a real application would query its index. */
2
+ export default function filterResults(root) {
3
+ const update = () => {
4
+ const input = root.querySelector("#docs-search-input");
5
+ const status = root.querySelector("#docs-search-status");
6
+ const empty = root.querySelector("#docs-search-empty");
7
+ const results = [
8
+ root.querySelector("#docs-result-design-tokens"),
9
+ root.querySelector("#docs-result-token-overrides"),
10
+ root.querySelector("#docs-result-button-variants")
11
+ ].filter((result) => result !== null);
12
+ if (!input || !status || !empty || results.length === 0)
13
+ return;
14
+ const query = input.value.trim().toLowerCase();
15
+ let visible = 0;
16
+ for (const result of results) {
17
+ result.hidden = !result.textContent?.toLowerCase().includes(query);
18
+ if (!result.hidden)
19
+ visible += 1;
20
+ }
21
+ status.textContent = `${visible} result${visible === 1 ? "" : "s"}`;
22
+ empty.hidden = visible !== 0;
23
+ };
24
+ const onInput = (event) => {
25
+ if (event.target?.id === "docs-search-input")
26
+ update();
27
+ };
28
+ root.addEventListener("input", onInput);
29
+ const frame = requestAnimationFrame(update);
30
+ return () => {
31
+ cancelAnimationFrame(frame);
32
+ root.removeEventListener("input", onInput);
33
+ };
34
+ }
@@ -0,0 +1,15 @@
1
+ /** Shows a hidden authored toast when the example's button is pressed; the toast hides itself when dismissed. */
2
+ export function showToastOnClick(buttonId, toastId) {
3
+ return (root) => {
4
+ const button = root.querySelector(`#${buttonId}`);
5
+ const toast = root.querySelector(`#${toastId}`);
6
+ if (!button || !toast)
7
+ return () => { };
8
+ const show = () => {
9
+ toast.hidden = false;
10
+ };
11
+ button.addEventListener("click", show);
12
+ return () => button.removeEventListener("click", show);
13
+ };
14
+ }
15
+ export default showToastOnClick("show-saved-toast", "saved-toast");
@@ -0,0 +1,2 @@
1
+ import { showToastOnClick } from "./01-authored.behavior.js";
2
+ export default showToastOnClick("show-failed-toast", "failed-toast");
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Applies a tree's `reorder` requests, as an application does: the tree reports a drop, and the
3
+ * application moves the item. The tree re-derives levels from the DOM.
4
+ */
5
+ export default function applyTreeReorder(root) {
6
+ const onReorder = (event) => {
7
+ const { sourceId, targetId, position } = event.detail;
8
+ const tree = event.target?.closest('[role="tree"]');
9
+ const item = (id) => tree?.querySelector(`[role="treeitem"][data-item-id="${CSS.escape(id)}"]`);
10
+ const source = item(sourceId);
11
+ const target = item(targetId);
12
+ if (!source || !target || source === target || source.contains(target))
13
+ return;
14
+ if (position === "before")
15
+ target.before(source);
16
+ else if (position === "after")
17
+ target.after(source);
18
+ else {
19
+ const group = target.querySelector(':scope > [role="group"]');
20
+ if (!group)
21
+ return;
22
+ // Stay inside the group's slot range: before its closing marker when there is one.
23
+ const last = group.lastChild;
24
+ const marker = last && (last.nodeType === Node.PROCESSING_INSTRUCTION_NODE || last.nodeType === Node.COMMENT_NODE) ? last : null;
25
+ group.insertBefore(source, marker);
26
+ }
27
+ };
28
+ root.addEventListener("reorder", onReorder, true);
29
+ return () => root.removeEventListener("reorder", onReorder, true);
30
+ }
@@ -0,0 +1,2 @@
1
+ // Apply reorder requests so the click and keyboard example changes the authored tree.
2
+ export { default } from "./03-max-depth-2-and-hover-expand-delay-300.behavior.js";
@@ -0,0 +1,16 @@
1
+ import { componentRegistry } from "@nextwebwg/html-next/runtime";
2
+ /** Apply Tree's controlled selection request to the authored items in this example. */
3
+ export default function applyTreeSelection(root) {
4
+ const onSelect = (event) => {
5
+ const { ids } = event.detail;
6
+ const tree = event.target?.closest('[role="tree"]');
7
+ if (!tree)
8
+ return;
9
+ const selected = new Set(ids);
10
+ for (const item of tree.querySelectorAll('[role="treeitem"][data-item-id]')) {
11
+ componentRegistry.update(item, { selected: selected.has(item.dataset.itemId ?? "") });
12
+ }
13
+ };
14
+ root.addEventListener("select", onSelect, true);
15
+ return () => root.removeEventListener("select", onSelect, true);
16
+ }
@@ -0,0 +1 @@
1
+ export { default } from "../../ui-tree/examples/03-max-depth-2-and-hover-expand-delay-300.behavior.js";
@@ -0,0 +1,56 @@
1
+ export interface DocumentationGroup { id: string; label: string }
2
+ export interface ExampleCode { language: string; code: string }
3
+ export interface DocumentationExample {
4
+ id: string; name: string; title: string; description: string; markup: string;
5
+ resizable: boolean; frameworks: Record<string, ExampleCode>; behavior?: string; bindings?: string;
6
+ }
7
+ export interface DocumentationComponent {
8
+ id: string; package: string; tag: string; title: string; description: string; group: string;
9
+ root: string; resource: string; source?: string; navigationParent?: string;
10
+ targets: string[]; examples: DocumentationExample[];
11
+ attributes: Record<string, unknown>[]; properties: Record<string, unknown>[];
12
+ events: Record<string, unknown>[]; slots: Record<string, unknown>[];
13
+ designTokens: { sources?: string[]; component: Record<string, unknown>[]; shared: Record<string, unknown>[] };
14
+ }
15
+ export interface DocumentationCatalog {
16
+ schemaVersion: 1; package?: string; version?: string; groups: DocumentationGroup[];
17
+ resources?: { tag: string; package: string; resource: string }[]; components: DocumentationComponent[];
18
+ }
19
+ export interface DiscoveryRoot { directory: string; packageName: string; groups?: DocumentationGroup[] }
20
+ export interface ThemeRecipe { schemaVersion: 1; values: Record<string, string> }
21
+ export interface ThemeField { family: string; id: string; label: string; property: string; type: string; defaultValue: string; description: string; min?: number; max?: number }
22
+ export interface DocumentationConfiguration {
23
+ title?: string; base?: string; origin?: string; outDir?: string; content?: string;
24
+ logo?: string | { light: string; dark?: string }; nav?: { label: string; href: string }[];
25
+ components?: DiscoveryRoot[]; catalogs?: string[]; categories?: Record<string, string>;
26
+ theme?: { recipe?: string; css?: string }; overrides?: string[];
27
+ recipe?: ThemeRecipe;
28
+ }
29
+ export interface DocumentationInput { root?: string; config?: DocumentationConfiguration; catalog?: DocumentationCatalog }
30
+ export interface DocumentationSite {
31
+ root: string; config: DocumentationConfiguration; base: string; catalog: DocumentationCatalog;
32
+ examples: (DocumentationExample & { file: string; component: string; path: string })[];
33
+ generated: string; content: string; previews: string; redirects: Record<string, string>;
34
+ }
35
+ export interface DocumentationBuild { outDir: string; routes: readonly string[]; browserInputs: readonly string[]; site: DocumentationSite }
36
+ export interface DocumentationServer { url: string; close(): Promise<void> }
37
+ export function readCatalog(specifier?: string | URL): Promise<DocumentationCatalog>;
38
+ export function discoverComponents(input: DiscoveryRoot & { root: string }): Promise<DocumentationCatalog>;
39
+ export function mergeCatalogs(catalogs: DocumentationCatalog[], options?: { categories?: Record<string, string> }): DocumentationCatalog;
40
+ export function prepareDocumentation(input: DocumentationInput & { root: string }): Promise<DocumentationSite>;
41
+ export function buildDocumentation(input?: DocumentationInput): Promise<DocumentationBuild>;
42
+ /** Optional HTMLKit integration; server tooling peers are loaded only when invoked. */
43
+ export function documentationOptions(site: DocumentationSite): Promise<import('@nextwebwg/htmlkit').ApplicationOptions>;
44
+ export function documentationPlugin(site: DocumentationSite): import('@nextwebwg/htmlkit').HtmlKitPlugin;
45
+ export function initializeDocumentation(input?: { root?: string; title?: string }): Promise<{ config: DocumentationConfiguration; files: string[]; applicationImport: string }>;
46
+ export function readConfiguration(root?: string): Promise<{ root: string; config: DocumentationConfiguration }>;
47
+ export function buildProjectDocumentation(input?: DocumentationInput): Promise<DocumentationBuild>;
48
+ export function devProjectDocumentation(input?: DocumentationInput & { port?: number; onError?: (error: unknown) => void }): Promise<DocumentationServer>;
49
+ export function previewProjectDocumentation(input?: DocumentationInput & { port?: number }): Promise<DocumentationServer>;
50
+ export const themeFields: readonly ThemeField[];
51
+ export const startingPoints: Record<string, ThemeRecipe>;
52
+ export function defaultRecipe(): ThemeRecipe;
53
+ export function validateRecipe(input: unknown): ThemeRecipe;
54
+ export interface ResolvedTheme { tokens: Record<string, string>; adjustments: { id: string; requested: string; resolved: string; reason: string }[] }
55
+ export function deriveTheme(input: ThemeRecipe, options?: { mode?: 'light' | 'dark' }): ResolvedTheme;
56
+ export function themeCSS(input: ThemeRecipe, systemCSS?: string, options?: { scope?: 'portable' | 'documentation' }): string;
package/docs/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ export { readCatalog, discoverComponents, mergeCatalogs } from './catalog.mjs';
2
+ export { prepareDocumentation, documentationOptions, documentationPlugin, buildDocumentation } from './site.mjs';
3
+ export { defaultRecipe, startingPoints, themeFields, validateRecipe, deriveTheme, themeCSS } from './theme.mjs';
4
+ export { initializeDocumentation, buildProjectDocumentation, devProjectDocumentation, previewProjectDocumentation, readConfiguration } from './commands.mjs';
@@ -0,0 +1,68 @@
1
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { createServer, request as proxyRequest } from 'node:http';
3
+
4
+ const rejected = (message, status = 403) => Object.assign(new Error(message), { status });
5
+ const secretMatches = (value, secret) => typeof value === 'string' && Buffer.byteLength(value) === secret.length && timingSafeEqual(Buffer.from(value), Buffer.from(secret));
6
+ async function body(request) {
7
+ const chunks = []; let size = 0;
8
+ for await (const chunk of request) {
9
+ size += chunk.length;
10
+ if (size > 64 * 1024) throw rejected('Theme request is too large.', 413);
11
+ chunks.push(chunk);
12
+ }
13
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
14
+ catch { throw rejected('Expected a JSON theme recipe and revision.', 400); }
15
+ }
16
+
17
+ /** Local capabilities stay in the parent editor; opaque example frames receive only theme CSS. */
18
+ export async function serveLocalDocumentation({ upstream, store, base = '/', port = 0, onSaved = async () => {} }) {
19
+ const target = new URL(upstream);
20
+ if (target.protocol !== 'http:' || target.hostname !== '127.0.0.1') throw new Error('The documentation upstream must be a loopback HTMLKit server.');
21
+ const capability = randomBytes(32).toString('hex');
22
+ let origin;
23
+ const json = (response, status, data) => {
24
+ response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
25
+ response.end(JSON.stringify(data));
26
+ };
27
+ const sameOrigin = request => {
28
+ if (request.headers.origin) return request.headers.origin === origin;
29
+ return request.headers['sec-fetch-site'] === 'same-origin';
30
+ };
31
+ const server = createServer((request, response) => {
32
+ void (async () => {
33
+ if (request.headers.host !== new URL(origin).host) throw rejected('Unexpected local documentation host.');
34
+ const url = new URL(request.url, origin);
35
+ const api = base + '_looma/';
36
+ if (url.pathname === api + 'session' || url.pathname === api + 'theme') {
37
+ if (!sameOrigin(request)) throw rejected('Open this editor from its local documentation address.');
38
+ if (url.pathname.endsWith('/session')) {
39
+ if (request.method !== 'GET') throw rejected('Use GET for the local session.', 405);
40
+ json(response, 200, { ...await store.read(), capability }); return;
41
+ }
42
+ if (request.method !== 'POST') throw rejected('Use POST to save a theme.', 405);
43
+ if (request.headers['content-type']?.split(';')[0].trim() !== 'application/json') throw rejected('Use application/json to save a theme.', 415);
44
+ if (!secretMatches(request.headers['x-looma-capability'], capability)) throw rejected('The local editing session expired. Reload before saving.');
45
+ const result = await store.save(await body(request));
46
+ try { await onSaved(result); }
47
+ catch (error) { throw rejected(`Theme files were saved, but the documentation could not rebuild: ${error.message}`, 500); }
48
+ json(response, 200, result); return;
49
+ }
50
+ if (!['GET', 'HEAD'].includes(request.method)) throw rejected('Documentation supports GET and HEAD.', 405);
51
+ const headers = { ...request.headers, host: target.host };
52
+ delete headers['x-looma-capability'];
53
+ const proxy = proxyRequest(target.origin + request.url, { method: request.method, headers }, remote => {
54
+ const responseHeaders = { ...remote.headers };
55
+ if (url.pathname.startsWith(api + 'preview/')) {
56
+ const existing = responseHeaders['content-security-policy'];
57
+ responseHeaders['content-security-policy'] = [...(existing ? [existing].flat() : []), 'sandbox allow-scripts'];
58
+ }
59
+ response.writeHead(remote.statusCode, responseHeaders); remote.pipe(response);
60
+ });
61
+ proxy.on('error', () => { if (!response.headersSent) json(response, 502, { error: 'The documentation renderer is unavailable.' }); else response.destroy(); });
62
+ request.on('aborted', () => proxy.destroy()); proxy.end();
63
+ })().catch(error => { if (!response.headersSent) json(response, error.status ?? 500, { error: error.status ? error.message : 'Could not process this local documentation request.' }); else response.destroy(); });
64
+ });
65
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
66
+ origin = `http://127.0.0.1:${server.address().port}`;
67
+ return { url: origin + base, close: () => new Promise((resolve, reject) => { server.close(error => error ? reject(error) : resolve()); server.closeIdleConnections(); }) };
68
+ }
@@ -0,0 +1,22 @@
1
+ /** An opaque sandbox cannot read its parent; it accepts only theme CSS from that parent. */
2
+ export function connect(host) {
3
+ const document = host.element.ownerDocument;
4
+ const view = document.defaultView;
5
+ const theme = document.createElement('style');
6
+ theme.dataset.loomaTheme = '';
7
+ document.head.append(theme);
8
+ const height = () => view.parent.postMessage({ type: 'looma-preview-height', height: Math.max(host.element.getBoundingClientRect().height, host.element.scrollHeight) }, '*');
9
+ const receive = event => {
10
+ if (event.source !== view.parent || event.data?.type !== 'looma-theme' || typeof event.data.css !== 'string') return;
11
+ theme.textContent = event.data.css;
12
+ document.documentElement.toggleAttribute('data-preview-reference', event.data.reference === true);
13
+ if (['light', 'dark'].includes(event.data.mode)) document.documentElement.dataset.theme = event.data.mode;
14
+ height();
15
+ };
16
+ view.addEventListener('message', receive);
17
+ const observer = new ResizeObserver(height);
18
+ observer.observe(host.element);
19
+ view.parent.postMessage({ type: 'looma-preview-ready' }, '*');
20
+ height();
21
+ return () => { observer.disconnect(); view.removeEventListener('message', receive); theme.remove(); };
22
+ }
@@ -0,0 +1 @@
1
+ <template component="looma-reference" module="./reference.js"><defs></defs><div><slot></slot></div></template>
@@ -0,0 +1,36 @@
1
+ /** A deep link opens its containing tab through the tab's native, accessible activation. */
2
+ export function connect(host) {
3
+ const root = host.element;
4
+ const view = root.ownerDocument.defaultView;
5
+ let currentHash = view.location.hash;
6
+ const open = () => {
7
+ let id;
8
+ try { id = decodeURIComponent(currentHash.slice(1)); } catch { return; }
9
+ const target = root.ownerDocument.getElementById(id);
10
+ if (!target || !root.contains(target)) { observer.disconnect(); return; }
11
+ const panel = target.closest('[role="tabpanel"]');
12
+ const tab = panel && [...root.querySelectorAll('[role="tab"]')].find(button => button.getAttribute('aria-controls') === panel.id);
13
+ if (!tab) return;
14
+ if (panel.hidden) tab.click();
15
+ if (!panel.hidden) { target.scrollIntoView({ block: 'start' }); observer.disconnect(); }
16
+ };
17
+ const observer = new MutationObserver(open);
18
+ const activate = value => {
19
+ currentHash = value;
20
+ observer.disconnect();
21
+ if (!currentHash) return;
22
+ observer.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['role', 'id', 'hidden'] });
23
+ open();
24
+ };
25
+ const hash = () => activate(view.location.hash);
26
+ const click = event => {
27
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
28
+ const link = event.target.closest?.('a[href]');
29
+ if (!link || link.target && link.target !== '_self') return;
30
+ const target = new URL(link.href, view.location.href);
31
+ if (target.origin === view.location.origin && target.pathname === view.location.pathname && target.hash) activate(target.hash);
32
+ };
33
+ root.addEventListener('click', click);
34
+ view.addEventListener('hashchange', hash); hash();
35
+ return () => { observer.disconnect(); root.removeEventListener('click', click); view.removeEventListener('hashchange', hash); };
36
+ }
@@ -0,0 +1,93 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { escapeHtml as escape } from './catalog.mjs';
3
+ import { createLoomaMarkdownIt } from '@threadlabs/looma/markdown/markdown-it';
4
+
5
+ const descriptions = createLoomaMarkdownIt().set({ html: false });
6
+
7
+ const literal = value => escape(value).replaceAll('{', '\\{');
8
+ const code = value => `<code>${literal(value ?? '—')}</code>`;
9
+ const table = (head, rows) => `<table><thead><tr>${head.map(h => `<th>${literal(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(value => `<td>${value}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
10
+ export const exampleKey = id => createHash('sha256').update(id).digest('hex').slice(0, 20);
11
+ export const componentPath = component => `/components/${component.navigationParent ?? component.tag}/` + (component.navigationParent ? `#${component.tag}-api` : '');
12
+ export const previewPath = example => `/_looma/preview/${exampleKey(example.id)}/`;
13
+
14
+ export function apiBody(api, prefix = '') {
15
+ const attributes = api.attributes ?? [];
16
+ const properties = api.properties ?? [];
17
+ const events = api.events ?? [];
18
+ const slots = api.slots ?? [];
19
+ const tokens = api.designTokens ?? { component: [], shared: [] };
20
+ const section = (name, items, headings, render) => `<h2 id="${prefix}${name.toLowerCase().replaceAll(' ', '-')}">${name}</h2>${items.length ? table(headings, items.map(render)) : `<p>No ${name.toLowerCase()}.</p>`}`;
21
+ const fields = value => value?.length ? `<ul>${value.map(field => `<li>${code(field.path)}: ${code(field.type)}${field.required ? ' (required)' : ''}</li>`).join('')}</ul>` : '';
22
+ return `<p><strong>Package:</strong> ${code(api.package)} · <strong>Native root:</strong> ${code(api.root)}</p>`
23
+ + section('Attributes', attributes, ['Name', 'Description', 'Property', 'Type', 'Default', 'Allowed values'], p => [code(p.name), literal(p.description ?? ''), code(p.property), code(p.type) + fields(p.fields), code(p.default), code(p.options?.join(', '))])
24
+ + (api.targets?.includes('vue') ? section('Framework props', properties, ['Name', 'Type', 'Default', 'Allowed values'], p => [code(p.name), code(p.type) + fields(p.fields), code(p.default), code(p.options?.join(', '))]) : '')
25
+ + section('Events', events, ['Name', 'Detail', 'Description'], e => [code(e.name), code(e.detailSchema ?? e.detailType) + fields(e.fields), literal(e.detailDocs ?? '')])
26
+ + section('Slots', slots, ['Name', 'Purpose'], s => [code(s.name), literal(s.description ?? '')])
27
+ + `<h2 id="${prefix}design-tokens">Design tokens</h2><p>Set shared theme properties on a theme boundary. Component hooks apply only to their owning component.</p>`
28
+ + section('Component tokens', tokens.component ?? [], ['Token', 'Declared values', 'Fallback'], t => [code(t.name), code(t.declarations?.join(' | ')), code(t.fallbacks?.join(' | '))])
29
+ + section('Shared tokens', tokens.shared ?? [], ['Token', 'Fallback'], t => [code(t.name), code(t.fallbacks?.join(' | '))]);
30
+ }
31
+
32
+ function structuredBindings(example) {
33
+ let markup = example.markup;
34
+ const assignments = [];
35
+ for (const pair of (example.bindings ?? '').split(/\s+/).filter(Boolean)) {
36
+ const [attribute, variable] = pair.split('=');
37
+ if (!/^[a-zA-Z_$][\w$]*$/.test(variable ?? '') || !/^[\w-]+$/.test(attribute)) throw new Error(`Invalid example binding ${pair} in ${example.id}.`);
38
+ const match = new RegExp(`<[^>]*\\s${attribute}='([^']*)'[^>]*>`).exec(markup);
39
+ const id = match && /\sid="([^"]+)"/.exec(match[0])?.[1];
40
+ if (!match || !id) throw new Error(`Example binding ${pair} in ${example.id} needs an element with an id.`);
41
+ const value = JSON.parse(match[1].replaceAll('&#39;', "'").replaceAll('&quot;', '"').replaceAll('&amp;', '&'));
42
+ assignments.push({ id, variable, property: attribute.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()), value });
43
+ markup = markup.replace(match[0], match[0].replace(` ${attribute}='${match[1]}'`, ''));
44
+ }
45
+ return { markup, assignments };
46
+ }
47
+
48
+ export function exampleCodes(example, component, components) {
49
+ const tags = [...new Set([...example.markup.matchAll(/<([a-z][\w]*-[\w-]+)\b/g)].map(match => match[1]))];
50
+ const owners = [...new Set(tags.map(tag => components.find(c => c.tag === tag)?.package).filter(Boolean))];
51
+ const html = `<script type="module">\n${owners.map(owner => `import ${JSON.stringify(owner)};`).join('\n')}\n</script>\n\n${example.markup}`;
52
+ const codes = { 'html-next': { language: 'html', code: html } };
53
+ if (component.targets?.includes('vue')) {
54
+ const toName = tag => tag.replace(/^ui-/, '').split('-').map(word => word[0].toUpperCase() + word.slice(1)).join('');
55
+ const groups = new Map();
56
+ for (const tag of tags) {
57
+ const entry = components.find(c => c.tag === tag);
58
+ if (!entry?.targets?.includes('vue')) continue;
59
+ const owner = entry.vueImport ?? `${entry.package}/vue${tag.startsWith('ui-editor-') ? '/editor' : ''}`;
60
+ groups.set(owner, [...groups.get(owner) ?? [], toName(tag)]);
61
+ }
62
+ const binding = structuredBindings(example);
63
+ let markup = binding.markup;
64
+ for (const tag of tags) if (components.find(c => c.tag === tag)?.targets?.includes('vue')) markup = markup.replace(new RegExp(`(</?)${tag}(?=[\\s/>])`, 'g'), (_, start) => start + toName(tag));
65
+ for (const assignment of binding.assignments) markup = markup.replace(`id="${assignment.id}"`, `id="${assignment.id}" :${assignment.property}="${assignment.variable}"`);
66
+ const declarations = binding.assignments.map(assignment => `const ${assignment.variable} = ${JSON.stringify(assignment.value, null, 2)};`).join('\n');
67
+ codes.vue = { language: 'vue', code: `<script setup>\n${[...groups].map(([owner, names]) => `import { ${names.sort().join(', ')} } from ${JSON.stringify(owner)};`).join('\n')}\n${declarations}\n</script>\n\n<template>\n${markup}\n</template>` };
68
+ }
69
+ return { ...codes, ...example.frameworks };
70
+ }
71
+
72
+ /** The same card invocation is embedded in reference pages and rendered by the customizer. */
73
+ export function cardMarkup(example, base = '/', component = {}, { reference = false, code: exampleCode = '' } = {}) {
74
+ return `<looma-example-card identity="${escape(example.id)}"${reference ? ' reference' : ''} title="${escape(component.title ?? example.title)}" description="${escape(reference ? example.description : component.description ?? example.description)}" example-title="${escape(example.title)}" category="${escape(component.group ?? 'forms')}" tag="${escape(component.tag ?? '')}" package-name="${escape(component.package ?? '')}" native-root="${escape(component.root ? `<${component.root}>` : '')}" href="${component.tag ? base + componentPath(component).slice(1) : ''}" src="${base}${previewPath(example).slice(1)}"${example.resizable ? ' resizable' : ''}>${reference ? `<div slot="heading" class="looma-example-heading"><h3 id="example-${exampleKey(example.id)}">${escape(example.title)}</h3><p>${escape(example.description)}</p></div>` : ''}${exampleCode}</looma-example-card>`;
75
+ }
76
+
77
+ export function referencePage(component, catalog, cardResource, base) {
78
+ const parts = catalog.components.filter(c => c.navigationParent === component.tag);
79
+ const examples = [...component.examples, ...parts.flatMap(part => part.examples)];
80
+ const codes = example => {
81
+ const owner = catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component;
82
+ // Blank lines let Markupress parse real fences inside the projected HTML slots.
83
+ // A fence longer than any authored run keeps snippets literal, including nested fences.
84
+ const tabs = Object.entries(exampleCodes(example, owner, catalog.components)).map(([mode, value]) => {
85
+ const fence = '~'.repeat(Math.max(3, ...[...value.code.matchAll(/~+/g)].map(match => match[0].length + 1)));
86
+ const language = /^[\w+-]+$/.test(value.language) ? value.language : 'text';
87
+ return `<section aria-label="${mode === 'vue' ? 'Vue' : 'HTML Next'}"><div data-theme="dark" style="--ui-surface-sunken: #0d1117">\n\n${fence}${language}\n${value.code}\n${fence}\n\n</div></section>`;
88
+ }).join('\n');
89
+ return `<ui-tabs slot="code" label="Example syntax" group="syntax" style="--ui-tabs-panel-padding: 0px">${tabs}</ui-tabs>`;
90
+ };
91
+ const body = `<div class="looma-reference-examples"><ui-stack gap="xl">${examples.map(example => `<ui-card as="section" variant="subtle" padding="lg">${cardMarkup(example, base, catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component, { reference: true, code: codes(example) })}</ui-card>`).join('')}</ui-stack></div>`;
92
+ return `---\ntitle: ${JSON.stringify(component.title)}\ndescription: ${JSON.stringify(component.description || `${component.title} component reference.`)}\nid: ${JSON.stringify(component.id)}\n---\n\n# ${escape(component.title)}\n\n<div class="component-description"><p>${descriptions.renderInline(component.description).replaceAll('{', '\\{').replaceAll('\n', '&#10;')}</p></div>\n\n<link rel="component" href="${cardResource}">\n<link rel="component" href="${cardResource.replace(/card\.html$/, 'reference.html')}">\n<link rel="component" href="@threadlabs/looma/components/ui-card/ui-card.html">\n<link rel="component" href="@threadlabs/looma/components/ui-tabs/ui-tabs.html">\n<link rel="component" href="@threadlabs/looma/components/ui-disclosure/ui-disclosure.html">\n<link rel="component" href="@threadlabs/looma/components/ui-stack/ui-stack.html">\n\n<looma-reference>\n\n<div class="looma-reference-tabs"><ui-tabs label="Component reference" style="--ui-tabs-panel-padding: 0px; --ui-tabs-tab-min-block-size: var(--ui-control-size-lg)"><section aria-label="Examples">${body}</section><section aria-label="API"><h2 id="api">API</h2>\n${apiBody(component)}\n${parts.map(part => `<h2 id="${part.tag}-api">${literal(part.title)} API</h2><p>${literal(part.description)}</p>${apiBody(part, part.tag + '-')}`).join('\n')}</section></ui-tabs></div>\n\n</looma-reference>\n`;
93
+ }