@threadlabs/looma 0.34.0 → 0.35.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 (88) 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-select/ui-select.html +9 -7
  16. package/components/ui-switch/ui-switch.html +3 -0
  17. package/components/ui-textarea/ui-textarea.html +2 -1
  18. package/dist/index.js +24 -23
  19. package/docs/README.md +26 -2
  20. package/docs/catalog.json +566 -44
  21. package/docs/customizer-render.mjs +24 -11
  22. package/docs/customizer.js +67 -17
  23. package/docs/index.d.ts +4 -1
  24. package/docs/index.mjs +1 -1
  25. package/docs/render.mjs +1 -1
  26. package/docs/site.mjs +30 -20
  27. package/docs/theme-session.mjs +4 -2
  28. package/docs/theme.mjs +59 -7
  29. package/package.json +2 -2
  30. package/styles/ui-badge.css +11 -6
  31. package/styles/ui-button.css +29 -4
  32. package/styles/ui-card.css +9 -9
  33. package/styles/ui-checkbox.css +4 -2
  34. package/styles/ui-dialog.css +4 -0
  35. package/styles/ui-icon-button.css +22 -8
  36. package/styles/ui-input-group.css +4 -1
  37. package/styles/ui-input.css +2 -1
  38. package/styles/ui-light-editor.css +28 -0
  39. package/styles/ui-menu.css +2 -0
  40. package/styles/ui-popover.css +2 -0
  41. package/styles/ui-select.css +9 -7
  42. package/styles/ui-switch.css +3 -0
  43. package/styles/ui-textarea.css +2 -1
  44. package/theme-light.css +1 -0
  45. package/theme-system.css +43 -7
  46. package/tokens.css +4 -0
  47. package/vanilla/UiCard.d.ts +2 -1
  48. package/vanilla/UiCard.js +12 -12
  49. package/vanilla/UiLightEditor.d.ts +29 -0
  50. package/vanilla/UiLightEditor.js +102 -0
  51. package/vanilla/UiNumberInput.js +27 -20
  52. package/vanilla/index.js +1 -0
  53. package/vue/UiBadge.vue +29 -4
  54. package/vue/UiButton.vue +33 -4
  55. package/vue/UiCard.d.ts +8 -2
  56. package/vue/UiCard.vue +41 -17
  57. package/vue/UiCheckbox.vue +4 -2
  58. package/vue/UiDialog.vue +4 -0
  59. package/vue/UiIconButton.vue +26 -11
  60. package/vue/UiInput.vue +2 -1
  61. package/vue/UiInputGroup.vue +4 -1
  62. package/vue/UiLightEditor.d.ts +75 -0
  63. package/vue/UiLightEditor.js +2 -0
  64. package/vue/UiLightEditor.vue +402 -0
  65. package/vue/UiMenu.vue +2 -0
  66. package/vue/UiNumberInput.vue +32 -3
  67. package/vue/UiPopover.vue +2 -0
  68. package/vue/UiSelect.vue +16 -10
  69. package/vue/UiSwitch.vue +3 -0
  70. package/vue/UiTextarea.vue +2 -1
  71. package/vue/chunks/UiBadge.js +1 -1
  72. package/vue/chunks/UiButton.js +1 -1
  73. package/vue/chunks/UiCard.js +33 -5
  74. package/vue/chunks/UiCheckbox.js +1 -1
  75. package/vue/chunks/UiDialog.js +1 -1
  76. package/vue/chunks/UiIconButton.js +1 -1
  77. package/vue/chunks/UiInput.js +1 -1
  78. package/vue/chunks/UiInputGroup.js +1 -1
  79. package/vue/chunks/UiLightEditor.js +347 -0
  80. package/vue/chunks/UiMenu.js +1 -1
  81. package/vue/chunks/UiNumberInput.js +21 -8
  82. package/vue/chunks/UiPopover.js +1 -1
  83. package/vue/chunks/UiSelect.js +1 -1
  84. package/vue/chunks/UiSwitch.js +1 -1
  85. package/vue/chunks/UiTextarea.js +1 -1
  86. package/vue/components.css +306 -66
  87. package/vue/index.d.ts +1 -0
  88. 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,55 @@ 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')}<p>One coordinated palette generates readable light and dark ranges.</p><ui-button data-action="refine-palette">Refine palette</ui-button>`,
36
+ 'Material': `${renderPreset('material', 'overview')}<p>Shared material and light shape the whole design system.</p><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))].map(family => `<ui-disclosure summary="${family}"${family === 'Colour' ? ' open' : ''}><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve'].includes(item.id) && !['Colour','Material'].includes(family)).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="sm"><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
49
  <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">
50
+ <p role="status" aria-live="polite" data-message>Your theme is remembered in this browser. Repository files change only when you save.</p>
51
+ ${materialView}${paletteView}
52
+ <div class="customizer-layout" data-customizer-overview>
42
53
  <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
54
  ${families}
44
55
  <ui-cluster><ui-button data-action="css" variant="solid">Download CSS</ui-button><ui-button data-action="recipe">Download recipe</ui-button><ui-button data-action="import">Import recipe</ui-button><ui-button data-action="reset" variant="ghost">Reset changes</ui-button></ui-cluster>
45
56
  <input type="file" accept="application/json,.json" hidden data-recipe-file>
46
57
  <ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
47
58
  <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
59
  <ui-disclosure summary="Theme CSS"><ui-textarea data-css readonly rows="12" aria-label="Generated theme CSS" value="${escape(themeCSS(recipe))}"></ui-textarea></ui-disclosure>
50
60
  </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>
61
+ <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-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>
52
62
  <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
63
  <noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
54
64
  </ui-stack></section></div></ui-stack></div>
55
65
  <style>
66
+ [data-customizer-overview][hidden], [data-refinement-view][hidden] { display: none !important; }
56
67
  .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
68
  aside { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding-inline-end: var(--ui-space-2) !important; }
58
69
  .examples { min-inline-size: 0; }
70
+ .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; }
71
+ [data-refinement-view] { scroll-margin-block-start: 5rem; }
59
72
  .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
73
  .palette-range > span { flex: 1; min-inline-size: var(--ui-space-5); }
61
74
  .palette-canvas { background: var(--ui-surface); }
62
75
  .palette-content { background: var(--ui-surface-raised); }
63
76
  .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; } }
77
+ @media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { position: static; max-block-size: none; } }
65
78
  </style></template>`;
66
79
  }
@@ -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,35 @@ 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 ?? '';
74
+ const options = [...root.querySelectorAll('[data-add-example] option')];
69
75
  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;
76
+ const matches = options.filter(option => words.every(word => option.dataset.search.includes(word))).length;
71
77
  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 });
78
+ componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: !selectedExample || host.state.cards.some(item => item.id === selectedExample) });
79
+ };
80
+ const refinedRecipe = values => {
81
+ const presets = { ...recipe.presets };
82
+ for (const family of ['material', 'palette']) {
83
+ const state = refinementState(recipe, family);
84
+ if (!presets[family] && state.id !== 'custom') presets[family] = state.id;
85
+ }
86
+ return { ...recipe, presets, values };
73
87
  };
74
88
  const input = event => {
75
89
  if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
90
+ if (event.target.matches('[data-light-editor]')) {
91
+ try {
92
+ show(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
93
+ sayApplied('Light position remembered throughout your design system.');
94
+ } catch (error) { say(error.message, true); }
95
+ return;
96
+ }
76
97
  const control = event.target.closest?.('[data-token]');
77
98
  if (!control) return;
78
99
  const field = fields.get(control.dataset.token);
79
100
  const raw = event.detail?.value ?? control.value;
80
101
  const value = control.dataset.editorKind ? editorValue(field, raw) : String(raw);
81
- const next = { schemaVersion: 1, values: { ...recipe.values, [control.dataset.token]: value } };
102
+ const next = refinedRecipe({ ...recipe.values, [control.dataset.token]: value });
82
103
  try {
83
104
  recipe = validateRecipe(next);
84
105
  theme?.apply(recipe); changed = true;
@@ -99,12 +120,31 @@ export function connect(host) {
99
120
  if (!button || (button.dataset.action === 'save' && saving)) return;
100
121
  try {
101
122
  switch (button.dataset.action) {
123
+ case 'refine-material': case 'refine-palette': {
124
+ const family = button.dataset.action.slice(7);
125
+ refinementTrigger = button;
126
+ root.querySelector('[data-customizer-overview]').hidden = true;
127
+ for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = section.dataset.refinementView !== family;
128
+ const section = root.querySelector(`[data-refinement-view="${family}"]`);
129
+ section.focus(); section.scrollIntoView({ block: 'start' });
130
+ break;
131
+ }
132
+ case 'back':
133
+ for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = true;
134
+ root.querySelector('[data-customizer-overview]').hidden = false;
135
+ refinementTrigger?.focus(); break;
136
+ case 'restore-material': case 'restore-palette': {
137
+ const family = button.dataset.action.slice(8), state = refinementState(recipe, family);
138
+ if (state.id !== 'custom') { show(applyRefinement(recipe, family, state.id)); sayApplied(`${family === 'material' ? 'Material' : 'Palette'} preset restored; other theme decisions are unchanged.`); }
139
+ break;
140
+ }
102
141
  case 'add': {
103
- const select = root.querySelector('[data-add-example]');
104
- const option = select.selectedOptions[0];
142
+ const option = [...root.querySelectorAll('[data-add-example] option')].find(option => option.value === selectedExample);
143
+ if (!option) break;
105
144
  const selected = JSON.parse(option.dataset.example);
106
145
  const card = Object.fromEntries(['id','title','description','exampleTitle','src','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
107
146
  if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [...host.state.cards, card];
147
+ say(`${card.title} · ${card.exampleTitle} added to preview.`);
108
148
  view.requestAnimationFrame(() => { paint(); filterExamples(); }); break;
109
149
  }
110
150
  case 'css': {
@@ -144,7 +184,11 @@ export function connect(host) {
144
184
  sayApplied(`${field.label} units updated and remembered in this browser.`);
145
185
  } catch (error) { updateUnits(); say(error.message, true); }
146
186
  }
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.'); }
187
+ if (event.target.matches('[data-refinement-preset]')) {
188
+ const family = event.target.dataset.refinementPreset;
189
+ show(applyRefinement(recipe, family, event.target.value));
190
+ sayApplied(`${family === 'material' ? 'Material' : 'Coordinated palette'} preset applied and remembered in both modes.`);
191
+ }
148
192
  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
193
  if (event.target === file && file.files[0]) {
150
194
  try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
@@ -157,9 +201,15 @@ export function connect(host) {
157
201
  const ready = event => {
158
202
  if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
159
203
  };
160
- const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); };
204
+ const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); view.requestAnimationFrame(filterExamples); };
205
+ const selectExample = event => {
206
+ if (!event.target.matches('[data-add-example]')) return;
207
+ selectedExample = event.detail.value ?? '';
208
+ filterExamples();
209
+ };
161
210
  root.addEventListener('looma-example-remove', remove);
162
211
  root.addEventListener('input', input);
212
+ root.addEventListener('value-change', selectExample);
163
213
  root.addEventListener('blur', blur, true);
164
214
  root.addEventListener('change', change);
165
215
  root.addEventListener('click', click);
@@ -177,5 +227,5 @@ export function connect(host) {
177
227
  root.querySelector('[data-action="save"]').hidden = false;
178
228
  }).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
179
229
  }
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); };
230
+ 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
231
  }
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);
@@ -123,6 +124,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
123
124
  }).join('\n\n');
124
125
  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
126
  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`);
127
+ if (combined.components.some(component => component.group === 'editor')) await write(path.join(content, 'editor.md'), `---\ntitle: Editor\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
128
  await write(path.join(content, 'design-system.md'), await readFile(own('README.md'), 'utf8'));
127
129
  if (config.content) {
128
130
  const authored = path.resolve(root, config.content);
@@ -134,7 +136,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
134
136
  let exists = false;
135
137
  try { await readFile(target); exists = true; } catch (error) { if (error.code !== 'ENOENT') throw error; }
136
138
  // 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.`);
139
+ 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
140
  await mkdir(path.dirname(target), { recursive: true }); await cp(source, target);
139
141
  }
140
142
  }
@@ -147,7 +149,6 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
147
149
  for (const component of combined.components) {
148
150
  const target = componentPath(component);
149
151
  redirects[`/components/${component.group}/${component.tag}/`] = target;
150
- if (component.group === 'editor') redirects[`/editor/${component.tag}/`] = target;
151
152
  if (component.navigationParent) redirects[`/parts/${component.tag}/`] = target;
152
153
  }
153
154
  return { root, config, base, catalog: combined, examples, generated, content, previews, redirects };
@@ -220,20 +221,29 @@ export async function buildDocumentation({ root = process.cwd(), config = {}, ca
220
221
 
221
222
  async function isolatePreviewScripts(site, { outDir, writeFile: writeOutput }) {
222
223
  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
- }
224
+ // Preview entries and outputs are independent. Keep compiler memory bounded
225
+ // and retain the build lock until every in-flight bundle has settled.
226
+ const remaining = [...site.examples];
227
+ const results = await Promise.allSettled(Array.from({ length: Math.min(2, remaining.length) }, async () => {
228
+ try {
229
+ for (let example; (example = remaining.pop());) {
230
+ const htmlFile = path.join(outDir, '_looma/preview', exampleKey(example.id), 'index.html');
231
+ let html = await readFile(htmlFile, 'utf8');
232
+ const scripts = [...html.matchAll(/<script\b[^>]*type="module"[^>]*src="([^"]+)"[^>]*><\/script>/g)];
233
+ if (!scripts.length) throw new Error(`No browser module found for isolated preview ${example.id}.`);
234
+ for (const [tag, url] of scripts) {
235
+ const input = path.join(outDir, url.slice(site.base.length));
236
+ const bundle = await build({ configFile: false, logLevel: 'silent', build: { write: false, minify: true, lib: { entry: input, name: 'LoomaPreview', formats: ['iife'] }, rollupOptions: { output: { codeSplitting: false } } } });
237
+ const code = (Array.isArray(bundle) ? bundle[0] : bundle).output.find(output => output.type === 'chunk').code;
238
+ const name = `${exampleKey(example.id)}.js`;
239
+ await writeOutput('_looma/runtime/' + name, code);
240
+ html = html.replace(tag, `<script src="${site.base}_looma/runtime/${name}" defer></script>`);
241
+ }
242
+ html = html.replace(/<link\b[^>]*rel="modulepreload"[^>]*>/g, '');
243
+ await writeFile(htmlFile, html);
244
+ }
245
+ } catch (error) { remaining.length = 0; throw error; }
246
+ }));
247
+ const failed = results.find(result => result.status === 'rejected');
248
+ if (failed) throw failed.reason;
239
249
  }
@@ -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); },
package/docs/theme.mjs CHANGED
@@ -27,6 +27,12 @@ export const themeFields = [
27
27
  ['Material', 'surface-separation', 'Surface separation', '--ui-surface-separation', 'fraction', '0', 'One material offset, scaled by popup purpose and actual size: quiet tooltips through substantial dialogs.'],
28
28
  ['Material', 'translucency', 'Overlay translucency', '--ui-overlay-translucency', 'fraction', '0', 'How much background comes through floating surfaces.'],
29
29
  ['Material', 'edge-light', 'Edge light', '--ui-edge-light', 'fraction', '0', 'Light caught by surface edges, independent of depth.'],
30
+ ['Material', 'finish', 'Finish', '--ui-finish', 'fraction', '0', 'Matte through polished: how strongly the material catches a reflection.'],
31
+ ['Material', 'glow', 'Glow', '--ui-glow', 'fraction', '0', 'Soft reflected light beyond a surface, derived from its tone.'],
32
+ ['Material', 'light-x', 'Light horizontal position', '--ui-light-x', 'fraction', '0.5', 'The light source from left to right, relative to each surface.'],
33
+ ['Material', 'light-y', 'Light vertical position', '--ui-light-y', 'fraction', '0', 'The light source from top to bottom, relative to each surface.'],
34
+ ['Material', 'light-spread', 'Light spread', '--ui-light-spread', 'fraction', '0.5', 'A concentrated spot through broad illumination.'],
35
+ ['Material', 'light-strength', 'Light strength', '--ui-light-strength', 'fraction', '0.65', 'Shared illumination; each material and component role determines its response.'],
30
36
  ['Motion', 'response-time', 'Response time', '--ui-motion-fast', 'time', '120ms', 'Shared feedback pace; floating transitions take proportionally longer.'],
31
37
  ['Motion', 'response-curve', 'Response curve', '--ui-motion-ease', 'easing', 'cubic-bezier(0.2,0,0.2,1)', 'How movement accelerates and settles.'],
32
38
  ].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description, ...(bounds[id] ? { min: bounds[id][0], max: bounds[id][1] } : type === 'fraction' ? { min: 0, max: 1 } : {}) }));
@@ -35,14 +41,12 @@ export const defaultRecipe = () => ({ schemaVersion: 1, values: Object.fromEntri
35
41
  const starting = values => ({ schemaVersion: 1, values: { ...defaultRecipe().values, ...values } });
36
42
  export const startingPoints = {
37
43
  clear: defaultRecipe(),
38
- bookish: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-size': '42px', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18', 'edge-light': '1', 'response-time': '150ms', 'response-curve': 'ease' }),
44
+ bookish: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-size': '42px', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18', 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9', 'response-time': '150ms', 'response-curve': 'ease' }),
39
45
  wiki: starting({ action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-size': '34px', density: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
40
46
  editorial: starting({ action: '#182c24', canvas: '#eee9dc', surface: '#fffdf5', text: '#182c24', 'corner-roundness': '0px', 'outer-depth': '0', 'inner-depth': '0', 'body-font': 'Georgia, serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'line-weight': '2px', 'response-time': '90ms', 'response-curve': 'linear' }),
41
47
  night: starting({ action: '#a7b8ff', canvas: '#11141c', surface: '#202634', text: '#edf1fb', 'corner-roundness': '12px', 'outer-depth': '1.4', 'inner-depth': '0.45', translucency: '0.2', 'edge-light': '0.65', 'response-time': '210ms', 'response-curve': 'cubic-bezier(0.2,0.8,0.2,1)' }),
42
48
  garden: starting({ action: '#256342', canvas: '#eef5e7', surface: '#fcfff6', text: '#173121', 'corner-roundness': '22px', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2', 'control-size': '46px', 'scale-contrast': '1.24', density: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
43
49
  };
44
- // Preserve the public historical startingPoints.library import without a duplicate UI preset.
45
- Object.defineProperty(startingPoints, 'library', { value: startingPoints.bookish });
46
50
  export const startingPointLabels = { clear: 'Clear', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
47
51
  const paletteKeys = ['action', 'canvas', 'surface', 'text'];
48
52
  // Palette choices move only the shared color seeds; size, material and motion stay independent.
@@ -51,6 +55,33 @@ export const themePalettes = Object.fromEntries([
51
55
  ['editorial', 'Ink & parchment'], ['night', 'Midnight'], ['garden', 'Forest & cream'],
52
56
  ].map(([id, label]) => [id, { label, values: Object.fromEntries(paletteKeys.map(key => [key, startingPoints[id].values[key]])) }]));
53
57
 
58
+ const materialKeys = themeFields.filter(field => field.family === 'Material').map(field => field.id);
59
+ const materialDefaults = defaultRecipe().values;
60
+ const material = (label, overrides) => ({ label, values: Object.fromEntries(materialKeys.map(key => [key, overrides[key] ?? materialDefaults[key]])) });
61
+ export const materialPresets = {
62
+ matte: material('Matte', { 'outer-depth': '0', 'inner-depth': '0' }),
63
+ soft: material('Soft', { 'outer-depth': '0.6', 'inner-depth': '0.3', 'edge-light': '0.3', finish: '0.2', 'light-spread': '0.8' }),
64
+ polished: material('Polished', { 'outer-depth': '1', 'inner-depth': '1', 'edge-light': '1', finish: '0.85', glow: '0.3', 'light-spread': '0.3', 'light-strength': '0.9' }),
65
+ glass: material('Frosted glass', startingPoints.bookish.values),
66
+ };
67
+ const refinementPresets = family => family === 'material' ? materialPresets : family === 'palette' ? themePalettes : undefined;
68
+
69
+ /** Preset provenance is optional recipe data, never a second source of theme values. */
70
+ export function refinementState(input, family) {
71
+ const recipe = validateRecipe(input), presets = refinementPresets(family);
72
+ if (!presets) throw new Error('Unknown refinement family.');
73
+ const matches = preset => Object.entries(preset.values).every(([key, value]) => recipe.values[key] === value);
74
+ const id = recipe.presets?.[family] ?? Object.keys(presets).find(id => matches(presets[id])) ?? 'custom';
75
+ return { id, customized: id !== 'custom' && !matches(presets[id]) };
76
+ }
77
+
78
+ /** Apply or restore one family without changing the rest of the design system. */
79
+ export function applyRefinement(input, family, id) {
80
+ const recipe = validateRecipe(input), preset = refinementPresets(family)?.[id];
81
+ if (!preset) throw new Error('Unknown refinement preset.');
82
+ return validateRecipe({ ...recipe, values: { ...recipe.values, ...preset.values }, presets: { ...recipe.presets, [family]: id } });
83
+ }
84
+
54
85
  /** Identify a coordinated palette without coupling it to the rest of a recipe. */
55
86
  export function paletteFor(input) {
56
87
  const { values } = validateRecipe(input);
@@ -111,19 +142,28 @@ export function validateRecipe(input) {
111
142
  if (number !== undefined && (number < min || number > max)) throw new Error(`${field.label}: choose a value between ${min} and ${max}${field.type.includes('length') ? 'px (or equivalent)' : field.type === 'time' ? 'ms' : ''}.`);
112
143
  }
113
144
  }
114
- return { schemaVersion: 1, values };
145
+ if (input.presets !== undefined && (!input.presets || typeof input.presets !== 'object' || Array.isArray(input.presets) || Object.entries(input.presets).some(([family, id]) => !Object.hasOwn(refinementPresets(family) ?? {}, id)))) throw new Error('Unknown refinement preset.');
146
+ return { schemaVersion: 1, values, ...(input.presets === undefined ? {} : { presets: { ...input.presets } }) };
115
147
  }
116
148
 
117
149
  /** Match presets by their complete semantic recipe, including locally saved themes. */
118
150
  export function startingPointFor(input) {
119
- const recipe = JSON.stringify(validateRecipe(input));
120
- return Object.entries(startingPoints).find(([, value]) => JSON.stringify(value) === recipe)?.[0] ?? 'custom';
151
+ const recipe = JSON.stringify(validateRecipe(input).values);
152
+ return Object.entries(startingPoints).find(([, value]) => JSON.stringify(value.values) === recipe)?.[0] ?? 'custom';
121
153
  }
122
154
 
123
155
  const rgb = hex => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
124
156
  const mix = (a, b, weight) => '#' + rgb(a).map((n, i) => Math.round(n * weight + rgb(b)[i] * (1 - weight)).toString(16).padStart(2, '0')).join('');
125
157
  const luminance = hex => rgb(hex).map(n => { const s = n / 255; return s <= .04045 ? s / 12.92 : ((s + .055) / 1.055) ** 2.4; }).reduce((sum, n, i) => sum + n * [.2126, .7152, .0722][i], 0);
126
158
  const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + .05) / (Math.min(luminance(a), luminance(b)) + .05);
159
+ // The brightest reflection stop is itself readable. Interpolating it over a readable
160
+ // fill cannot wash out the label, even at maximum finish and light strength.
161
+ function reflection(fill, text) {
162
+ const labels = Array.isArray(text) ? text : [text];
163
+ let amount = .32;
164
+ while (amount > 0 && labels.some(label => contrast(mix('#ffffff', fill, amount), label) < 4.5)) amount = Math.max(0, amount - .005);
165
+ return mix('#ffffff', fill, amount);
166
+ }
127
167
  const foreground = (fill, light, dark) => [light, dark, '#ffffff', '#000000'].sort((a, b) => contrast(fill, b) - contrast(fill, a))[0];
128
168
 
129
169
  /** Find the nearest mixture with a known readable endpoint, including every supplied surface. */
@@ -164,6 +204,7 @@ export function deriveTheme(input, { mode } = {}) {
164
204
  tokens[`--ui-${tone}`] = fill;
165
205
  tokens[`--ui-on-${tone}`] = foreground(fill, '#ffffff', '#000000');
166
206
  tokens[`--ui-${tone}-on-strong`] = tokens[`--ui-on-${tone}`];
207
+ tokens[`--ui-${tone}-reflection`] = reflection(fill, tokens[`--ui-on-${tone}`]);
167
208
  tokens[`--ui-${tone}-hover`] = mix(fill, ink, .86);
168
209
  tokens[`--ui-${tone}-active`] = mix(fill, ink, .74);
169
210
  tokens[`--ui-${tone}-soft`] = mix(fill, tokens['--ui-surface-raised'], .08);
@@ -177,6 +218,11 @@ export function deriveTheme(input, { mode } = {}) {
177
218
  tokens['--ui-text'] = readableColour(textSeed, readingSurfaces, 4.5, ink);
178
219
  tokens['--ui-text-secondary'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .72), readingSurfaces, 4.5, ink);
179
220
  tokens['--ui-text-muted'] = tokens['--ui-text-secondary'];
221
+ for (const [state, amount] of [['surface', .06], ['hover', .1], ['active', .14]]) {
222
+ tokens[`--ui-neutral-${state}`] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], amount), [tokens['--ui-text']], 4.5, paper);
223
+ }
224
+ tokens['--ui-surface-reflection'] = reflection(tokens['--ui-surface-raised'], [tokens['--ui-text'], tokens['--ui-text-secondary'], ...Object.keys(tones).map(tone => tokens[`--ui-${tone}-subtle-text`])]);
225
+ tokens['--ui-neutral-reflection'] = reflection(tokens['--ui-neutral-surface'], tokens['--ui-text']);
180
226
  tokens['--ui-selection-surface'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .1);
181
227
  tokens['--ui-selection-text'] = tokens['--ui-accent-subtle-text'];
182
228
  tokens['--ui-selection-border'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .2);
@@ -212,8 +258,14 @@ export function themeCSS(input, systemCSS = '', { scope = 'portable' } = {}) {
212
258
  else geometry.push(` ${name}: ${value};`);
213
259
  }
214
260
  const geometrySelector = scope === 'documentation' ? ':root[data-looma-documentation-theme]' : ':root';
261
+ const chevron = color => `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='${color.replace('#', '%23')}' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")`;
262
+ const boundary = mode => scope === 'documentation'
263
+ ? `:root[data-looma-documentation-theme][data-theme="${mode}"], :root[data-looma-documentation-theme] [data-theme="${mode}"]`
264
+ : `[data-theme="${mode}"]`;
265
+ const chevrons = `${geometrySelector} { --ui-select-chevron: ${chevron(light['--ui-text-secondary'])}; }\n@media (prefers-color-scheme: dark) { ${geometrySelector}:not([data-theme]) { --ui-select-chevron: ${chevron(dark['--ui-text-secondary'])}; } }\n${boundary('light')} { --ui-select-chevron: ${chevron(light['--ui-text-secondary'])}; }\n${boundary('dark')} { --ui-select-chevron: ${chevron(dark['--ui-text-secondary'])}; }`;
266
+
215
267
  // Resolve colors at their point of use, so a nearer mode boundary wins independently
216
268
  // of the selector specificity needed to restore an early documentation theme.
217
269
  // Keep the first comment stable: the local writer recognizes its generated files by this marker.
218
- return `/* Looma shared semantic theme. */\n/* Always includes light and dark.\n Set data-theme="light" or "dark" on any element to fix that subtree's mode.\n Leave the root attribute unset to follow the system; a switch can set/remove it. */\n${systemCSS}\n${geometrySelector} {\n${geometry.join('\n')}\n}\n${selector} {\n${palette.join('\n')}\n}\n:root:not([data-theme]) { color-scheme: light dark; }\n[data-theme="light"] { color-scheme: light; }\n[data-theme="dark"] { color-scheme: dark; }\n@media (prefers-reduced-motion: reduce) {\n ${selector} { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n`;
270
+ return `/* Looma shared semantic theme. */\n/* Always includes light and dark.\n Set data-theme="light" or "dark" on any element to fix that subtree's mode.\n Leave the root attribute unset to follow the system; a switch can set/remove it. */\n${systemCSS}\n${geometrySelector} {\n${geometry.join('\n')}\n}\n${selector} {\n${palette.join('\n')}\n}\n${chevrons}\n:root:not([data-theme]) { color-scheme: light dark; }\n[data-theme="light"] { color-scheme: light; }\n[data-theme="dark"] { color-scheme: dark; }\n@media (prefers-reduced-motion: reduce) {\n ${selector} { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n@media (prefers-reduced-transparency: reduce), (forced-colors: active) {\n ${selector} { --ui-finish: 0; --ui-glow: 0; }\n}\n`;
219
271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -127,7 +127,7 @@
127
127
  "@tiptap/pm": ">=2 <3",
128
128
  "@tiptap/vue-3": ">=2 <3",
129
129
  "vue": "^3.5.43",
130
- "markupress": "^0.8.3",
130
+ "markupress": "^0.8.4",
131
131
  "@nextwebwg/htmlkit": "1.0.0-alpha.69",
132
132
  "vite": "^8.3.4"
133
133
  },