@threadlabs/looma 0.33.1 → 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 (184) hide show
  1. package/components/shared/overlay.js +8 -2
  2. package/components/shared/surface-material.js +21 -0
  3. package/components/ui-button/ui-button.html +63 -47
  4. package/components/ui-card/ui-card.html +44 -2
  5. package/components/ui-curve-editor/curve.js +10 -0
  6. package/components/ui-curve-editor/ui-curve-editor.html +181 -0
  7. package/components/ui-curve-editor/ui-curve-editor.js +51 -0
  8. package/components/ui-dialog/ui-dialog.html +62 -1
  9. package/components/ui-dialog/ui-dialog.js +9 -1
  10. package/components/ui-icon-button/ui-icon-button.html +75 -23
  11. package/components/ui-menu/ui-menu.html +60 -0
  12. package/components/ui-menu/ui-menu.js +1 -1
  13. package/components/ui-number-input/arithmetic.js +47 -0
  14. package/components/ui-number-input/ui-number-input.html +62 -0
  15. package/components/ui-number-input/ui-number-input.js +44 -0
  16. package/components/ui-popover/ui-popover.html +64 -1
  17. package/components/ui-popover/ui-popover.js +1 -1
  18. package/components/ui-slider/ui-slider.html +84 -0
  19. package/components/ui-slider/ui-slider.js +29 -0
  20. package/components/ui-tabs/ui-tabs.html +6 -1
  21. package/components/ui-text/ui-text.html +31 -6
  22. package/components/ui-tooltip/ui-tooltip.html +60 -0
  23. package/components/ui-tooltip/ui-tooltip.js +1 -1
  24. package/dist/index.js +46 -43
  25. package/docs/README.md +34 -4
  26. package/docs/card.html +16 -2
  27. package/docs/card.js +17 -2
  28. package/docs/catalog.json +1154 -144
  29. package/docs/customizer-render.mjs +35 -9
  30. package/docs/customizer.js +83 -20
  31. package/docs/documentation.css +9 -1
  32. package/docs/index.d.ts +4 -2
  33. package/docs/index.mjs +1 -1
  34. package/docs/preview.js +2 -0
  35. package/docs/render.mjs +15 -6
  36. package/docs/site.mjs +50 -9
  37. package/docs/theme-session.mjs +51 -0
  38. package/docs/theme.mjs +134 -15
  39. package/docs/value-editors.mjs +33 -0
  40. package/package.json +2 -2
  41. package/styles/ui-button.css +45 -45
  42. package/styles/ui-card.css +47 -0
  43. package/styles/ui-curve-editor.css +109 -0
  44. package/styles/ui-dialog.css +132 -0
  45. package/styles/ui-icon-button.css +63 -20
  46. package/styles/ui-menu.css +72 -0
  47. package/styles/ui-number-input.css +34 -0
  48. package/styles/ui-popover.css +74 -0
  49. package/styles/ui-slider.css +53 -0
  50. package/styles/ui-tabs.css +6 -1
  51. package/styles/ui-text.css +23 -1
  52. package/styles/ui-tooltip.css +72 -0
  53. package/theme-system.css +26 -10
  54. package/tokens.css +8 -2
  55. package/vanilla/UiButton.d.ts +1 -0
  56. package/vanilla/UiButton.js +195 -130
  57. package/vanilla/UiCard.d.ts +1 -1
  58. package/vanilla/UiCard.js +1 -1
  59. package/vanilla/UiCurveEditor.d.ts +28 -0
  60. package/vanilla/UiCurveEditor.js +134 -0
  61. package/vanilla/UiIconButton.d.ts +2 -1
  62. package/vanilla/UiIconButton.js +58 -31
  63. package/vanilla/UiNumberInput.d.ts +33 -0
  64. package/vanilla/UiNumberInput.js +79 -0
  65. package/vanilla/UiSlider.d.ts +33 -0
  66. package/vanilla/UiSlider.js +125 -0
  67. package/vanilla/UiText.d.ts +2 -2
  68. package/vanilla/UiText.js +45 -11
  69. package/vanilla/index.js +3 -0
  70. package/vue/UiButton.d.ts +6 -0
  71. package/vue/UiButton.vue +155 -56
  72. package/vue/UiCard.d.ts +2 -2
  73. package/vue/UiCard.vue +55 -5
  74. package/vue/UiCurveEditor.d.ts +53 -0
  75. package/vue/UiCurveEditor.js +2 -0
  76. package/vue/UiCurveEditor.vue +427 -0
  77. package/vue/UiDialog.vue +177 -1
  78. package/vue/UiIconButton.d.ts +8 -2
  79. package/vue/UiIconButton.vue +164 -34
  80. package/vue/UiMenu.vue +89 -0
  81. package/vue/UiNumberInput.d.ts +83 -0
  82. package/vue/UiNumberInput.js +2 -0
  83. package/vue/UiNumberInput.vue +370 -0
  84. package/vue/UiPopover.vue +92 -1
  85. package/vue/UiSlider.d.ts +83 -0
  86. package/vue/UiSlider.js +2 -0
  87. package/vue/UiSlider.vue +412 -0
  88. package/vue/UiTabs.vue +6 -1
  89. package/vue/UiText.d.ts +7 -5
  90. package/vue/UiText.vue +104 -22
  91. package/vue/UiTooltip.vue +89 -0
  92. package/vue/chunks/UiActionBar.js +1 -1
  93. package/vue/chunks/UiAffordanceScope.js +2 -2
  94. package/vue/chunks/UiAvatar.js +2 -2
  95. package/vue/chunks/UiAvatarGroup.js +2 -2
  96. package/vue/chunks/UiBadge.js +2 -2
  97. package/vue/chunks/UiBreadcrumbItem.js +2 -2
  98. package/vue/chunks/UiBreadcrumbs.js +2 -2
  99. package/vue/chunks/UiButton.js +94 -11
  100. package/vue/chunks/UiCallout.js +2 -2
  101. package/vue/chunks/UiCard.js +6 -5
  102. package/vue/chunks/UiCheckbox.js +2 -2
  103. package/vue/chunks/UiCluster.js +2 -2
  104. package/vue/chunks/UiColorPicker.js +2 -2
  105. package/vue/chunks/UiCombobox.js +2 -2
  106. package/vue/chunks/UiContainer.js +2 -2
  107. package/vue/chunks/UiContextMenu.js +2 -2
  108. package/vue/chunks/UiCover.js +2 -2
  109. package/vue/chunks/UiCurveEditor.js +413 -0
  110. package/vue/chunks/UiDescriptionItem.js +2 -2
  111. package/vue/chunks/UiDescriptionList.js +2 -2
  112. package/vue/chunks/UiDialog.js +4 -4
  113. package/vue/chunks/UiDisclosure.js +2 -2
  114. package/vue/chunks/UiEditable.js +2 -2
  115. package/vue/chunks/UiEditorBlockActions.js +2 -2
  116. package/vue/chunks/UiEditorChipEditor.js +2 -2
  117. package/vue/chunks/UiEditorCodeLanguage.js +2 -2
  118. package/vue/chunks/UiEditorFootnoteEditor.js +2 -2
  119. package/vue/chunks/UiEditorImageToolbar.js +3 -3
  120. package/vue/chunks/UiEditorInsertTableGrid.js +2 -2
  121. package/vue/chunks/UiEditorLinkCard.js +3 -3
  122. package/vue/chunks/UiEditorLinkForm.js +3 -3
  123. package/vue/chunks/UiEditorMentionMenu.js +2 -2
  124. package/vue/chunks/UiEditorMentionMenuItem.js +2 -2
  125. package/vue/chunks/UiEditorSlashMenu.js +2 -2
  126. package/vue/chunks/UiEditorSlashMenuGroup.js +2 -2
  127. package/vue/chunks/UiEditorSlashMenuItem.js +2 -2
  128. package/vue/chunks/UiEditorSurface.js +2 -2
  129. package/vue/chunks/UiEditorTableContextMenu.js +2 -2
  130. package/vue/chunks/UiEditorTableOverlay.js +2 -2
  131. package/vue/chunks/UiEditorTableToolbar.js +2 -2
  132. package/vue/chunks/UiEditorToolbar.js +2 -2
  133. package/vue/chunks/UiFloatingActionButton.js +2 -2
  134. package/vue/chunks/UiFormField.js +2 -2
  135. package/vue/chunks/UiGrid.js +2 -2
  136. package/vue/chunks/UiIcon.js +2 -2
  137. package/vue/chunks/UiIconButton.js +77 -11
  138. package/vue/chunks/UiImage.js +2 -2
  139. package/vue/chunks/UiInput.js +2 -2
  140. package/vue/chunks/UiInputGroup.js +2 -2
  141. package/vue/chunks/UiList.js +2 -2
  142. package/vue/chunks/UiListItem.js +2 -2
  143. package/vue/chunks/UiListbox.js +2 -2
  144. package/vue/chunks/UiMenu.js +3 -3
  145. package/vue/chunks/UiMenuGroup.js +2 -2
  146. package/vue/chunks/UiMenuItem.js +2 -2
  147. package/vue/chunks/UiMeter.js +2 -2
  148. package/vue/chunks/UiNavItem.js +2 -2
  149. package/vue/chunks/UiNumberInput.js +390 -0
  150. package/vue/chunks/UiPageHeader.js +2 -2
  151. package/vue/chunks/UiPopover.js +4 -4
  152. package/vue/chunks/UiRadio.js +2 -2
  153. package/vue/chunks/UiRadioGroup.js +2 -2
  154. package/vue/chunks/UiReel.js +2 -2
  155. package/vue/chunks/UiScrollArea.js +2 -2
  156. package/vue/chunks/UiSearchResultRow.js +2 -2
  157. package/vue/chunks/UiSearchShell.js +2 -2
  158. package/vue/chunks/UiSection.js +2 -2
  159. package/vue/chunks/UiSelect.js +2 -2
  160. package/vue/chunks/UiSeparator.js +2 -2
  161. package/vue/chunks/UiSidebar.js +2 -2
  162. package/vue/chunks/UiSlider.js +396 -0
  163. package/vue/chunks/UiSpinner.js +2 -2
  164. package/vue/chunks/UiStack.js +2 -2
  165. package/vue/chunks/UiStatusMessage.js +2 -2
  166. package/vue/chunks/UiSwatch.js +2 -2
  167. package/vue/chunks/UiSwitch.js +2 -2
  168. package/vue/chunks/UiSwitcher.js +2 -2
  169. package/vue/chunks/UiTable.js +2 -2
  170. package/vue/chunks/UiTabs.js +3 -3
  171. package/vue/chunks/UiText.js +53 -12
  172. package/vue/chunks/UiTextarea.js +2 -2
  173. package/vue/chunks/UiToast.js +2 -2
  174. package/vue/chunks/UiToastRegion.js +2 -2
  175. package/vue/chunks/UiTooltip.js +3 -3
  176. package/vue/chunks/UiTopBar.js +1 -1
  177. package/vue/chunks/UiTree.js +2 -2
  178. package/vue/chunks/UiTreeItem.js +2 -2
  179. package/vue/chunks/_plugin-vue_export-helper.js +68 -2
  180. package/vue/chunks/props.js +24 -1
  181. package/vue/components.css +2215 -1203
  182. package/vue/editor/index.js +623 -211
  183. package/vue/index.d.ts +3 -0
  184. package/vue/index.js +5 -2
@@ -1,33 +1,54 @@
1
1
  import { escapeHtml as escape } from './catalog.mjs';
2
2
  import { componentPath, previewPath } from './render.mjs';
3
- import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe } from './theme.mjs';
3
+ import { valueEditor } from './value-editors.mjs';
4
+ import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes, paletteFor } from './theme.mjs';
4
5
 
5
6
  export function customizerResource(site, input = defaultRecipe()) {
6
7
  const recipe = validateRecipe(input);
7
8
  const cardFields = ['id', 'title', 'description', 'exampleTitle', 'src', 'resizable', 'category', 'tag', 'nativeRoot', 'href', 'packageName'];
8
9
  const examples = site.catalog.components.flatMap(component => component.examples.map(example => ({ id: example.id, title: component.title, description: component.description, exampleTitle: example.title, src: site.base + previewPath(example).slice(1), resizable: Boolean(example.resizable), category: component.group, tag: component.tag, packageName: component.package, nativeRoot: component.root ? `<${component.root}>` : '', href: site.base + componentPath(component).slice(1) })));
9
10
  const toCard = example => Object.fromEntries(cardFields.map(key => [key, example[key]]));
10
- const initial = ['ui-button', 'ui-input', 'ui-color-picker', 'ui-checkbox', 'ui-card', 'ui-switch'].map(tag => examples.find(example => example.tag === tag)).filter(Boolean).map(toCard);
11
+ const initial = ['ui-button', 'ui-input', 'ui-color-picker', 'ui-checkbox', 'ui-card', 'ui-switch', 'ui-text', 'ui-dialog', 'ui-popover'].map(tag => examples.find(example => example.tag === tag && (tag !== 'ui-button' || example.id.endsWith('/02-variant')) && (tag !== 'ui-text' || example.id.endsWith('/02-variants')) && (tag !== 'ui-card' || example.id.endsWith('/04-glass'))) ?? examples.find(example => example.tag === tag)).filter(Boolean).map(toCard);
11
12
  if (!initial.length && examples.length) initial.push(toCard(examples[0]));
12
- const field = field => `<ui-form-field><label slot="label" for="theme-${field.id}">${escape(field.label)}</label><span slot="help">${escape(field.description)}</span><ui-cluster wrap="nowrap" gap="xs">${field.type === 'colour' ? `<ui-color-picker size="sm" data-token="${field.id}" value="${escape(recipe.values[field.id])}" label="${escape(field.label)} colour picker"></ui-color-picker>` : ''}<ui-input id="theme-${field.id}" size="sm" data-token="${field.id}" value="${escape(recipe.values[field.id])}" aria-label="${escape(field.label)}" type="${['positive','ratio','nonnegative','fraction','weight'].includes(field.type) ? 'number' : 'text'}"${['positive','ratio','nonnegative','fraction','weight'].includes(field.type) ? ' step="any"' : ''}></ui-input></ui-cluster></ui-form-field>`;
13
- const families = [...new Set(themeFields.map(field => field.family))].map(family => `<ui-disclosure summary="${family}"${family === 'Colour' ? ' open' : ''}><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve'].includes(item.id)).map(field).join('')}${family === 'Size & spacing' ? `<ui-cluster><ui-button size="sm">Small</ui-button><ui-button>Medium</ui-button><ui-button size="lg">Large</ui-button></ui-cluster><ui-disclosure summary="How the scale grows"><ui-stack><p>Control size anchors one shared height. Size ratio separates small from large. Density and spacing curve change breathing room independently.</p>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(field).join('')}</ui-stack></ui-disclosure>` : ''}</ui-stack></ui-disclosure>`).join('');
14
- const options = site.catalog.groups.map(group => `<optgroup label="${escape(group.label)}">${examples.filter(example => example.category === group.id).map(example => `<option value="${escape(example.id)}" data-example="${escape(JSON.stringify(example))}">${escape(example.title)} · ${escape(example.exampleTitle)}</option>`).join('')}</optgroup>`).join('');
15
- const links = ['ui-stack','ui-grid','ui-cluster','ui-form-field','ui-input','ui-select','ui-color-picker','ui-button','ui-disclosure','ui-textarea'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
13
+ const renderField = field => {
14
+ const value = recipe.values[field.id], editor = valueEditor(field, value);
15
+ const common = `size="sm" data-token="${field.id}" label="${escape(field.label)}"`;
16
+ let control;
17
+ if (field.type === 'easing') {
18
+ control = `<ui-input id="theme-${field.id}" size="sm" data-token="${field.id}" aria-label="${escape(field.label)}" value="${escape(value)}"></ui-input><div data-curve-for="${field.id}"${editor.kind === 'text' ? ' hidden' : ''}><ui-curve-editor ${common} data-editor-kind="curve" value="${escape(value)}"></ui-curve-editor></div>`;
19
+ } else if (editor.kind === 'number' || editor.kind === 'slider') {
20
+ const tag = editor.kind === 'slider' ? 'ui-slider' : 'ui-number-input';
21
+ const numeric = `<${tag} id="theme-${field.id}" ${common} data-editor-kind="${editor.kind}" value="${escape(editor.value)}" unit="${editor.unit}" min="${editor.min}" max="${editor.max}" step="${editor.step}"></${tag}>`;
22
+ control = editor.units ? `<ui-cluster wrap="nowrap" gap="xs">${numeric}<ui-select size="sm" style="--ui-select-inline-size: auto" data-unit-for="${field.id}" aria-label="${escape(field.label)} unit" value="${editor.unit}">${[...new Set([...editor.units, editor.unit])].map(unit => `<option value="${unit}">${unit}</option>`).join('')}</ui-select></ui-cluster>` : numeric;
23
+ } else {
24
+ control = `<ui-cluster wrap="nowrap" gap="xs">${field.type === 'colour' ? `<ui-color-picker size="sm" data-token="${field.id}" value="${escape(value)}" label="${escape(field.label)} colour picker"></ui-color-picker>` : ''}<ui-input id="theme-${field.id}" size="sm" data-token="${field.id}" value="${escape(value)}" aria-label="${escape(field.label)}"></ui-input></ui-cluster>`;
25
+ }
26
+ return `<ui-form-field><label slot="label" for="theme-${field.id}">${escape(field.label)}</label><span slot="help">${escape(field.description)}</span>${control}</ui-form-field>`;
27
+ };
28
+ const supplements = {
29
+ 'Colour': `<ui-form-field><label slot="label" for="theme-palette">Coordinated palette</label><span slot="help">Choose related action, paper and ink colors together. Size, material and motion stay yours. Both light and dark ranges are generated.</span><ui-select id="theme-palette" size="sm" data-palette value="${paletteFor(recipe)}"><option value="custom" disabled>Your palette</option>${Object.entries(themePalettes).map(([id, palette]) => `<option value="${id}">${escape(palette.label)}</option>`).join('')}</ui-select></ui-form-field><ui-cluster>${['light', 'dark'].map(mode => `<ui-card data-theme="${mode}" padding="sm"><ui-text variant="label">${mode === 'light' ? 'Light' : 'Dark'} range</ui-text><div class="palette-range" role="img" aria-label="${mode} canvas, content and action range"><span class="palette-canvas"></span><span class="palette-content"></span><span class="palette-action"></span></div><ui-text variant="caption">Fellowship office</ui-text></ui-card>`).join('')}</ui-cluster><p data-palette-adjustments role="status" aria-live="polite"></p><ui-disclosure summary="Resolved palette values"><p data-palette-adjustment-details></p></ui-disclosure><ui-disclosure summary="Palette seeds"><ui-stack>${themeFields.filter(item => item.family === 'Colour').map(renderField).join('')}</ui-stack></ui-disclosure>`,
30
+ 'Material': `<ui-disclosure summary="Material details"><ui-stack><p>Glassiness and surface separation coordinate the material. These controls preserve older recipes and offer finer adjustments.</p>${themeFields.filter(item => ['translucency', 'edge-light'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
31
+ 'Size & spacing': `<ui-cluster><ui-button size="sm">Small</ui-button><ui-button>Medium</ui-button><ui-button size="lg">Large</ui-button></ui-cluster><ui-disclosure summary="How the scale grows"><ui-stack><p>Control size anchors one shared height. Size ratio separates small from large. Density and spacing curve change breathing room independently.</p>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
32
+ };
33
+ const families = [...new Set(themeFields.map(field => field.family))].map(family => `<ui-disclosure summary="${family}"${family === 'Colour' ? ' open' : ''}><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve', 'translucency', 'edge-light'].includes(item.id) && family !== 'Colour').map(renderField).join('')}${supplements[family] ?? ''}</ui-stack></ui-disclosure>`).join('');
34
+ const options = site.catalog.groups.map(group => `<optgroup label="${escape(group.label)}">${examples.filter(example => example.category === group.id).map(example => `<option value="${escape(example.id)}" data-example="${escape(JSON.stringify(example))}" data-search="${escape(`${example.title} ${example.tag} ${example.description} ${example.exampleTitle}`.toLocaleLowerCase())}">${escape(example.title)} · ${escape(example.exampleTitle)}</option>`).join('')}</optgroup>`).join('');
35
+ const links = ['ui-stack','ui-grid','ui-cluster','ui-form-field','ui-input','ui-select','ui-color-picker','ui-button','ui-disclosure','ui-textarea','ui-number-input','ui-slider','ui-curve-editor','ui-card','ui-text'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
16
36
  return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
17
37
  <state name="cards" type="list" value="${escape(JSON.stringify(initial))}"><prop type="object">${cardFields.map(name => `<prop name="${name}" required type="${name === 'resizable' ? 'boolean' : 'string'}"></prop>`).join('')}</prop></state>
18
38
  </defs>
19
39
  <div data-looma-customizer data-recipe="${escape(JSON.stringify(recipe))}" data-base="${escape(site.base)}" data-mode="${site.config.local ? 'local' : 'public'}" data-revision="${escape(site.config.revision ?? '')}">
20
- <ui-stack gap="xl"><p>Shape your design system through shared decisions. These are the actual examples from the component documentation. Unsaved changes affect the cards; saving applies the theme throughout your documentation.</p>
40
+ <ui-stack gap="xl"><p>Shape your design system through shared decisions. These are the actual examples from the component documentation. Changes apply throughout your documentation and are remembered in this browser until reset. Save to repository writes the theme to your project; downloads let you use it elsewhere.</p>
21
41
  <div class="customizer-layout">
22
42
  <aside><ui-stack><ui-form-field><label slot="label" for="theme-start">Starting point</label><ui-select id="theme-start" data-preset value="${startingPointFor(recipe)}"><option value="custom" disabled>Your theme</option>${Object.entries(startingPointLabels).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}</ui-select></ui-form-field>
23
43
  ${families}
24
44
  <ui-cluster><ui-button data-action="css" variant="solid">Download CSS</ui-button><ui-button data-action="recipe">Download recipe</ui-button><ui-button data-action="import">Import recipe</ui-button><ui-button data-action="reset" variant="ghost">Reset changes</ui-button></ui-cluster>
25
45
  <input type="file" accept="application/json,.json" hidden data-recipe-file>
26
46
  <ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
27
- <p role="status" aria-live="polite" data-message>Preview changes on the documentation cards.</p>
47
+ <ui-disclosure summary="Use your theme"><p>Every export includes light and dark. Leave data-theme off the page to follow system preference, or set it to light or dark on the page or a section. <a href="${escape(site.base)}design-system/">Installation and mode guidance</a></p></ui-disclosure>
48
+ <p role="status" aria-live="polite" data-message>Your theme is remembered in this browser. Repository files change only when you save.</p>
28
49
  <ui-disclosure summary="Theme CSS"><ui-textarea data-css readonly rows="12" aria-label="Generated theme CSS" value="${escape(themeCSS(recipe))}"></ui-textarea></ui-disclosure>
29
50
  </ui-stack></aside>
30
- <section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-choice">Add a documentation example</label><ui-select id="example-choice" data-add-example>${options}</ui-select></ui-form-field><ui-button data-action="add">Add example card</ui-button>
51
+ <section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Find components and examples</label><ui-input id="example-query" type="search" data-example-query placeholder="Name, tag or purpose"></ui-input></ui-form-field><p role="status" aria-live="polite" data-example-count></p><ui-form-field><label slot="label" for="example-choice">Add a documentation example</label><ui-select id="example-choice" data-add-example>${options}</ui-select></ui-form-field><ui-button data-action="add">Add example card</ui-button>
31
52
  <ui-grid columns="2" min="sm"><section $each="card of $cards" $key="$card.id"><looma-example-card ${cardFields.filter(name => name !== 'id').map(name => `from:${name.replace(/[A-Z]/g, letter => '-' + letter.toLowerCase())}="$card.${name}"`).join(' ')} from:identity="$card.id" loading="eager" removable></looma-example-card></section></ui-grid>
32
53
  <noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
33
54
  </ui-stack></section></div></ui-stack></div>
@@ -35,6 +56,11 @@ export function customizerResource(site, input = defaultRecipe()) {
35
56
  .customizer-layout { display: grid !important; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
36
57
  aside { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding-inline-end: var(--ui-space-2) !important; }
37
58
  .examples { min-inline-size: 0; }
59
+ .palette-range { display: flex !important; min-block-size: var(--ui-control-size-sm); border: var(--ui-border-width) solid var(--ui-control-border); border-radius: var(--ui-radius-sm); overflow: hidden; }
60
+ .palette-range > span { flex: 1; min-inline-size: var(--ui-space-5); }
61
+ .palette-canvas { background: var(--ui-surface); }
62
+ .palette-content { background: var(--ui-surface-raised); }
63
+ .palette-action { background: var(--ui-accent); }
38
64
  @media (max-width: 64rem) { .customizer-layout { grid-template-columns: minmax(0, 1fr); } aside { position: static; max-block-size: none; } }
39
65
  </style></template>`;
40
66
  }
@@ -1,5 +1,8 @@
1
+ import { documentationTheme, themeEvent } from './theme-session.mjs';
1
2
  import { componentRegistry } from '@nextwebwg/html-next/runtime';
2
- import { startingPoints, startingPointFor, themeCSS, validateRecipe } from './theme.mjs';
3
+ import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, paletteFor, deriveTheme } from './theme.mjs';
4
+
5
+ import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
3
6
 
4
7
  /** The editor changes public theme values; the library owns every control's layout. */
5
8
  export function connect(host) {
@@ -8,8 +11,9 @@ export function connect(host) {
8
11
  const view = document.defaultView;
9
12
  const message = root.querySelector('[data-message]');
10
13
  const file = root.querySelector('[data-recipe-file]');
11
- let recipe = validateRecipe(JSON.parse(root.dataset.recipe));
12
- let saved = structuredClone(recipe);
14
+ const theme = documentationTheme(view);
15
+ let saved = validateRecipe(JSON.parse(root.dataset.recipe));
16
+ let recipe = theme?.recipe ?? structuredClone(saved);
13
17
  let revision = root.dataset.revision;
14
18
  let capability;
15
19
  let changed = false;
@@ -17,30 +21,72 @@ export function connect(host) {
17
21
  let alive = true;
18
22
  const abort = new AbortController();
19
23
  const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
20
- const updateControl = (element, value) => componentRegistry.update(element, { value });
24
+ const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
25
+ const fields = new Map(themeFields.map(field => [field.id, field]));
26
+ const unitContext = () => ({ remPixels: parseFloat(view.getComputedStyle(document.documentElement).fontSize) });
27
+ const updateControl = (element, value) => {
28
+ const field = fields.get(element.dataset.token);
29
+ if (!field || !element.dataset.editorKind) { componentRegistry.update(element, { value }); return; }
30
+ const editor = valueEditor(field, value, unitContext());
31
+ if (element.dataset.editorKind === 'curve') {
32
+ element.closest('[data-curve-for]').hidden = editor.kind !== 'curve';
33
+ if (editor.kind === 'curve') componentRegistry.update(element, { value });
34
+ } else componentRegistry.update(element, Object.fromEntries(['value', 'unit', 'min', 'max', 'step'].map(key => [key, editor[key]])));
35
+ };
36
+ const updateUnits = () => {
37
+ for (const control of root.querySelectorAll('[data-unit-for]')) {
38
+ const field = fields.get(control.dataset.unitFor), editor = valueEditor(field, recipe.values[field.id], unitContext());
39
+ if (![...control.options].some(option => option.value === editor.unit)) control.add(new view.Option(editor.unit, editor.unit));
40
+ componentRegistry.update(control, { value: editor.unit });
41
+ }
42
+ };
21
43
  const paint = () => {
22
44
  const css = themeCSS(recipe);
23
- for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
45
+ updateControl(root.querySelector('[data-palette]'), paletteFor(recipe));
46
+ const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
47
+ const report = root.querySelector('[data-palette-adjustments]');
48
+ report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your seed colors stay editable; resolved values are shown below.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
49
+ root.querySelector('[data-palette-adjustment-details]').textContent = adjustments.length ? adjustments.map(item => `${item.mode} ${fields.get(item.id).label.toLowerCase()} ${item.requested} → ${item.resolved}`).join('; ') + '. Seed colors remain editable.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
50
+ for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css: theme?.css ?? css } }));
24
51
  updateControl(root.querySelector('[data-css]'), css);
25
52
  };
26
- const show = next => {
53
+ const show = (next, persist = true) => {
27
54
  recipe = validateRecipe(next);
55
+ if (persist) theme?.apply(recipe);
28
56
  for (const control of root.querySelectorAll('[data-token]')) updateControl(control, recipe.values[control.dataset.token]);
29
57
  updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
58
+ updateUnits();
30
59
  changed = JSON.stringify(recipe) !== JSON.stringify(saved);
31
60
  paint();
32
61
  };
62
+ const filterExamples = () => {
63
+ const words = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
64
+ const select = root.querySelector('[data-add-example]');
65
+ const options = [...select.options];
66
+ for (const option of options) option.hidden = !words.every(word => option.dataset.search.includes(word));
67
+ for (const group of select.querySelectorAll('optgroup')) group.hidden = [...group.children].every(option => option.hidden);
68
+ if (select.selectedOptions[0]?.hidden) select.value = options.find(option => !option.hidden)?.value ?? '';
69
+ for (const card of root.querySelectorAll('[data-example-card]')) card.closest('section').hidden = !words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
70
+ const matches = options.filter(option => !option.hidden).length;
71
+ root.querySelector('[data-example-count]').textContent = matches ? `${matches} matching examples` : 'No examples match. Try another name or purpose.';
72
+ componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: matches === 0 });
73
+ };
33
74
  const input = event => {
75
+ if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
34
76
  const control = event.target.closest?.('[data-token]');
35
77
  if (!control) return;
36
- const value = event.detail?.value ?? control.value;
78
+ const field = fields.get(control.dataset.token);
79
+ const raw = event.detail?.value ?? control.value;
80
+ const value = control.dataset.editorKind ? editorValue(field, raw) : String(raw);
37
81
  const next = { schemaVersion: 1, values: { ...recipe.values, [control.dataset.token]: value } };
38
82
  try {
39
- recipe = validateRecipe(next); changed = true;
83
+ recipe = validateRecipe(next);
84
+ theme?.apply(recipe); changed = true;
40
85
  updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
41
86
  control.removeAttribute('aria-invalid');
42
- for (const sibling of root.querySelectorAll('[data-token]')) if (sibling !== control && sibling.dataset.token === control.dataset.token) updateControl(sibling, value);
43
- paint(); say('Unsaved preview. Download the theme or save it to your repository.');
87
+ for (const sibling of root.querySelectorAll('[data-token]')) if (sibling.dataset.token === control.dataset.token) updateControl(sibling, value);
88
+ updateUnits();
89
+ paint(); sayApplied('Theme remembered in this browser. Download it or save it to your repository.');
44
90
  } catch (error) { control.setAttribute('aria-invalid', 'true'); say(error.message, true); }
45
91
  };
46
92
  const download = (data, name, type) => {
@@ -59,7 +105,7 @@ export function connect(host) {
59
105
  const selected = JSON.parse(option.dataset.example);
60
106
  const card = Object.fromEntries(['id','title','description','exampleTitle','src','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
61
107
  if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [...host.state.cards, card];
62
- view.requestAnimationFrame(paint); break;
108
+ view.requestAnimationFrame(() => { paint(); filterExamples(); }); break;
63
109
  }
64
110
  case 'css': {
65
111
  const response = await fetch(root.dataset.base + '_looma/theme-system.css', { signal: abort.signal });
@@ -68,7 +114,7 @@ export function connect(host) {
68
114
  }
69
115
  case 'recipe': download(JSON.stringify(recipe, null, 2) + '\n', 'looma.theme.json', 'application/json'); say('Editable recipe downloaded.'); break;
70
116
  case 'import': file.click(); break;
71
- case 'reset': show(structuredClone(saved)); say('Returned to the saved theme.'); break;
117
+ case 'reset': show(theme?.reset() ?? structuredClone(saved), false); say(theme?.storageAvailable ? 'Browser customization reset to the saved theme.' : 'Changes reset for this page. Browser storage is unavailable, so a previous browser theme may return after reload.'); break;
72
118
  case 'save': {
73
119
  if (!capability) throw new Error('Repository saving is available only in the local documentation server.');
74
120
  const submitted = structuredClone(recipe);
@@ -77,7 +123,7 @@ export function connect(host) {
77
123
  const response = await fetch(root.dataset.base + '_looma/theme', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Looma-Capability': capability }, body: JSON.stringify({ recipe: submitted, revision }), signal: abort.signal });
78
124
  const result = await response.json();
79
125
  if (!response.ok) throw new Error(result.error ?? 'Could not save this theme.');
80
- revision = result.revision; saved = submitted;
126
+ revision = result.revision; saved = submitted; theme?.baseline(saved);
81
127
  changed = JSON.stringify(recipe) !== JSON.stringify(saved);
82
128
  if (changed) { say('Saved the submitted theme. Your newer edits are still an unsaved preview.'); break; }
83
129
  say('Saved to your repository. Reloading your design system…');
@@ -88,31 +134,48 @@ export function connect(host) {
88
134
  finally { if (button.dataset.action === 'save') saving = false; }
89
135
  };
90
136
  const change = async event => {
91
- if (event.target.matches('[data-preset]')) { show(structuredClone(startingPoints[event.target.value])); say('Starting point applied to every preview card.'); }
137
+ if (event.target.matches('[data-unit-for]')) {
138
+ const field = fields.get(event.target.dataset.unitFor);
139
+ try {
140
+ let current = recipe.values[field.id];
141
+ try { current = convertValueUnit(current, event.target.value, unitContext()); }
142
+ catch { current = convertValueUnit(view.getComputedStyle(document.documentElement).getPropertyValue(field.property).trim(), event.target.value, unitContext()); }
143
+ show({ ...recipe, values: { ...recipe.values, [field.id]: current } });
144
+ sayApplied(`${field.label} units updated and remembered in this browser.`);
145
+ } catch (error) { updateUnits(); say(error.message, true); }
146
+ }
147
+ if (event.target.matches('[data-palette]')) { show({ ...recipe, values: { ...recipe.values, ...themePalettes[event.target.value].values } }); sayApplied('Coordinated palette applied. Both color modes are remembered with your existing size, material and motion.'); }
148
+ if (event.target.matches('[data-preset]')) { show(structuredClone(startingPoints[event.target.value])); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
92
149
  if (event.target === file && file.files[0]) {
93
- try { show(JSON.parse(await file.files[0].text())); say('Recipe imported as an unsaved preview.'); }
150
+ try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
94
151
  catch (error) { say(error.message, true); }
95
152
  file.value = '';
96
153
  }
97
154
  };
155
+ const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
156
+ const synchronize = () => { if (theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
98
157
  const ready = event => {
99
158
  if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
100
159
  };
101
160
  const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); };
102
161
  root.addEventListener('looma-example-remove', remove);
103
162
  root.addEventListener('input', input);
163
+ root.addEventListener('blur', blur, true);
104
164
  root.addEventListener('change', change);
105
165
  root.addEventListener('click', click);
106
166
  view.addEventListener('message', ready);
107
- paint();
167
+ view.addEventListener(themeEvent, synchronize);
168
+ show(recipe, false); filterExamples();
169
+ if (theme?.storageAvailable === false) sayApplied('');
108
170
  if (root.dataset.mode === 'local') {
109
171
  fetch(root.dataset.base + '_looma/session', { signal: abort.signal }).then(async response => {
110
172
  if (!response.ok) throw new Error('Local theme saving is unavailable.');
111
- const session = await response.json(); if (!alive) return;
112
- capability = session.capability;
113
- if (!changed) { saved = validateRecipe(session.recipe); revision = session.revision; show(saved); }
173
+ const local = await response.json(); if (!alive) return;
174
+ capability = local.capability;
175
+ saved = validateRecipe(local.recipe); revision = local.revision; theme?.baseline(saved);
176
+ if (!changed && !theme?.hasOverride) show(saved, false);
114
177
  root.querySelector('[data-action="save"]').hidden = false;
115
178
  }).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
116
179
  }
117
- return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); };
180
+ return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
118
181
  }
@@ -2,7 +2,15 @@
2
2
  [data-example-card] iframe { display: block; max-inline-size: 100%; border: 0; background: transparent; }
3
3
  [data-example-card] pre { overflow: auto; }
4
4
  /* The customizer is an application page; use Markupress's public width token instead of patching its layout. */
5
- :root:has([data-looma-customizer]), :root:has([data-component-card]) { --markupress-content-width: 110rem; }
5
+ :root:has([data-looma-customizer]), :root:has([data-component~="looma-catalog"]) { --markupress-content-width: 110rem; }
6
6
  body:has([data-preview]) { background: var(--ui-surface); color: var(--ui-text); font-family: var(--ui-font-sans); font-size: var(--ui-font-size); line-height: var(--ui-line-height); }
7
7
  [data-preview] { display: grid; place-items: center; gap: var(--ui-space-4); min-block-size: 13rem; padding: 1.35rem; box-sizing: border-box; }
8
+ [data-preview-layout] { justify-items: stretch; }
8
9
  body:has([data-preview]) { margin: 0; background: linear-gradient(90deg, rgb(43 37 56 / .035) 1px, transparent 1px), linear-gradient(rgb(43 37 56 / .035) 1px, transparent 1px), color-mix(in srgb, var(--ui-accent) 5%, var(--ui-surface)); background-size: 22px 22px; }
10
+
11
+ /* These headings are authored page content projected through the example card's heading slot. */
12
+ .looma-reference-examples { padding-block-start: var(--ui-space-5); }
13
+ .looma-example-heading h3 { margin: 0; font-size: var(--ui-font-size-lg); line-height: var(--ui-line-height-tight); }
14
+ .looma-example-heading p { margin: var(--ui-space-2) 0 0; font-size: var(--ui-font-size-sm); color: var(--ui-text-secondary); }
15
+ html[data-preview-reference], html[data-preview-reference] body { background: var(--ui-surface); }
16
+ html[data-preview-reference] [data-preview] { min-block-size: 11rem; }
package/docs/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export interface DocumentationCatalog {
18
18
  }
19
19
  export interface DiscoveryRoot { directory: string; packageName: string; groups?: DocumentationGroup[] }
20
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 }
21
+ export interface ThemeField { family: string; id: string; label: string; property: string; type: string; defaultValue: string; description: string; min?: number; max?: number }
22
22
  export interface DocumentationConfiguration {
23
23
  title?: string; base?: string; origin?: string; outDir?: string; content?: string;
24
24
  logo?: string | { light: string; dark?: string }; nav?: { label: string; href: string }[];
@@ -51,4 +51,6 @@ export const themeFields: readonly ThemeField[];
51
51
  export const startingPoints: Record<string, ThemeRecipe>;
52
52
  export function defaultRecipe(): ThemeRecipe;
53
53
  export function validateRecipe(input: unknown): ThemeRecipe;
54
- export function themeCSS(input: ThemeRecipe, systemCSS?: string): string;
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 CHANGED
@@ -1,4 +1,4 @@
1
1
  export { readCatalog, discoverComponents, mergeCatalogs } from './catalog.mjs';
2
2
  export { prepareDocumentation, documentationOptions, documentationPlugin, buildDocumentation } from './site.mjs';
3
- export { defaultRecipe, startingPoints, themeFields, validateRecipe, themeCSS } from './theme.mjs';
3
+ export { defaultRecipe, startingPoints, themeFields, validateRecipe, deriveTheme, themeCSS } from './theme.mjs';
4
4
  export { initializeDocumentation, buildProjectDocumentation, devProjectDocumentation, previewProjectDocumentation, readConfiguration } from './commands.mjs';
package/docs/preview.js CHANGED
@@ -9,6 +9,8 @@ export function connect(host) {
9
9
  const receive = event => {
10
10
  if (event.source !== view.parent || event.data?.type !== 'looma-theme' || typeof event.data.css !== 'string') return;
11
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;
12
14
  height();
13
15
  };
14
16
  view.addEventListener('message', receive);
package/docs/render.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
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 });
3
6
 
4
7
  const literal = value => escape(value).replaceAll('{', '\\{');
5
8
  const code = value => `<code>${literal(value ?? '—')}</code>`;
@@ -67,8 +70,8 @@ export function exampleCodes(example, component, components) {
67
70
  }
68
71
 
69
72
  /** The same card invocation is embedded in reference pages and rendered by the customizer. */
70
- export function cardMarkup(example, base = '/', component = {}) {
71
- return `<looma-example-card identity="${escape(example.id)}" title="${escape(component.title ?? example.title)}" description="${escape(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' : ''}></looma-example-card>`;
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>`;
72
75
  }
73
76
 
74
77
  export function referencePage(component, catalog, cardResource, base) {
@@ -76,9 +79,15 @@ export function referencePage(component, catalog, cardResource, base) {
76
79
  const examples = [...component.examples, ...parts.flatMap(part => part.examples)];
77
80
  const codes = example => {
78
81
  const owner = catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component;
79
- const tabs = Object.entries(exampleCodes(example, owner, catalog.components)).map(([mode, value]) => `<section aria-label="${mode === 'vue' ? 'Vue' : 'HTML Next'}"><pre><code class="language-${escape(value.language)}">${literal(value.code)}</code></pre></section>`).join('');
80
- return `<ui-disclosure summary="Example code"><ui-tabs label="Example syntax" group="syntax">${tabs}</ui-tabs></ui-disclosure>`;
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>`;
81
90
  };
82
- const body = `<ui-stack gap="xl">${examples.map(example => `<section>${cardMarkup(example, base, catalog.components.find(c => c.examples.some(e => e.id === example.id)) ?? component)}${codes(example)}</section>`).join('')}</ui-stack>`;
83
- 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${escape(component.description)}\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-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:::: tabs\n== Examples\n\n${body}\n\n== API\n\n<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')}\n::::\n\n</looma-reference>\n`;
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`;
84
93
  }
package/docs/site.mjs CHANGED
@@ -5,6 +5,7 @@ import { createRequire } from 'node:module';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import { discoverComponents, escapeHtml, mergeCatalogs, readCatalog, readJson } from './catalog.mjs';
7
7
  import { cardMarkup, componentPath, exampleKey, previewPath, referencePage } from './render.mjs';
8
+ import { defaultRecipe, themeCSS } from './theme.mjs';
8
9
  import { customizerResource } from './customizer-render.mjs';
9
10
  import { catalogResource } from './catalog-view.mjs';
10
11
  import { cacheDirectory, rejectSymlink, withBuildLock } from './workspace.mjs';
@@ -85,14 +86,19 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
85
86
  await cp(own('catalog-view.js'), path.join(generated, 'catalog-view.js'));
86
87
  await cp(own('preview.js'), path.join(generated, 'preview.js'));
87
88
  for (const file of ['reference.html', 'reference.js']) await cp(own(file), path.join(generated, file));
88
- for (const file of ['customizer.js', 'theme.mjs']) 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));
89
90
  const write = async (file, source) => { await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, source); };
90
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();
91
96
  const resourceByTag = new Map(combined.resources.map(resource => [resource.tag, resource]));
92
97
  for (const component of combined.components) {
93
98
  if (!component.navigationParent) {
94
- const page = path.join(content, 'components', component.tag + '.md');
99
+ const page = path.join(content, 'components', groupDirectories.get(component.group), component.tag + '.md');
95
100
  await write(page, referencePage(component, combined, relativeHref(page, card), base));
101
+ generatedReferences.add(`components/${component.tag}.md`);
96
102
  }
97
103
  for (const example of component.examples) {
98
104
  const key = exampleKey(example.id);
@@ -107,7 +113,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
107
113
  const setup = path.join(previews, key + '.js');
108
114
  const behavior = example.behavior;
109
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`);
110
- 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)}">${example.markup}${anchors}</div></template>\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`);
111
117
  examples.push({ ...example, file, component: component.id, path: base + previewPath(example).slice(1) });
112
118
  }
113
119
  }
@@ -122,6 +128,9 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
122
128
  const authored = path.resolve(root, config.content);
123
129
  for (const source of await authoredFiles(authored)) {
124
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.`);
125
134
  let exists = false;
126
135
  try { await readFile(target); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
127
136
  // A project's landing page and footer are branding/content, never API overrides.
@@ -144,6 +153,16 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
144
153
  return { root, config, base, catalog: combined, examples, generated, content, previews, redirects };
145
154
  }
146
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
+
147
166
  /** One plugin powers both the public consumer and local repositories. */
148
167
  export function documentationPlugin(site) {
149
168
  return {
@@ -158,13 +177,35 @@ export async function documentationOptions(site) {
158
177
  const { siteOptions } = await tool('markupress', site.root);
159
178
  const styles = ['tokens.css', 'theme-light.css', 'theme-dark.css', 'theme-system.css'].map(file => fileURLToPath(import.meta.resolve(`@threadlabs/looma/${file}`)));
160
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 });
161
- let savedTheme;
162
- if (site.config.themeCSSContents !== undefined) {
163
- savedTheme = path.join(site.generated, 'saved-theme.css');
164
- await writeFile(savedTheme, site.config.themeCSSContents);
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);
165
207
  }
166
- const themeStyles = [savedTheme, ...(site.config.overrides ?? []).map(file => path.resolve(site.root, file))].filter(Boolean);
167
- return themeStyles.length ? { ...options, plugins: [...options.plugins, { name: 'looma-saved-theme', config: () => ({ css: themeStyles }) }] } : options;
208
+ return styles;
168
209
  }
169
210
 
170
211
  /** Build through HTMLKit, then make opaque preview scripts portable to static hosts without CORS. */
@@ -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
+ }