@threadlabs/looma 0.34.0 → 0.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/components/ui-badge/ui-badge.html +11 -3
  2. package/components/ui-button/ui-button.html +29 -4
  3. package/components/ui-card/ui-card.html +15 -13
  4. package/components/ui-checkbox/ui-checkbox.html +4 -2
  5. package/components/ui-dialog/ui-dialog.html +2 -0
  6. package/components/ui-icon-button/ui-icon-button.html +22 -8
  7. package/components/ui-input/ui-input.html +2 -1
  8. package/components/ui-input-group/ui-input-group.html +4 -1
  9. package/components/ui-light-editor/ui-light-editor.html +45 -0
  10. package/components/ui-light-editor/ui-light-editor.js +53 -0
  11. package/components/ui-menu/ui-menu.html +2 -0
  12. package/components/ui-number-input/ui-number-input.html +2 -1
  13. package/components/ui-number-input/ui-number-input.js +6 -3
  14. package/components/ui-popover/ui-popover.html +2 -0
  15. package/components/ui-reel/ui-reel.html +2 -1
  16. package/components/ui-select/ui-select.html +9 -7
  17. package/components/ui-switch/ui-switch.html +3 -0
  18. package/components/ui-tabs/ui-tabs.html +2 -3
  19. package/components/ui-tabs/ui-tabs.js +0 -9
  20. package/components/ui-textarea/ui-textarea.html +2 -1
  21. package/components/ui-tooltip/ui-tooltip.html +2 -1
  22. package/dist/index.js +24 -23
  23. package/docs/README.md +29 -3
  24. package/docs/card.html +7 -6
  25. package/docs/catalog-view.mjs +2 -1
  26. package/docs/catalog.json +575 -53
  27. package/docs/customizer-render.mjs +31 -16
  28. package/docs/customizer.js +72 -19
  29. package/docs/index.d.ts +4 -1
  30. package/docs/index.mjs +1 -1
  31. package/docs/render.mjs +1 -1
  32. package/docs/site.mjs +33 -22
  33. package/docs/theme-session.mjs +4 -2
  34. package/docs/theme.mjs +61 -7
  35. package/package.json +2 -2
  36. package/styles/ui-badge.css +11 -6
  37. package/styles/ui-button.css +29 -4
  38. package/styles/ui-card.css +9 -9
  39. package/styles/ui-checkbox.css +4 -2
  40. package/styles/ui-dialog.css +4 -0
  41. package/styles/ui-icon-button.css +22 -8
  42. package/styles/ui-input-group.css +4 -1
  43. package/styles/ui-input.css +2 -1
  44. package/styles/ui-light-editor.css +28 -0
  45. package/styles/ui-menu.css +2 -0
  46. package/styles/ui-popover.css +2 -0
  47. package/styles/ui-select.css +9 -7
  48. package/styles/ui-switch.css +3 -0
  49. package/styles/ui-tabs.css +2 -3
  50. package/styles/ui-textarea.css +2 -1
  51. package/styles/ui-tooltip.css +2 -1
  52. package/theme-light.css +1 -0
  53. package/theme-system.css +43 -7
  54. package/tokens.css +5 -1
  55. package/vanilla/UiCard.d.ts +2 -1
  56. package/vanilla/UiCard.js +12 -12
  57. package/vanilla/UiLightEditor.d.ts +29 -0
  58. package/vanilla/UiLightEditor.js +102 -0
  59. package/vanilla/UiNumberInput.js +27 -20
  60. package/vanilla/index.js +1 -0
  61. package/vue/UiBadge.vue +29 -4
  62. package/vue/UiButton.vue +33 -4
  63. package/vue/UiCard.d.ts +8 -2
  64. package/vue/UiCard.vue +41 -17
  65. package/vue/UiCheckbox.vue +4 -2
  66. package/vue/UiDialog.vue +4 -0
  67. package/vue/UiIconButton.vue +26 -11
  68. package/vue/UiInput.vue +2 -1
  69. package/vue/UiInputGroup.vue +4 -1
  70. package/vue/UiLightEditor.d.ts +75 -0
  71. package/vue/UiLightEditor.js +2 -0
  72. package/vue/UiLightEditor.vue +402 -0
  73. package/vue/UiMenu.vue +2 -0
  74. package/vue/UiNumberInput.vue +32 -3
  75. package/vue/UiPopover.vue +2 -0
  76. package/vue/UiReel.vue +1 -1
  77. package/vue/UiSelect.vue +16 -10
  78. package/vue/UiSwitch.vue +3 -0
  79. package/vue/UiTabs.vue +2 -3
  80. package/vue/UiTextarea.vue +2 -1
  81. package/vue/UiTooltip.vue +2 -1
  82. package/vue/chunks/UiBadge.js +1 -1
  83. package/vue/chunks/UiButton.js +1 -1
  84. package/vue/chunks/UiCard.js +33 -5
  85. package/vue/chunks/UiCheckbox.js +1 -1
  86. package/vue/chunks/UiDialog.js +1 -1
  87. package/vue/chunks/UiIconButton.js +1 -1
  88. package/vue/chunks/UiInput.js +1 -1
  89. package/vue/chunks/UiInputGroup.js +1 -1
  90. package/vue/chunks/UiLightEditor.js +347 -0
  91. package/vue/chunks/UiMenu.js +1 -1
  92. package/vue/chunks/UiNumberInput.js +21 -8
  93. package/vue/chunks/UiPopover.js +1 -1
  94. package/vue/chunks/UiReel.js +2 -2
  95. package/vue/chunks/UiSelect.js +1 -1
  96. package/vue/chunks/UiSwitch.js +1 -1
  97. package/vue/chunks/UiTabs.js +1 -1
  98. package/vue/chunks/UiTextarea.js +1 -1
  99. package/vue/chunks/UiTooltip.js +1 -1
  100. package/vue/components.css +312 -71
  101. package/vue/index.d.ts +1 -0
  102. package/vue/index.js +2 -1
@@ -1,14 +1,14 @@
1
1
  import { escapeHtml as escape } from './catalog.mjs';
2
2
  import { componentPath, previewPath } from './render.mjs';
3
3
  import { valueEditor } from './value-editors.mjs';
4
- import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes, paletteFor } from './theme.mjs';
4
+ import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState } from './theme.mjs';
5
5
 
6
6
  export function customizerResource(site, input = defaultRecipe()) {
7
7
  const recipe = validateRecipe(input);
8
8
  const cardFields = ['id', 'title', 'description', 'exampleTitle', 'src', 'resizable', 'category', 'tag', 'nativeRoot', 'href', 'packageName'];
9
9
  const examples = site.catalog.components.flatMap(component => component.examples.map(example => ({ id: example.id, title: component.title, description: component.description, exampleTitle: example.title, src: site.base + previewPath(example).slice(1), resizable: Boolean(example.resizable), category: component.group, tag: component.tag, packageName: component.package, nativeRoot: component.root ? `<${component.root}>` : '', href: site.base + componentPath(component).slice(1) })));
10
10
  const toCard = example => Object.fromEntries(cardFields.map(key => [key, example[key]]));
11
- const initial = ['ui-button', 'ui-input', 'ui-color-picker', 'ui-checkbox', 'ui-card', 'ui-switch', 'ui-text', 'ui-dialog', 'ui-popover'].map(tag => examples.find(example => example.tag === tag && (tag !== 'ui-button' || example.id.endsWith('/02-variant')) && (tag !== 'ui-text' || example.id.endsWith('/02-variants')) && (tag !== 'ui-card' || example.id.endsWith('/04-glass'))) ?? examples.find(example => example.tag === tag)).filter(Boolean).map(toCard);
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-expressive-surface'))) ?? examples.find(example => example.tag === tag)).filter(Boolean).map(toCard);
12
12
  if (!initial.length && examples.length) initial.push(toCard(examples[0]));
13
13
  const renderField = field => {
14
14
  const value = recipe.values[field.id], editor = valueEditor(field, value);
@@ -25,42 +25,57 @@ export function customizerResource(site, input = defaultRecipe()) {
25
25
  }
26
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
27
  };
28
+ const renderPreset = (family, location) => {
29
+ const presets = family === 'material' ? materialPresets : themePalettes;
30
+ const state = refinementState(recipe, family), label = family === 'material' ? 'Material' : 'Palette';
31
+ return `<ui-form-field><label slot="label" for="${location}-${family}">${label} preset</label><ui-select id="${location}-${family}" data-refinement-preset="${family}" aria-label="${label} preset" value="${state.id}"><option value="custom" disabled>Your ${family}</option>${Object.entries(presets).map(([id, preset]) => `<option value="${id}">${escape(preset.label)}${state.id === id && state.customized ? ' · customized' : ''}</option>`).join('')}</ui-select></ui-form-field>`;
32
+ };
33
+ const paletteDetails = `<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>`;
28
34
  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>`,
35
+ 'Colour': `${renderPreset('palette', 'overview')}<ui-text variant="caption">Coordinated light and dark ranges.</ui-text><ui-button data-action="refine-palette">Refine palette</ui-button>`,
36
+ 'Material': `${renderPreset('material', 'overview')}<ui-text variant="caption">Material and light, shared everywhere.</ui-text><ui-button data-action="refine-material">Refine material & light</ui-button>`,
31
37
  '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
38
  };
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');
39
+ const families = [...new Set(themeFields.map(field => field.family))].filter(family => !['Colour', 'Material'].includes(family)).map(family => `<ui-disclosure summary="${family}"><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve'].includes(item.id)).map(renderField).join('')}${supplements[family] ?? ''}</ui-stack></ui-disclosure>`).join('');
40
+ const refinement = (family, content) => `<section data-refinement-view="${family}" hidden tabindex="-1" aria-label="${family === 'material' ? 'Material and light refinement' : 'Palette refinement'}"><ui-stack gap="xl"><ui-cluster justify="between"><ui-button data-action="back">Back to customization</ui-button><ui-button data-action="restore-${family}">Restore ${family} preset</ui-button></ui-cluster><ui-text variant="heading" as="h2">${family === 'material' ? 'Refine material & light' : 'Refine your palette'}</ui-text><p>These are advanced refinements of the same persistent theme. Every change applies throughout your documentation.</p>${renderPreset(family, 'refinement')}${content}</ui-stack></section>`;
41
+ const materialView = refinement('material', `<div class="refinement-layout"><ui-stack><ui-light-editor data-light-editor x="${recipe.values['light-x']}" y="${recipe.values['light-y']}" label="Theme light position"><ui-stack gap="s"><ui-cluster><ui-button preset="primary">Join briefing</ui-button><ui-button preset="standard">Read memo</ui-button></ui-cluster><ui-input label="Office search" placeholder="Search the Fellowship office"></ui-input></ui-stack></ui-light-editor><ui-card variant="elevated" treatment="expressive"><ui-text variant="eyebrow">Fellowship office</ui-text><ui-text variant="heading" as="h3">Second breakfast briefing</ui-text><ui-text>Sam has prepared the agenda. Frodo has the maps, and Sméagol is taking minutes.</ui-text><ui-cluster><ui-button preset="primary">Join briefing</ui-button><ui-button preset="quiet">Read agenda</ui-button></ui-cluster></ui-card><ui-card variant="elevated"><ui-text variant="label">Standard elevated surface</ui-text><ui-text>Subtle lift stays independent from expressive treatment.</ui-text></ui-card></ui-stack><ui-stack><ui-text variant="label">Surface response</ui-text>${themeFields.filter(field => ['finish','edge-light','glow'].includes(field.id)).map(renderField).join('')}<ui-text variant="label">Light source</ui-text>${themeFields.filter(field => ['light-spread','light-strength'].includes(field.id)).map(renderField).join('')}<ui-disclosure summary="Depth, glass and separation"><ui-stack>${themeFields.filter(field => ['outer-depth','inner-depth','glassiness','surface-separation','translucency'].includes(field.id)).map(renderField).join('')}</ui-stack></ui-disclosure></ui-stack></div>`);
42
+ const paletteView = refinement('palette', `<div class="refinement-layout"><ui-stack>${paletteDetails}<ui-card><ui-text variant="eyebrow">Fellowship office</ui-text><ui-text variant="heading" as="h3">There and back by Friday</ui-text><ui-text>Choose a coordinated range, then refine action, paper and ink. Readable roles follow automatically in both modes.</ui-text><ui-cluster><ui-button preset="primary">Share route</ui-button><ui-button preset="standard">Save draft</ui-button></ui-cluster></ui-card></ui-stack><ui-stack>${themeFields.filter(field => field.family === 'Colour').map(renderField).join('')}</ui-stack></div>`);
43
+ 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-description="${escape(example.category + ' · ' + example.tag)}" 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('');
44
+ 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','ui-light-editor','ui-combobox'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
36
45
  return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
37
46
  <state name="cards" type="list" value="${escape(JSON.stringify(initial))}"><prop type="object">${cardFields.map(name => `<prop name="${name}" required type="${name === 'resizable' ? 'boolean' : 'string'}"></prop>`).join('')}</prop></state>
38
47
  </defs>
39
48
  <div data-looma-customizer data-recipe="${escape(JSON.stringify(recipe))}" data-base="${escape(site.base)}" data-mode="${site.config.local ? 'local' : 'public'}" data-revision="${escape(site.config.revision ?? '')}">
40
- <ui-stack gap="xl"><p>Shape your design system through shared decisions. These are the actual examples from the component documentation. Changes apply throughout your documentation and are remembered in this browser until reset. Save to repository writes the theme to your project; downloads let you use it elsewhere.</p>
41
- <div class="customizer-layout">
42
- <aside><ui-stack><ui-form-field><label slot="label" for="theme-start">Starting point</label><ui-select id="theme-start" data-preset value="${startingPointFor(recipe)}"><option value="custom" disabled>Your theme</option>${Object.entries(startingPointLabels).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}</ui-select></ui-form-field>
43
- ${families}
44
- <ui-cluster><ui-button data-action="css" variant="solid">Download CSS</ui-button><ui-button data-action="recipe">Download recipe</ui-button><ui-button data-action="import">Import recipe</ui-button><ui-button data-action="reset" variant="ghost">Reset changes</ui-button></ui-cluster>
49
+ <ui-stack gap="l"><ui-text>Choose a starting point, refine shared decisions, and see them on real documentation examples.</ui-text>
50
+ <ui-text variant="caption" role="status" aria-live="polite" data-message>Changes are remembered across the documentation.</ui-text>
51
+ ${materialView}${paletteView}
52
+ <div class="customizer-layout" data-customizer-overview>
53
+ <aside aria-label="Theme controls"><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>
54
+ ${supplements.Colour}${supplements.Material}
55
+ <ui-disclosure summary="Type, size & motion"><ui-stack>${families}</ui-stack></ui-disclosure>
56
+ <ui-button data-action="reset" variant="ghost">Reset changes</ui-button>
57
+ <ui-disclosure summary="Export & save"><ui-stack><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-cluster>
45
58
  <input type="file" accept="application/json,.json" hidden data-recipe-file>
46
59
  <ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
47
60
  <ui-disclosure summary="Use your theme"><p>Every export includes light and dark. Leave data-theme off the page to follow system preference, or set it to light or dark on the page or a section. <a href="${escape(site.base)}design-system/">Installation and mode guidance</a></p></ui-disclosure>
48
- <p role="status" aria-live="polite" data-message>Your theme is remembered in this browser. Repository files change only when you save.</p>
49
- <ui-disclosure summary="Theme CSS"><ui-textarea data-css readonly rows="12" aria-label="Generated theme CSS" value="${escape(themeCSS(recipe))}"></ui-textarea></ui-disclosure>
61
+ <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></ui-stack></ui-disclosure>
50
62
  </ui-stack></aside>
51
- <section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Find components and examples</label><ui-input id="example-query" type="search" data-example-query placeholder="Name, tag or purpose"></ui-input></ui-form-field><p role="status" aria-live="polite" data-example-count></p><ui-form-field><label slot="label" for="example-choice">Add a documentation example</label><ui-select id="example-choice" data-add-example>${options}</ui-select></ui-form-field><ui-button data-action="add">Add example card</ui-button>
63
+ <section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Filter previews</label><ui-input id="example-query" type="search" data-example-query placeholder="Name, tag or purpose"></ui-input></ui-form-field><ui-text variant="caption" role="status" aria-live="polite" data-example-count></ui-text><ui-disclosure summary="Add an example"><ui-stack><ui-combobox id="example-choice" data-add-example label="Choose a component example" placeholder="Search buttons, fields, dialogs…" disclosure clearable>${options}</ui-combobox><ui-button data-action="add" disabled>Add to preview</ui-button></ui-stack></ui-disclosure>
52
64
  <ui-grid columns="2" min="sm"><section $each="card of $cards" $key="$card.id"><looma-example-card ${cardFields.filter(name => name !== 'id').map(name => `from:${name.replace(/[A-Z]/g, letter => '-' + letter.toLowerCase())}="$card.${name}"`).join(' ')} from:identity="$card.id" loading="eager" removable></looma-example-card></section></ui-grid>
53
65
  <noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
54
66
  </ui-stack></section></div></ui-stack></div>
55
67
  <style>
68
+ [data-customizer-overview][hidden], [data-refinement-view][hidden] { display: none !important; }
56
69
  .customizer-layout { display: grid !important; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
57
70
  aside { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding-inline-end: var(--ui-space-2) !important; }
58
71
  .examples { min-inline-size: 0; }
72
+ .refinement-layout { display: grid !important; grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
73
+ [data-refinement-view] { scroll-margin-block-start: 5rem; }
59
74
  .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
75
  .palette-range > span { flex: 1; min-inline-size: var(--ui-space-5); }
61
76
  .palette-canvas { background: var(--ui-surface); }
62
77
  .palette-content { background: var(--ui-surface-raised); }
63
78
  .palette-action { background: var(--ui-accent); }
64
- @media (max-width: 64rem) { .customizer-layout { grid-template-columns: minmax(0, 1fr); } aside { position: static; max-block-size: none; } }
79
+ @media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { position: static; max-block-size: none; } }
65
80
  </style></template>`;
66
81
  }
@@ -1,6 +1,6 @@
1
1
  import { documentationTheme, themeEvent } from './theme-session.mjs';
2
2
  import { componentRegistry } from '@nextwebwg/html-next/runtime';
3
- import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, paletteFor, deriveTheme } from './theme.mjs';
3
+ import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme } from './theme.mjs';
4
4
 
5
5
  import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
6
6
 
@@ -19,6 +19,8 @@ export function connect(host) {
19
19
  let changed = false;
20
20
  let saving = false;
21
21
  let alive = true;
22
+ let selectedExample = '';
23
+ let refinementTrigger;
22
24
  const abort = new AbortController();
23
25
  const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
24
26
  const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
@@ -41,13 +43,21 @@ export function connect(host) {
41
43
  }
42
44
  };
43
45
  const paint = () => {
44
- const css = themeCSS(recipe);
45
- updateControl(root.querySelector('[data-palette]'), paletteFor(recipe));
46
+ const css = theme?.css || themeCSS(recipe);
47
+ for (const family of ['material','palette']) {
48
+ const state = refinementState(recipe, family), presets = family === 'material' ? materialPresets : themePalettes;
49
+ for (const select of root.querySelectorAll(`[data-refinement-preset="${family}"]`)) {
50
+ for (const option of select.options) if (presets[option.value]) option.textContent = presets[option.value].label + (state.id === option.value && state.customized ? ' · customized' : '');
51
+ updateControl(select, state.id);
52
+ }
53
+ componentRegistry.update(root.querySelector(`[data-action="restore-${family}"]`), { disabled: state.id === 'custom' || !state.customized });
54
+ }
55
+ componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
46
56
  const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
47
57
  const report = root.querySelector('[data-palette-adjustments]');
48
58
  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
59
  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 } }));
60
+ for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
51
61
  updateControl(root.querySelector('[data-css]'), css);
52
62
  };
53
63
  const show = (next, persist = true) => {
@@ -61,24 +71,38 @@ export function connect(host) {
61
71
  };
62
72
  const filterExamples = () => {
63
73
  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 });
74
+ let visible = 0;
75
+ for (const card of root.querySelectorAll('[data-example-card]')) {
76
+ const matches = words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
77
+ card.closest('section').hidden = !matches;
78
+ if (matches) visible++;
79
+ }
80
+ root.querySelector('[data-example-count]').textContent = visible ? `${visible} example${visible === 1 ? '' : 's'} in preview` : 'No previews match. Clear the filter or add another example.';
81
+ componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: !selectedExample || host.state.cards.some(item => item.id === selectedExample) });
82
+ };
83
+ const refinedRecipe = values => {
84
+ const presets = { ...recipe.presets };
85
+ for (const family of ['material', 'palette']) {
86
+ const state = refinementState(recipe, family);
87
+ if (!presets[family] && state.id !== 'custom') presets[family] = state.id;
88
+ }
89
+ return { ...recipe, presets, values };
73
90
  };
74
91
  const input = event => {
75
92
  if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
93
+ if (event.target.matches('[data-light-editor]')) {
94
+ try {
95
+ show(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
96
+ sayApplied('Light position remembered throughout your design system.');
97
+ } catch (error) { say(error.message, true); }
98
+ return;
99
+ }
76
100
  const control = event.target.closest?.('[data-token]');
77
101
  if (!control) return;
78
102
  const field = fields.get(control.dataset.token);
79
103
  const raw = event.detail?.value ?? control.value;
80
104
  const value = control.dataset.editorKind ? editorValue(field, raw) : String(raw);
81
- const next = { schemaVersion: 1, values: { ...recipe.values, [control.dataset.token]: value } };
105
+ const next = refinedRecipe({ ...recipe.values, [control.dataset.token]: value });
82
106
  try {
83
107
  recipe = validateRecipe(next);
84
108
  theme?.apply(recipe); changed = true;
@@ -99,12 +123,31 @@ export function connect(host) {
99
123
  if (!button || (button.dataset.action === 'save' && saving)) return;
100
124
  try {
101
125
  switch (button.dataset.action) {
126
+ case 'refine-material': case 'refine-palette': {
127
+ const family = button.dataset.action.slice(7);
128
+ refinementTrigger = button;
129
+ root.querySelector('[data-customizer-overview]').hidden = true;
130
+ for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = section.dataset.refinementView !== family;
131
+ const section = root.querySelector(`[data-refinement-view="${family}"]`);
132
+ section.focus(); section.scrollIntoView({ block: 'start' });
133
+ break;
134
+ }
135
+ case 'back':
136
+ for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = true;
137
+ root.querySelector('[data-customizer-overview]').hidden = false;
138
+ refinementTrigger?.focus(); break;
139
+ case 'restore-material': case 'restore-palette': {
140
+ const family = button.dataset.action.slice(8), state = refinementState(recipe, family);
141
+ if (state.id !== 'custom') { show(applyRefinement(recipe, family, state.id)); sayApplied(`${family === 'material' ? 'Material' : 'Palette'} preset restored; other theme decisions are unchanged.`); }
142
+ break;
143
+ }
102
144
  case 'add': {
103
- const select = root.querySelector('[data-add-example]');
104
- const option = select.selectedOptions[0];
145
+ const option = [...root.querySelectorAll('[data-add-example] option')].find(option => option.value === selectedExample);
146
+ if (!option) break;
105
147
  const selected = JSON.parse(option.dataset.example);
106
148
  const card = Object.fromEntries(['id','title','description','exampleTitle','src','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
107
149
  if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [...host.state.cards, card];
150
+ say(`${card.title} · ${card.exampleTitle} added to preview.`);
108
151
  view.requestAnimationFrame(() => { paint(); filterExamples(); }); break;
109
152
  }
110
153
  case 'css': {
@@ -144,7 +187,11 @@ export function connect(host) {
144
187
  sayApplied(`${field.label} units updated and remembered in this browser.`);
145
188
  } catch (error) { updateUnits(); say(error.message, true); }
146
189
  }
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.'); }
190
+ if (event.target.matches('[data-refinement-preset]')) {
191
+ const family = event.target.dataset.refinementPreset;
192
+ show(applyRefinement(recipe, family, event.target.value));
193
+ sayApplied(`${family === 'material' ? 'Material' : 'Coordinated palette'} preset applied and remembered in both modes.`);
194
+ }
148
195
  if (event.target.matches('[data-preset]')) { show(structuredClone(startingPoints[event.target.value])); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
149
196
  if (event.target === file && file.files[0]) {
150
197
  try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
@@ -157,9 +204,15 @@ export function connect(host) {
157
204
  const ready = event => {
158
205
  if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
159
206
  };
160
- const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); };
207
+ const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); view.requestAnimationFrame(filterExamples); };
208
+ const selectExample = event => {
209
+ if (!event.target.matches('[data-add-example]')) return;
210
+ selectedExample = event.detail.value ?? '';
211
+ filterExamples();
212
+ };
161
213
  root.addEventListener('looma-example-remove', remove);
162
214
  root.addEventListener('input', input);
215
+ root.addEventListener('value-change', selectExample);
163
216
  root.addEventListener('blur', blur, true);
164
217
  root.addEventListener('change', change);
165
218
  root.addEventListener('click', click);
@@ -177,5 +230,5 @@ export function connect(host) {
177
230
  root.querySelector('[data-action="save"]').hidden = false;
178
231
  }).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
179
232
  }
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); };
233
+ return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('value-change', selectExample); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
181
234
  }
package/docs/index.d.ts CHANGED
@@ -17,7 +17,7 @@ export interface DocumentationCatalog {
17
17
  resources?: { tag: string; package: string; resource: string }[]; components: DocumentationComponent[];
18
18
  }
19
19
  export interface DiscoveryRoot { directory: string; packageName: string; groups?: DocumentationGroup[] }
20
- export interface ThemeRecipe { schemaVersion: 1; values: Record<string, string> }
20
+ export interface ThemeRecipe { schemaVersion: 1; values: Record<string, string>; presets?: Partial<Record<'material' | 'palette', string>> }
21
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;
@@ -50,6 +50,9 @@ export function previewProjectDocumentation(input?: DocumentationInput & { port?
50
50
  export const themeFields: readonly ThemeField[];
51
51
  export const startingPoints: Record<string, ThemeRecipe>;
52
52
  export function defaultRecipe(): ThemeRecipe;
53
+ export const materialPresets: Record<string, { label: string; values: Record<string, string> }>;
54
+ export function applyRefinement(input: ThemeRecipe, family: 'material' | 'palette', id: string): ThemeRecipe;
55
+ export function refinementState(input: ThemeRecipe, family: 'material' | 'palette'): { id: string; customized: boolean };
53
56
  export function validateRecipe(input: unknown): ThemeRecipe;
54
57
  export interface ResolvedTheme { tokens: Record<string, string>; adjustments: { id: string; requested: string; resolved: string; reason: string }[] }
55
58
  export function deriveTheme(input: ThemeRecipe, options?: { mode?: 'light' | 'dark' }): ResolvedTheme;
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, deriveTheme, themeCSS } from './theme.mjs';
3
+ export { defaultRecipe, startingPoints, themeFields, validateRecipe, deriveTheme, themeCSS, materialPresets, applyRefinement, refinementState } from './theme.mjs';
4
4
  export { initializeDocumentation, buildProjectDocumentation, devProjectDocumentation, previewProjectDocumentation, readConfiguration } from './commands.mjs';
package/docs/render.mjs CHANGED
@@ -8,7 +8,7 @@ const literal = value => escape(value).replaceAll('{', '\\{');
8
8
  const code = value => `<code>${literal(value ?? '—')}</code>`;
9
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
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` : '');
11
+ export const componentPath = component => `/${component.group === 'editor' ? 'editor' : 'components'}/${component.navigationParent ?? component.tag}/` + (component.navigationParent ? `#${component.tag}-api` : '');
12
12
  export const previewPath = example => `/_looma/preview/${exampleKey(example.id)}/`;
13
13
 
14
14
  export function apiBody(api, prefix = '') {
package/docs/site.mjs CHANGED
@@ -96,9 +96,10 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
96
96
  const resourceByTag = new Map(combined.resources.map(resource => [resource.tag, resource]));
97
97
  for (const component of combined.components) {
98
98
  if (!component.navigationParent) {
99
- const page = path.join(content, 'components', groupDirectories.get(component.group), component.tag + '.md');
99
+ const section = component.group === 'editor' ? ['editor'] : ['components', groupDirectories.get(component.group)];
100
+ const page = path.join(content, ...section, component.tag + '.md');
100
101
  await write(page, referencePage(component, combined, relativeHref(page, card), base));
101
- generatedReferences.add(`components/${component.tag}.md`);
102
+ generatedReferences.add(`${component.group === 'editor' ? 'editor' : 'components'}/${component.tag}.md`);
102
103
  }
103
104
  for (const example of component.examples) {
104
105
  const key = exampleKey(example.id);
@@ -119,10 +120,12 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
119
120
  }
120
121
  const gallery = combined.groups.map(group => {
121
122
  const components = combined.components.filter(c => c.group === group.id && !c.navigationParent);
122
- return components.length ? `<section data-catalog-group="${escapeHtml(group.id)}">\n\n## ${escapeHtml(group.label)}\n\n<ui-grid columns="3" min="sm">${components.map(component => `<div data-category="${escapeHtml(component.group)}" data-catalog-search="${escapeHtml(`${component.tag} ${component.title} ${component.description}`)}">${cardMarkup(component.examples[0], base, component)}</div>`).join('')}</ui-grid>\n\n</section>` : '';
123
+ // A standalone opening tag makes this a Markdown HTML block, keeping native card wrappers inside Grid.
124
+ return components.length ? `<section data-catalog-group="${escapeHtml(group.id)}">\n\n## ${escapeHtml(group.label)}\n\n<ui-grid columns="3" min="sm">\n${components.map(component => `<div data-category="${escapeHtml(component.group)}" data-catalog-search="${escapeHtml(`${component.tag} ${component.title} ${component.description}`)}">${cardMarkup(component.examples[0], base, component)}</div>`).join('\n')}\n</ui-grid>\n\n</section>` : '';
123
125
  }).join('\n\n');
124
126
  await write(path.join(content, 'components/index.md'), `---\ntitle: Components\n---\n\n# Components\n\nExplore the live examples that also power your theme customizer.\n\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), card)}">\n<link rel="component" href="@threadlabs/looma/components/ui-grid/ui-grid.html">\n<link rel="component" href="${relativeHref(path.join(content, 'components/index.md'), path.join(generated, 'catalog-view.html'))}">\n\n<looma-catalog>\n\n${gallery}\n\n</looma-catalog>\n`);
125
127
  await write(path.join(content, 'index.md'), `---\ntitle: ${JSON.stringify(config.title ?? 'Your design system')}\n---\n\n# ${config.title ?? 'Your design system'}\n\nWeave your design system from Looma and your own HTML Next components.\n\n[Explore components](/components/) · [Customize your theme](/customize/)\n`);
128
+ if (combined.components.some(component => component.group === 'editor')) await write(path.join(content, 'editor.md'), `---\ntitle: Editor\nsidebar: false\n---\n\n# Editor\n\nWriting tools and editor components, separate from the general component reference.\n\n${combined.components.filter(component => component.group === 'editor' && !component.navigationParent).map(component => `- [${component.title}](${componentPath(component)})`).join('\n')}\n`);
126
129
  await write(path.join(content, 'design-system.md'), await readFile(own('README.md'), 'utf8'));
127
130
  if (config.content) {
128
131
  const authored = path.resolve(root, config.content);
@@ -134,20 +137,19 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
134
137
  let exists = false;
135
138
  try { await readFile(target); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
136
139
  // A project's landing page and footer are branding/content, never API overrides.
137
- if (exists && !['index.md', '_footer.md'].includes(path.relative(content, target))) throw new Error(`Authored/generated documentation collision: ${source} would replace ${target}. Add guidance under another path.`);
140
+ if (exists && !['index.md', 'editor.md', '_footer.md'].includes(path.relative(content, target))) throw new Error(`Authored/generated documentation collision: ${source} would replace ${target}. Add guidance under another path.`);
138
141
  await mkdir(path.dirname(target), { recursive: true }); await cp(source, target);
139
142
  }
140
143
  }
141
144
  const customizer = path.join(generated, 'customizer.html');
142
145
  await write(customizer, customizerResource({ catalog: combined, config, base }, config.recipe));
143
146
  const customizerPage = path.join(content, 'customize.md');
144
- await write(customizerPage, `---\ntitle: Customize\n---\n\n# Weave your design system\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
147
+ await write(customizerPage, `---\ntitle: Customize\nsidebar: false\noutline: false\nprevious: false\nnext: false\n---\n\n# Weave your design system\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
145
148
  await write(path.join(generated, 'catalog.json'), JSON.stringify(combined));
146
149
  const redirects = {};
147
150
  for (const component of combined.components) {
148
151
  const target = componentPath(component);
149
152
  redirects[`/components/${component.group}/${component.tag}/`] = target;
150
- if (component.group === 'editor') redirects[`/editor/${component.tag}/`] = target;
151
153
  if (component.navigationParent) redirects[`/parts/${component.tag}/`] = target;
152
154
  }
153
155
  return { root, config, base, catalog: combined, examples, generated, content, previews, redirects };
@@ -220,20 +222,29 @@ export async function buildDocumentation({ root = process.cwd(), config = {}, ca
220
222
 
221
223
  async function isolatePreviewScripts(site, { outDir, writeFile: writeOutput }) {
222
224
  const { build } = await tool('vite', site.root);
223
- for (const example of site.examples) {
224
- const htmlFile = path.join(outDir, '_looma/preview', exampleKey(example.id), 'index.html');
225
- let html = await readFile(htmlFile, 'utf8');
226
- const scripts = [...html.matchAll(/<script\b[^>]*type="module"[^>]*src="([^"]+)"[^>]*><\/script>/g)];
227
- if (!scripts.length) throw new Error(`No browser module found for isolated preview ${example.id}.`);
228
- for (const [tag, url] of scripts) {
229
- const input = path.join(outDir, url.slice(site.base.length));
230
- const bundle = await build({ configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry: input, name: 'LoomaPreview', formats: ['iife'] }, rollupOptions: { output: { codeSplitting: false } } } });
231
- const code = (Array.isArray(bundle) ? bundle[0] : bundle).output.find(output => output.type === 'chunk').code;
232
- const name = `${exampleKey(example.id)}.js`;
233
- await writeOutput('_looma/runtime/' + name, code);
234
- html = html.replace(tag, `<script src="${site.base}_looma/runtime/${name}" defer></script>`);
235
- }
236
- html = html.replace(/<link\b[^>]*rel="modulepreload"[^>]*>/g, '');
237
- await writeFile(htmlFile, html);
238
- }
225
+ // Preview entries and outputs are independent. Keep compiler memory bounded
226
+ // and retain the build lock until every in-flight bundle has settled.
227
+ const remaining = [...site.examples];
228
+ const results = await Promise.allSettled(Array.from({ length: Math.min(2, remaining.length) }, async () => {
229
+ try {
230
+ for (let example; (example = remaining.pop());) {
231
+ const htmlFile = path.join(outDir, '_looma/preview', exampleKey(example.id), 'index.html');
232
+ let html = await readFile(htmlFile, 'utf8');
233
+ const scripts = [...html.matchAll(/<script\b[^>]*type="module"[^>]*src="([^"]+)"[^>]*><\/script>/g)];
234
+ if (!scripts.length) throw new Error(`No browser module found for isolated preview ${example.id}.`);
235
+ for (const [tag, url] of scripts) {
236
+ const input = path.join(outDir, url.slice(site.base.length));
237
+ const bundle = await build({ configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry: input, name: 'LoomaPreview', formats: ['iife'] }, rollupOptions: { output: { codeSplitting: false } } } });
238
+ const code = (Array.isArray(bundle) ? bundle[0] : bundle).output.find(output => output.type === 'chunk').code;
239
+ const name = `${exampleKey(example.id)}.js`;
240
+ await writeOutput('_looma/runtime/' + name, code);
241
+ html = html.replace(tag, `<script src="${site.base}_looma/runtime/${name}" defer></script>`);
242
+ }
243
+ html = html.replace(/<link\b[^>]*rel="modulepreload"[^>]*>/g, '');
244
+ await writeFile(htmlFile, html);
245
+ }
246
+ } catch (error) { remaining.length = 0; throw error; }
247
+ }));
248
+ const failed = results.find(result => result.status === 'rejected');
249
+ if (failed) throw failed.reason;
239
250
  }
@@ -8,6 +8,7 @@ export function createThemeSession(view, { base, baseline, systemCSS }) {
8
8
  const key = `looma:documentation-theme:v1:${base}`;
9
9
  let original = validateRecipe(baseline);
10
10
  let selected;
11
+ let portableCSS = '';
11
12
  let storageAvailable = true;
12
13
  const style = view.document.createElement('style');
13
14
  style.dataset.loomaDocumentationTheme = '';
@@ -15,9 +16,10 @@ export function createThemeSession(view, { base, baseline, systemCSS }) {
15
16
  const read = value => { try { return value ? validateRecipe(JSON.parse(value)) : undefined; } catch { return undefined; } };
16
17
  try { selected = read(view.localStorage.getItem(key)); } catch { storageAvailable = false; }
17
18
  const paint = () => {
19
+ portableCSS = selected ? themeCSS(selected, systemCSS) : '';
18
20
  view.document.documentElement.toggleAttribute('data-looma-documentation-theme', Boolean(selected));
19
21
  style.textContent = selected ? themeCSS(selected, systemCSS, { scope: 'documentation' }) : '';
20
- view.dispatchEvent(new view.CustomEvent(themeEvent, { detail: { css: selected ? themeCSS(selected, systemCSS) : '' } }));
22
+ view.dispatchEvent(new view.CustomEvent(themeEvent, { detail: { css: portableCSS } }));
21
23
  };
22
24
  const persist = () => {
23
25
  try { if (selected) view.localStorage.setItem(key, JSON.stringify(selected)); else view.localStorage.removeItem(key); storageAvailable = true; } catch { storageAvailable = false; }
@@ -27,7 +29,7 @@ export function createThemeSession(view, { base, baseline, systemCSS }) {
27
29
  get recipe() { return structuredClone(selected ?? original); },
28
30
  get hasOverride() { return selected !== undefined; },
29
31
  get storageAvailable() { return storageAvailable; },
30
- get css() { return selected ? themeCSS(selected, systemCSS) : ''; },
32
+ get css() { return portableCSS; },
31
33
  apply(recipe) { selected = validateRecipe(recipe); persist(); },
32
34
  reset() { selected = undefined; persist(); return structuredClone(original); },
33
35
  baseline(recipe) { original = validateRecipe(recipe); },