@threadlabs/looma 0.37.6 → 0.38.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 (45) hide show
  1. package/components/ui-button/ui-button.html +20 -5
  2. package/components/ui-combobox/ui-combobox.html +24 -3
  3. package/components/ui-combobox/ui-combobox.js +18 -3
  4. package/components/ui-icon-button/ui-icon-button.html +17 -8
  5. package/components/ui-menu-item/ui-menu-item.html +6 -1
  6. package/components/ui-select/ui-select.html +12 -0
  7. package/dist/index.js +1 -1
  8. package/docs/README.md +23 -10
  9. package/docs/catalog.json +78 -31
  10. package/docs/customizer-render.mjs +36 -20
  11. package/docs/customizer.js +76 -25
  12. package/docs/documentation.css +2 -0
  13. package/docs/palette.mjs +3 -3
  14. package/docs/site.mjs +1 -1
  15. package/docs/theme-session.mjs +1 -0
  16. package/docs/theme.mjs +11 -10
  17. package/package.json +2 -2
  18. package/styles/ui-button.css +19 -9
  19. package/styles/ui-combobox.css +40 -2
  20. package/styles/ui-icon-button.css +12 -2
  21. package/styles/ui-menu-item.css +6 -1
  22. package/styles/ui-select.css +9 -0
  23. package/theme-system.css +7 -2
  24. package/vanilla/UiButton.js +1 -1
  25. package/vanilla/UiCombobox.d.ts +1 -0
  26. package/vanilla/UiCombobox.js +38 -37
  27. package/vanilla/UiIconButton.d.ts +1 -1
  28. package/vanilla/UiIconButton.js +1 -1
  29. package/vanilla/UiSelect.d.ts +1 -0
  30. package/vanilla/UiSelect.js +7 -7
  31. package/vue/UiButton.vue +30 -12
  32. package/vue/UiCombobox.d.ts +6 -0
  33. package/vue/UiCombobox.vue +70 -6
  34. package/vue/UiIconButton.d.ts +2 -2
  35. package/vue/UiIconButton.vue +23 -18
  36. package/vue/UiMenuItem.vue +6 -1
  37. package/vue/UiSelect.d.ts +6 -0
  38. package/vue/UiSelect.vue +30 -1
  39. package/vue/chunks/UiButton.js +4 -4
  40. package/vue/chunks/UiCombobox.js +72 -46
  41. package/vue/chunks/UiIconButton.js +6 -7
  42. package/vue/chunks/UiMenuItem.js +1 -1
  43. package/vue/chunks/UiSelect.js +27 -2
  44. package/vue/components.css +126 -25
  45. package/vue/editor/index.js +2 -3
@@ -2,7 +2,7 @@ import { googleFonts, systemFont } from './fonts.mjs';
2
2
  import { escapeHtml as escape } from './catalog.mjs';
3
3
  import { componentPath, previewPath } from './render.mjs';
4
4
  import { valueEditor } from './value-editors.mjs';
5
- import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState, themeVariations } from './theme.mjs';
5
+ import { defaultRecipe, themeFields, startingPoints, startingPointLabels, startingPointFor, deriveTheme, applyRefinement, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState, themeVariations } from './theme.mjs';
6
6
 
7
7
  export function customizerResource(site, input = defaultRecipe()) {
8
8
  const recipe = validateRecipe(input);
@@ -20,8 +20,8 @@ export function customizerResource(site, input = defaultRecipe()) {
20
20
  const common = `size="sm" data-token="${field.id}" label="${escape(field.label)}"`;
21
21
  let control;
22
22
  if (field.type === 'font') {
23
- const selected = recipe.fonts?.[field.id] ?? (value === systemFont ? 'system' : 'custom');
24
- control = `<ui-combobox id="theme-${field.id}" data-font-picker="${field.id}" label="${escape(field.label)}" value="${escape(selected)}" placeholder="Search Google Fonts…" disclosure><option value="system">System font</option><option value="custom" disabled>Custom font stack</option>${Object.values(googleFonts).map(font => `<option value="${escape(font.family)}" data-description="Google Fonts · ${font.category}">${escape(font.family)}</option>`).join('')}</ui-combobox><ui-text variant="${field.id === 'display-font' ? 'heading' : 'body'}">Frodo’s quarterly review · Aa 0123</ui-text><ui-text variant="caption" data-font-status="${field.id}" role="status">System fonts need no download. Google selections load in this preview and travel in your CSS.</ui-text><ui-disclosure summary="Custom font stack"><ui-input id="theme-${field.id}-custom" ${common} value="${escape(value)}" aria-label="${escape(field.label)} stack"></ui-input><ui-text variant="caption">For an already loaded or self-hosted font.</ui-text></ui-disclosure>`;
23
+ const selected = field.id === 'display-font' && value === recipe.values['body-font'] ? 'inherit' : recipe.fonts?.[field.id] ?? (value === systemFont ? 'system' : 'custom');
24
+ control = `<ui-combobox id="theme-${field.id}" data-font-picker="${field.id}" label="${escape(field.label)}" value="${escape(selected)}" placeholder="Search Google Fonts…" disclosure>${field.id === 'display-font' ? '<option value="inherit">Same as main font</option>' : ''}<option value="system">System font</option><option value="custom" disabled>Custom font stack</option>${Object.values(googleFonts).map(font => `<option value="${escape(font.family)}" data-description="Google Fonts · ${font.category}">${escape(font.family)}</option>`).join('')}</ui-combobox><ui-text variant="${field.id === 'display-font' ? 'heading' : 'body'}">Frodo’s quarterly review · Aa 0123</ui-text><ui-text variant="caption" data-font-status="${field.id}" role="status">System fonts need no download. Google selections load in this preview and travel in your CSS.</ui-text><ui-disclosure summary="Custom font stack"><ui-input id="theme-${field.id}-custom" ${common} value="${escape(value)}" aria-label="${escape(field.label)} stack"></ui-input><ui-text variant="caption">For an already loaded or self-hosted font.</ui-text></ui-disclosure>`;
25
25
  } else if (field.type === 'choice') {
26
26
  control = `<ui-select id="theme-${field.id}" ${common} value="${escape(value)}">${Object.entries(field.choices).map(([id,label]) => `<option value="${id}">${escape(label)}</option>`).join('')}</ui-select>`;
27
27
  } else if (field.type === 'easing') {
@@ -35,47 +35,59 @@ export function customizerResource(site, input = defaultRecipe()) {
35
35
  }
36
36
  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>`;
37
37
  };
38
+ const swatches = tokens => `<span class="palette-swatches" aria-hidden="true">${['--ui-accent','--ui-neutral-surface','--ui-surface','--ui-text'].map(token => `<span style="background:${escape(tokens ? tokens[token] : `var(${token})`)}"></span>`).join('')}</span>`;
39
+ const choice = (attributes, label, value, options, content = '') => `<ui-combobox ${attributes} data-choice label="${escape(label)}" value="${escape(value)}" disclosure open-on-focus>${options}${content}</ui-combobox>`;
38
40
  const renderPreset = (family, location) => {
39
41
  const presets = family === 'material' ? materialPresets : themePalettes;
40
42
  const state = refinementState(recipe, family), label = family === 'material' ? 'Material' : 'Palette';
41
- 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>`;
43
+ const origin = startingPoints[recipe.origin ?? startingPointFor(recipe)];
44
+ const muted = origin && themeFields.filter(field => field.family === (family === 'palette' ? 'Colour' : 'Material')).every(field => origin.values[field.id] === recipe.values[field.id]);
45
+ const options = `<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('')}`;
46
+ const preview = family === 'palette' ? `<span slot="start" data-selected-palette>${swatches()}</span><template slot="option"><span class="palette-option">${Object.keys(presets).map(id => `<template $if="$option.value = '${id}'">${swatches(deriveTheme(applyRefinement(recipe, 'palette', id), { mode: 'light' }).tokens)}</template>`).join('')}<span>{$option.label}</span></span></template>` : '';
47
+ return choice(`id="${location}-${family}" data-refinement-preset="${family}"${muted ? ' muted' : ''}`, `${label} preset`, state.id, options, preview);
42
48
  };
43
- 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>${documentationCard('ui-text', '/02-variants')}</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>`;
49
+ const paletteDetails = `<ui-grid columns="2" gap="s" style="--ui-grid-min: 8rem">${['light', 'dark'].map(mode => `<ui-card data-theme="${mode}" padding="sm"><ui-stack gap="s"><ui-text variant="eyebrow">${mode === 'light' ? 'Light' : 'Dark'}</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="heading" as="h3">Fellowship</ui-text><ui-text>There and back.</ui-text><ui-text variant="caption">Second breakfast at nine.</ui-text><ui-button size="sm" preset="primary">Accent</ui-button><ui-button size="sm" preset="standard">Neutral</ui-button><ui-button size="sm" variant="outline" tone="accent">Outline</ui-button></ui-stack></ui-card>`).join('')}</ui-grid><ui-text variant="caption" data-palette-adjustments role="status" aria-live="polite"></ui-text><ui-disclosure summary="Resolved palette values"><ui-text variant="caption" data-palette-adjustment-details></ui-text></ui-disclosure>`;
50
+ const paletteRelationships = `<ui-stack gap="s"><ui-text variant="heading" as="h3">How should the colors work together?</ui-text>${[
51
+ ['preserve', 'Keep my colors', 'Keep the base colors as chosen. Warmth and intensity still refine the result.'],
52
+ ['neighboring', 'Related colors', 'Tint neutral surfaces with a neighboring hue for a closely coordinated palette.'],
53
+ ['complementary', 'Contrasting colors', 'Tint neutral surfaces toward the opposite hue for warm–cool contrast.'],
54
+ ].map(([id,label,description]) => `<ui-button variant="card" tone="neutral" data-action="palette-relationship" data-relationship="${id}" aria-pressed="${recipe.values['palette-relationship'] === id}"><ui-stack gap="xs"><ui-text variant="label">${label}</ui-text><span class="palette-range" data-relationship-preview="${id}" aria-hidden="true"><span class="palette-canvas"></span><span class="palette-content"></span><span class="palette-action"></span></span><ui-text variant="caption">${description}</ui-text></ui-stack><span slot="action" data-relationship-selected="${id}"${recipe.values['palette-relationship'] === id ? '' : ' hidden'}><ui-icon name="check"></ui-icon></span></ui-button>`).join('')}</ui-stack>`;
44
55
  const supplements = {
45
- '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>`,
56
+ 'Colour': `${renderPreset('palette', 'overview')}<ui-text variant="caption">Colors only, in light and dark. Starting points also set type, shape, material and motion.</ui-text><ui-button data-action="refine-palette">Refine palette</ui-button>`,
46
57
  '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>`,
47
58
  'Motion': `<ui-text variant="caption">Open and close this real documentation example to feel the response pace and curve. Reduced motion follows your system preference.</ui-text>${documentationCard('ui-popover')}`,
48
- 'Size & spacing': `<ui-text variant="caption">Shared size ladder: buttons, fields and swatches align at each step.</ui-text><ui-stack>${['sm','md','lg'].map((size,index) => `<ui-stack gap="xs" data-size-sample="${size}"><ui-text variant="caption">${['Small','Medium','Large'][index]}<span data-size-readout></span></ui-text><ui-cluster><ui-button size="${size}">Action</ui-button><ui-input size="${size}" style="--ui-input-inline-size: 10ch" label="${size} size sample" value="Frodo"></ui-input><ui-color-picker size="${size}" label="${size} swatch sample"></ui-color-picker></ui-cluster></ui-stack>`).join('')}</ui-stack><div class="spacing-samples" data-spacing-samples role="img" aria-label="Spacing progression"><span></span><span></span><span></span><span></span></div><ui-disclosure summary="How the scale grows"><ui-stack><p>Control roominess relates every field and button to base text. Size ratio separates small from large. Visible proportions stay the same on touch; hit areas are a separate design choice. Spacing and its curve shape page rhythm independently.</p>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
59
+ 'Size & spacing': `<ui-text variant="caption">Shared size ladder: buttons, fields and swatches align at each step.</ui-text><ui-stack>${['sm','md','lg'].map((size,index) => `<ui-stack gap="xs" data-size-sample="${size}"><ui-text variant="caption">${['Small','Medium','Large'][index]}<span data-size-readout></span></ui-text><ui-cluster><ui-button size="${size}">Action</ui-button><ui-input size="${size}" style="--ui-input-inline-size: 10ch" label="${size} size sample" value="Frodo"></ui-input><ui-color-picker size="${size}" label="${size} swatch sample"></ui-color-picker></ui-cluster></ui-stack>`).join('')}</ui-stack><div class="spacing-samples" data-spacing-samples role="img" aria-label="Spacing progression"><span></span><span></span><span></span><span></span></div><ui-disclosure summary="How the scale grows"><ui-stack><ui-text>Control roominess relates every field and button to base text. Size ratio separates small from large. Visible proportions stay the same on touch; hit areas are a separate design choice. Spacing and its curve shape page rhythm independently.</ui-text>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
49
60
  };
50
61
  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('');
51
- 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>`;
62
+ 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="l"><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-stack gap="s"><ui-text variant="heading" as="h2">${family === 'material' ? 'Refine material & light' : 'Refine your palette'}</ui-text><ui-text>These are advanced refinements of the same persistent theme. Every change applies throughout your documentation.</ui-text></ui-stack>${renderPreset(family, 'refinement')}${content}</ui-stack></section>`;
52
63
  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-cluster><ui-button size="sm" data-action="light-top">Top light</ui-button><ui-button size="sm" data-action="light-left">Upper left</ui-button><ui-button size="sm" data-action="light-center">Centered</ui-button></ui-cluster><ui-text variant="caption" data-material-dependencies role="status"></ui-text>${documentationCard('ui-card','/04-expressive-surface')}<ui-disclosure summary="Try floating surfaces"><ui-stack>${documentationCard('ui-tooltip')}${documentationCard('ui-popover')}${documentationCard('ui-dialog')}</ui-stack></ui-disclosure></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>`);
53
- 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 palette, then shift warmth, intensity or the relationship of surface hues. Both modes keep one identity, with readable roles derived automatically.</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' && field.type !== 'colour').map(renderField).join('')}<ui-disclosure summary="Precise color seeds"><ui-stack>${themeFields.filter(field => field.type === 'colour').map(renderField).join('')}</ui-stack></ui-disclosure></ui-stack></div>`);
64
+ const paletteView = refinement('palette', `<div class="refinement-layout"><div class="palette-preview"><ui-stack>${paletteDetails}</ui-stack></div><ui-stack><ui-text variant="heading" as="h3">Base colors</ui-text><ui-text variant="caption">The colors your theme’s light and dark palettes are built from.</ui-text>${themeFields.filter(field => field.type === 'colour').map(renderField).join('')}${themeFields.filter(field => field.family === 'Colour' && field.type !== 'colour' && field.id !== 'palette-relationship').map(renderField).join('')}${paletteRelationships}</ui-stack></div>`);
54
65
  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('');
55
66
  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','ui-checkbox'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
56
67
  return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
68
+ <state name="previews" 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>
57
69
  <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>
58
70
  </defs>
59
71
  <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 ?? '')}">
60
72
  <ui-stack gap="l"><ui-text>Choose a starting point, refine shared decisions, and see them on real documentation examples.</ui-text>
61
73
  <ui-text variant="caption" role="status" aria-live="polite" data-message>Changes are remembered across the documentation.</ui-text>
62
- <ui-cluster><ui-text variant="label" data-theme-origin></ui-text><ui-button size="sm" data-action="undo" disabled>Undo</ui-button><ui-button size="sm" data-action="redo" disabled>Redo</ui-button></ui-cluster>
63
- <ui-disclosure summary="Explore nearby styles"><ui-stack><ui-text variant="caption">Preview a small change before applying it. Locks preserve your selected families.</ui-text><ui-cluster>${[['palette','Palette'],['material','Material'],['type','Type & spacing']].map(([id,label]) => `<ui-checkbox size="sm" data-family-lock="${id}">Lock ${label}</ui-checkbox>`).join('')}</ui-cluster><ui-form-field><label slot="label" for="nearby-style">Nearby style</label><ui-select id="nearby-style" data-variation-choice>${Object.entries(themeVariations).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}</ui-select></ui-form-field><ui-button data-action="preview-variation">Preview variation</ui-button><div data-variation-confirm hidden><ui-stack><ui-text data-variation-description role="status"></ui-text><ui-cluster><ui-button preset="primary" data-action="apply-variation">Apply variation</ui-button><ui-button data-action="cancel-variation">Cancel preview</ui-button></ui-cluster></ui-stack></div></ui-stack></ui-disclosure>
74
+ <ui-stack gap="xs"><ui-text variant="eyebrow">Theme changes</ui-text><ui-cluster><ui-button size="sm" data-action="undo" disabled>Undo</ui-button><ui-button size="sm" data-action="redo" disabled>Redo</ui-button><ui-button size="sm" data-action="reset" title="Restore this theme’s starting values">Reset</ui-button></ui-cluster></ui-stack>
75
+ <ui-disclosure summary="Explore nearby styles"><ui-stack><ui-text variant="caption">Preview a small change before applying it. Locks preserve your selected families.</ui-text><ui-cluster>${[['palette','Palette'],['material','Material'],['type','Type & spacing']].map(([id,label]) => `<ui-checkbox size="sm" data-family-lock="${id}">Lock ${label}</ui-checkbox>`).join('')}</ui-cluster>${choice(`id="nearby-style" data-variation-choice data-selected-value="${Object.keys(themeVariations)[0]}"`, 'Nearby style', Object.keys(themeVariations)[0], Object.entries(themeVariations).map(([id,label]) => `<option value="${id}">${label}</option>`).join(''))}<ui-button data-action="preview-variation">Preview variation</ui-button><div data-variation-confirm hidden><ui-stack><ui-text data-variation-description role="status"></ui-text><ui-cluster><ui-button preset="primary" data-action="apply-variation">Apply variation</ui-button><ui-button data-action="cancel-variation">Cancel preview</ui-button></ui-cluster></ui-stack></div></ui-stack></ui-disclosure>
64
76
  ${materialView}${paletteView}
65
77
  <div class="customizer-layout" data-customizer-overview>
66
- <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>
78
+ <aside aria-label="Theme controls"><ui-stack>${choice('id="theme-start" data-preset', 'Starting point', startingPointFor(recipe), `<option value="custom" disabled>Your theme</option>${Object.entries(startingPointLabels).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}`)}
67
79
  ${supplements.Colour}${supplements.Material}
68
80
  <ui-disclosure summary="Type, size & motion"><ui-stack>${families}</ui-stack></ui-disclosure>
69
- <ui-button data-action="reset" variant="ghost">Reset changes</ui-button>
70
81
  <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>
71
82
  <input type="file" accept="application/json,.json" hidden data-recipe-file>
72
83
  <ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
73
- <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>
84
+ <ui-disclosure summary="Use your theme"><ui-text>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></ui-text></ui-disclosure>
74
85
  <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>
75
86
  </ui-stack></aside>
76
87
  <section data-theme-sampler><ui-stack gap="xs"><ui-text variant="heading" as="h2">Theme sampler</ui-text>${documentationCard('ui-cluster', '/99-theme-sampler', true)}</ui-stack></section>
77
- <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>
78
- <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>
88
+ <section class="examples"><ui-stack><ui-form-field><label slot="label" for="example-query">Search components</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>
89
+ <ui-grid columns="2" min="sm"><section $each="card of $previews" $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>
90
+ <ui-button data-action="more-examples">Show more components</ui-button>
79
91
  <noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
80
92
  </ui-stack></section></div></ui-stack></div>
81
93
  <style>
@@ -85,16 +97,20 @@ export function customizerResource(site, input = defaultRecipe()) {
85
97
  .examples { min-inline-size: 0; }
86
98
  .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; }
87
99
  [data-refinement-view] { scroll-margin-block-start: 5rem; }
100
+ .palette-preview { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding: max(var(--ui-space-2), calc(var(--ui-focus-width) * 2)) !important; }
88
101
  .spacing-samples { display: flex !important; align-items: center !important; gap: 0 !important; padding-block: var(--ui-space-3) !important; }
89
102
  .spacing-samples > span { display: block !important; block-size: var(--ui-space-1); inline-size: var(--ui-space-1); border-radius: 50%; background: var(--ui-accent); }
90
103
  .spacing-samples > span:nth-child(2) { margin-inline-start: var(--ui-space-2) !important; }
91
104
  .spacing-samples > span:nth-child(3) { margin-inline-start: var(--ui-space-3) !important; }
92
105
  .spacing-samples > span:nth-child(4) { margin-inline-start: var(--ui-space-4) !important; }
106
+ .palette-option { display: flex; align-items: center; gap: var(--ui-space-2); }
107
+ .palette-swatches { display: flex; gap: 0.125rem; flex: none; }
108
+ .palette-swatches > span { display: block; inline-size: 0.65rem; block-size: 0.65rem; border-radius: var(--ui-radius-xs); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ui-text) 12%, transparent); }
93
109
  .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; }
94
110
  .palette-range > span { flex: 1; min-inline-size: var(--ui-space-5); }
95
- .palette-canvas { background: var(--ui-surface); }
96
- .palette-content { background: var(--ui-surface-raised); }
97
- .palette-action { background: var(--ui-accent); }
98
- @media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { grid-row: auto; order: 2; position: static; max-block-size: none; } [data-theme-sampler] { order: 1; } .examples { order: 3; } }
111
+ .palette-canvas { background: var(--_palette-canvas, var(--ui-surface)); }
112
+ .palette-content { background: var(--_palette-content, var(--ui-surface-raised)); }
113
+ .palette-action { background: var(--_palette-action, var(--ui-accent)); }
114
+ @media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { grid-row: auto; order: 2; position: static; max-block-size: none; } .palette-preview { position: static; max-block-size: none; overflow: visible; padding: 0 !important; } [data-theme-sampler] { order: 1; } .examples { order: 3; } }
99
115
  </style></template>`;
100
116
  }
@@ -1,7 +1,7 @@
1
1
  import { googleFontStack, systemFont, loadGoogleFonts } from './fonts.mjs';
2
2
  import { documentationTheme, themeEvent } from './theme-session.mjs';
3
3
  import { componentRegistry } from '@nextwebwg/html-next/runtime';
4
- import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme, startingPointLabels, nearbyVariation } from './theme.mjs';
4
+ import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme, nearbyVariation } from './theme.mjs';
5
5
 
6
6
  import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
7
7
 
@@ -21,6 +21,9 @@ export function connect(host) {
21
21
  let saving = false;
22
22
  let alive = true;
23
23
  let selectedExample = '';
24
+ const catalogExamples = [...root.querySelectorAll('[data-add-example] option[data-example]')].map(option => JSON.parse(option.dataset.example));
25
+ const removedExamples = new Set();
26
+ let previewQuery = '', previewLimit = 9;
24
27
  let refinementTrigger;
25
28
  let previewFrame = 0, pending = false, candidate, gestureStart;
26
29
  const abort = new AbortController();
@@ -31,6 +34,7 @@ export function connect(host) {
31
34
  const editorFor = (field, value) => valueEditor(field, value, { remPixels: parseFloat(recipe.values['text-size']) });
32
35
  let boundsBase;
33
36
  const updateControl = (element, value) => {
37
+ if (element.matches('[data-choice]')) element.dataset.selectedValue = value;
34
38
  const field = fields.get(element.dataset.token);
35
39
  if (!field || !element.dataset.editorKind) { componentRegistry.update(element, { value }); return; }
36
40
  const editor = editorFor(field, value);
@@ -71,23 +75,34 @@ export function connect(host) {
71
75
  for (const family of ['material','palette']) {
72
76
  const state = refinementState(recipe, family), presets = family === 'material' ? materialPresets : themePalettes;
73
77
  for (const select of root.querySelectorAll(`[data-refinement-preset="${family}"]`)) {
74
- for (const option of select.options) if (presets[option.value]) option.textContent = presets[option.value].label + (state.id === option.value && state.customized ? ' · customized' : '');
78
+ for (const option of select.querySelectorAll('option')) if (presets[option.value]) {
79
+ const label = presets[option.value].label + (state.id === option.value && state.customized ? ' · customized' : '');
80
+ if (option.textContent !== label) option.textContent = label;
81
+ }
75
82
  updateControl(select, state.id);
83
+ const origin = startingPoints[recipe.origin ?? startingPointFor(recipe)];
84
+ const muted = Boolean(origin && themeFields.filter(field => field.family === (family === 'palette' ? 'Colour' : 'Material')).every(field => origin.values[field.id] === recipe.values[field.id]));
85
+ componentRegistry.update(select, { muted });
76
86
  }
77
87
  componentRegistry.update(root.querySelector(`[data-action="restore-${family}"]`), { disabled: state.id === 'custom' || !state.customized });
78
88
  }
79
- for (const control of root.querySelectorAll('[data-font-picker]')) componentRegistry.update(control, { value: recipe.fonts?.[control.dataset.fontPicker] ?? (recipe.values[control.dataset.fontPicker] === systemFont ? 'system' : 'custom') });
89
+ for (const control of root.querySelectorAll('[data-font-picker]')) componentRegistry.update(control, { value: control.dataset.fontPicker === 'display-font' && recipe.values['display-font'] === recipe.values['body-font'] ? 'inherit' : recipe.fonts?.[control.dataset.fontPicker] ?? (recipe.values[control.dataset.fontPicker] === systemFont ? 'system' : 'custom') });
80
90
  componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
81
91
  const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
82
92
  const report = root.querySelector('[data-palette-adjustments]');
83
- 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.';
84
- 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.';
93
+ report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your base colors stay editable; resolved values are shown below.' : 'Both ranges preserve your base colors with readable text and essential outlines.';
94
+ 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('; ') + '. Base colors remain editable.' : 'Both ranges preserve your base colors with readable text and essential outlines.';
85
95
  for (const card of root.querySelectorAll('[data-example-card]')) if (card.getClientRects().length) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
86
96
  const cssControl = root.querySelector('[data-css]');
87
97
  if (cssControl.getClientRects().length) updateControl(cssControl, css);
88
98
  for (const action of ['undo','redo']) componentRegistry.update(root.querySelector(`[data-action="${action}"]`), { disabled: !theme?.[action === 'undo' ? 'canUndo' : 'canRedo'] });
89
- const origin = recipe.origin ?? startingPointFor(recipe);
90
- root.querySelector('[data-theme-origin]').textContent = startingPointLabels[origin] ? `${startingPointLabels[origin]}${startingPointFor(recipe) === origin ? '' : ' · customized'}` : 'Your theme';
99
+ for (const button of root.querySelectorAll('[data-relationship]')) button.setAttribute('aria-pressed', String(button.dataset.relationship === recipe.values['palette-relationship']));
100
+ for (const sample of root.querySelectorAll('[data-relationship-preview]')) {
101
+ if (!sample.getClientRects().length) continue;
102
+ const tokens = deriveTheme({ ...recipe, values: { ...recipe.values, 'palette-relationship': sample.dataset.relationshipPreview } }, { mode: 'light' }).tokens;
103
+ for (const [role, token] of [['canvas','--ui-surface'],['content','--ui-surface-raised'],['action','--ui-accent']]) sample.style.setProperty('--_palette-' + role, tokens[token]);
104
+ }
105
+ for (const indicator of root.querySelectorAll('[data-relationship-selected]')) indicator.hidden = indicator.dataset.relationshipSelected !== recipe.values['palette-relationship'];
91
106
  root.querySelector('[data-material-dependencies]').textContent = Number(recipe.values['light-strength']) === 0
92
107
  ? 'Light strength is zero: reflections, glow and bright edge catches are off. Depth shadows and the dark edge remain; depth still follows the source direction.'
93
108
  : Number(recipe.values.finish) === 0 ? 'This material is matte: light spread has no reflection to shape. Raise Finish or choose Polished to see it; depth and illuminated edges remain independent.'
@@ -133,17 +148,25 @@ export function connect(host) {
133
148
  gestureStart = undefined;
134
149
  };
135
150
  const filterExamples = () => {
136
- const words = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
137
- let visible = 0;
138
- for (const card of root.querySelectorAll('[data-customizer-overview] .examples [data-example-card]')) {
139
- const matches = words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
140
- card.closest('section').hidden = !matches;
141
- if (matches) visible++;
142
- }
143
- root.querySelector('[data-example-count]').textContent = visible ? `${visible} example${visible === 1 ? '' : 's'} in preview` : 'No previews match. Clear the filter or add another example.';
151
+ const query = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim();
152
+ if (query !== previewQuery) { previewQuery = query; previewLimit = 9; }
153
+ const words = query.split(/\s+/).filter(Boolean), seen = new Set(), identities = new Set(), matches = [];
154
+ const include = (example, chosen = false) => {
155
+ const text = [example.title, example.tag, example.description, example.exampleTitle, example.category].join(' ').toLocaleLowerCase();
156
+ if (removedExamples.has(example.id) || identities.has(example.id) || (!chosen && seen.has(example.tag)) || !words.every(word => text.includes(word))) return;
157
+ identities.add(example.id); seen.add(example.tag); matches.push(example);
158
+ };
159
+ host.state.cards.forEach(example => include(example, true));
160
+ catalogExamples.forEach(example => include(example));
161
+ host.state.previews = matches.slice(0, previewLimit);
162
+ const visible = Math.min(matches.length, previewLimit);
163
+ root.querySelector('[data-example-count]').textContent = visible ? `Showing ${visible} of ${matches.length} preview${matches.length === 1 ? '' : 's'}` : 'No components match. Try another name, tag or purpose.';
164
+ root.querySelector('[data-action="more-examples"]').hidden = visible >= matches.length;
165
+ view.requestAnimationFrame(paint);
144
166
  componentRegistry.update(root.querySelector('[data-action="add"]'), { disabled: !selectedExample || host.state.cards.some(item => item.id === selectedExample) });
145
167
  };
146
168
  const refinedRecipe = values => {
169
+ if (recipe.values['display-font'] === recipe.values['body-font'] && values['display-font'] === recipe.values['display-font']) values = { ...values, 'display-font': values['body-font'] };
147
170
  const presets = { ...recipe.presets };
148
171
  for (const family of ['material', 'palette']) {
149
172
  const state = refinementState(recipe, family);
@@ -195,7 +218,7 @@ export function connect(host) {
195
218
  case 'preview-variation': {
196
219
  clearPreview();
197
220
  const locks = [...root.querySelectorAll('[data-family-lock]')].filter(control => control.querySelector('input')?.checked).map(control => control.dataset.familyLock);
198
- candidate = nearbyVariation(recipe, root.querySelector('[data-variation-choice]').value, locks);
221
+ candidate = nearbyVariation(recipe, root.querySelector('[data-variation-choice]').dataset.selectedValue, locks);
199
222
  theme?.compare(candidate); root.querySelector('[data-variation-confirm]').hidden = false;
200
223
  const differences = themeFields.filter(field => recipe.values[field.id] !== candidate.values[field.id]);
201
224
  root.querySelector('[data-variation-description]').textContent = differences.length ? differences.map(field => `${field.label}: ${recipe.values[field.id]} → ${candidate.values[field.id]}`).join('; ') : 'No changes: the relevant families are locked or already at their bounds.';
@@ -235,10 +258,14 @@ export function connect(host) {
235
258
  if (!option) break;
236
259
  const selected = JSON.parse(option.dataset.example);
237
260
  const card = Object.fromEntries(['id','title','description','exampleTitle','src','resizable','category','tag','nativeRoot','href','packageName'].map(key => [key, selected[key]]));
238
- if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [...host.state.cards, card];
261
+ removedExamples.delete(card.id);
262
+ if (!host.state.cards.some(item => item.id === card.id)) host.state.cards = [card, ...host.state.cards];
263
+ updateControl(root.querySelector('[data-example-query]'), ''); previewQuery = '';
264
+ previewLimit = Math.max(previewLimit, host.state.cards.length);
239
265
  say(`${card.title} · ${card.exampleTitle} added to preview.`);
240
266
  view.requestAnimationFrame(() => { paint(); filterExamples(); }); break;
241
267
  }
268
+ case 'more-examples': previewLimit += 9; filterExamples(); break;
242
269
  case 'css': {
243
270
  const response = await fetch(root.dataset.base + '_looma/theme-system.css', { signal: abort.signal });
244
271
  if (!response.ok) throw new Error('Could not load the shared theme recipe.');
@@ -246,7 +273,12 @@ export function connect(host) {
246
273
  }
247
274
  case 'recipe': download(JSON.stringify(recipe, null, 2) + '\n', 'looma.theme.json', 'application/json'); say('Editable recipe downloaded.'); break;
248
275
  case 'import': file.click(); break;
249
- case 'reset': show(theme?.reset() ?? structuredClone(saved), false); say(theme?.storageAvailable ? 'Browser customization reset to the saved theme.' : 'Changes reset for this page. Browser storage is unavailable, so a previous browser theme may return after reload.'); break;
276
+ case 'reset': {
277
+ const origin = recipe.origin ?? startingPointFor(recipe);
278
+ show(startingPoints[origin] ? { ...structuredClone(startingPoints[origin]), origin } : structuredClone(saved));
279
+ sayApplied('Restored this theme’s starting values. Undo brings your changes back.'); break;
280
+ }
281
+ case 'palette-relationship': show(refinedRecipe({ ...recipe.values, 'palette-relationship': button.dataset.relationship })); sayApplied('Color relationship applied to both modes.'); break;
250
282
  case 'save': {
251
283
  if (!capability) throw new Error('Repository saving is available only in the local documentation server.');
252
284
  const submitted = structuredClone(recipe);
@@ -266,6 +298,7 @@ export function connect(host) {
266
298
  finally { if (button.dataset.action === 'save') saving = false; }
267
299
  };
268
300
  const change = async event => {
301
+ const value = event.detail?.value ?? event.target.value;
269
302
  if (event.target.matches('[data-light-editor]') || event.target.closest?.('[data-token]')) {
270
303
  input(event); flush(); sayApplied('Theme remembered in this browser. Undo restores the previous decision.'); return;
271
304
  }
@@ -282,16 +315,21 @@ export function connect(host) {
282
315
  }
283
316
  if (event.target.matches('[data-refinement-preset]')) {
284
317
  const family = event.target.dataset.refinementPreset;
285
- show(applyRefinement(recipe, family, event.target.value));
318
+ show(applyRefinement(recipe, family, value));
286
319
  sayApplied(`${family === 'material' ? 'Material' : 'Coordinated palette'} preset applied and remembered in both modes.`);
287
320
  }
288
- if (event.target.matches('[data-preset]')) { show({ ...structuredClone(startingPoints[event.target.value]), origin: event.target.value }); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
321
+ if (event.target.matches('[data-preset]')) { show({ ...structuredClone(startingPoints[value]), origin: value }); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
289
322
  if (event.target === file && file.files[0]) {
290
323
  try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
291
324
  catch (error) { say(error.message, true); }
292
325
  file.value = '';
293
326
  }
294
327
  };
328
+ const choose = event => {
329
+ if (!event.target.matches('[data-choice]') || event.detail.kind !== 'selection') return;
330
+ event.target.dataset.selectedValue = event.detail.value;
331
+ if (!event.target.matches('[data-variation-choice]')) change(event);
332
+ };
295
333
  const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
296
334
  const synchronize = () => { if (!pending && theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
297
335
  const cancel = event => { if (event.target.matches('[data-light-editor], [data-editor-kind="curve"]')) { flush(false); say('Drag cancelled; the previous value is restored.'); } };
@@ -302,17 +340,29 @@ export function connect(host) {
302
340
  const ready = event => {
303
341
  if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
304
342
  };
305
- const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); view.requestAnimationFrame(filterExamples); };
343
+ const remove = event => {
344
+ const example = catalogExamples.find(card => card.id === event.detail.id);
345
+ const chosen = host.state.cards.some(card => card.id === event.detail.id);
346
+ host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id);
347
+ const siblingRemains = host.state.cards.some(card => card.tag === example?.tag);
348
+ for (const card of catalogExamples) if (card.tag === example?.tag && (!siblingRemains || card.id === event.detail.id)) removedExamples.add(card.id);
349
+ if (chosen) previewLimit = Math.max(9, previewLimit - 1);
350
+ view.requestAnimationFrame(filterExamples);
351
+ };
306
352
  let fontRequest = 0;
307
353
  const selectFont = async event => {
308
354
  const control = event.target.closest?.('[data-font-picker]'); if (!control) return;
309
355
  flush(); clearPreview();
310
356
  const id = control.dataset.fontPicker, family = event.detail.value;
311
357
  const fonts = { ...recipe.fonts };
312
- if (family === 'system') delete fonts[id]; else fonts[id] = family;
313
- const next = { ...refinedRecipe({ ...recipe.values, [id]: family === 'system' ? systemFont : googleFontStack(family) }), fonts };
358
+ if (family === 'system' || family === 'inherit') delete fonts[id]; else fonts[id] = family;
359
+ const value = family === 'inherit' ? recipe.values['body-font'] : family === 'system' ? systemFont : googleFontStack(family);
360
+ const linked = recipe.values['display-font'] === recipe.values['body-font'];
361
+ if (id === 'body-font' && linked) delete fonts['display-font'];
362
+ const next = { ...refinedRecipe({ ...recipe.values, [id]: value }), fonts };
363
+ if (id === 'display-font' && family === 'inherit' && fonts['body-font']) next.fonts['display-font'] = fonts['body-font'];
314
364
  const status = root.querySelector(`[data-font-status="${id}"]`), request = ++fontRequest;
315
- show(next); status.textContent = family === 'system' ? 'Using your operating system’s native font.' : `Loading ${family} from Google Fonts…`;
365
+ show(next); status.textContent = family === 'inherit' ? 'Headings use the main font.' : family === 'system' ? 'Using your operating system’s native font.' : `Loading ${family} from Google Fonts…`;
316
366
  try {
317
367
  await loadGoogleFonts(document, fonts);
318
368
  if (alive && request === fontRequest) for (const [field, selected] of Object.entries(fonts)) {
@@ -332,6 +382,7 @@ export function connect(host) {
332
382
  root.addEventListener('input', input);
333
383
  root.addEventListener('cancel', cancel);
334
384
  root.addEventListener('keydown', keyboard);
385
+ root.addEventListener('value-change', choose);
335
386
  root.addEventListener('value-change', selectExample);
336
387
  root.addEventListener('value-change', selectFont);
337
388
  root.addEventListener('blur', blur, true);
@@ -352,5 +403,5 @@ export function connect(host) {
352
403
  root.querySelector('[data-action="save"]').hidden = false;
353
404
  }).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
354
405
  }
355
- return () => { root.removeEventListener('toggle', updateSizes, true); flush(); clearPreview(); root.removeEventListener('cancel', cancel); root.removeEventListener('keydown', keyboard); alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('value-change', selectExample); root.removeEventListener('value-change', selectFont); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('beforeunload', flush); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
406
+ return () => { root.removeEventListener('toggle', updateSizes, true); flush(); clearPreview(); root.removeEventListener('cancel', cancel); root.removeEventListener('keydown', keyboard); alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('value-change', choose); root.removeEventListener('value-change', selectExample); root.removeEventListener('value-change', selectFont); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('beforeunload', flush); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
356
407
  }
@@ -1,3 +1,5 @@
1
+ :root { --markupress-header-notice-display: none; }
2
+ :root[data-looma-documentation-theme] { --markupress-header-notice-display: inline-flex; }
1
3
  /* Documentation presentation owns only its native preview frame and code overflow. Controls use Looma. */
2
4
  [data-example-card] iframe { display: block; max-inline-size: 100%; border: 0; background: transparent; }
3
5
  [data-example-card] pre { overflow: auto; }
package/docs/palette.mjs CHANGED
@@ -51,9 +51,9 @@ export function paletteSeeds(values) {
51
51
  if (tint) l = clamp(l, .08 * tint, 1 - .03 * tint);
52
52
  if (relationship !== 'preserve') { h = brandHue + (relationship === 'neighboring' ? 30 : 180); c = Math.max(c, .018); }
53
53
  if (warmth !== 0) {
54
- const target = warmth > 0 ? 85 : 255;
55
- const delta = ((target - h + 540) % 360) - 180;
56
- h += delta * Math.abs(warmth); c = Math.max(c, .035 * Math.abs(warmth));
54
+ const strength = Math.abs(warmth);
55
+ const target = fromLch([l, warmth > 0 ? .025 : .018, warmth > 0 ? 85 : 255]);
56
+ [l, c, h] = toLch(mixColour(fromLch([l, c, h]), target, 1 - strength));
57
57
  }
58
58
  }
59
59
  return [key, fromLch([l, c * intensity, h])];
package/docs/site.mjs CHANGED
@@ -178,7 +178,7 @@ export function documentationPlugin(site) {
178
178
  export async function documentationOptions(site) {
179
179
  const { siteOptions } = await tool('markupress', site.root);
180
180
  const styles = ['tokens.css', 'theme-light.css', 'theme-dark.css', 'theme-system.css'].map(file => fileURLToPath(import.meta.resolve(`@threadlabs/looma/${file}`)));
181
- const options = siteOptions({ root: site.root, contentDir: path.relative(site.root, site.content), title: site.config.title ?? 'Your design system', base: site.base, outDir: site.config.outDir ?? 'site', ...(site.config.origin ? { origin: site.config.origin } : {}), ...(site.config.logo ? { logo: site.config.logo } : {}), ...(site.config.nav ? { nav: site.config.nav } : {}) }, { plugins: [documentationPlugin(site)], css: styles, redirects: site.redirects });
181
+ const options = siteOptions({ root: site.root, contentDir: path.relative(site.root, site.content), title: site.config.title ?? 'Your design system', headerNotice: 'Custom theme', base: site.base, outDir: site.config.outDir ?? 'site', ...(site.config.origin ? { origin: site.config.origin } : {}), ...(site.config.logo ? { logo: site.config.logo } : {}), ...(site.config.nav ? { nav: site.config.nav } : {}) }, { plugins: [documentationPlugin(site)], css: styles, redirects: site.redirects });
182
182
  const savedTheme = path.join(site.generated, 'saved-theme.css');
183
183
  await writeFile(savedTheme, site.config.themeCSSContents ?? themeCSS(site.config.recipe ?? defaultRecipe()));
184
184
  const themeStyles = [savedTheme, ...await portableThemeStyles(site)];
@@ -59,6 +59,7 @@ export function createThemeSession(view, { base, baseline, systemCSS, historyLim
59
59
  if (event.newValue !== null && !next) return;
60
60
  selected = next; draft = comparison = undefined; past.length = future.length = 0; paint();
61
61
  });
62
+ view.addEventListener('markupress-header-notice-dismiss', () => session.reset());
62
63
  paint();
63
64
  return session;
64
65
  }
package/docs/theme.mjs CHANGED
@@ -3,7 +3,7 @@ import { mixColour as mix, paletteSeeds, companionSurface, toLch, fromLch } from
3
3
 
4
4
  const bounds = {
5
5
  'text-size': [14, 24], 'control-roominess': [2, 4], 'corner-roundness': [0, 32],
6
- 'line-weight': [.5, 3], spacing: [.75, 1.5], 'scale-contrast': [1, 1.5],
6
+ 'line-weight': [.5, 3], rhythm: [.75, 1.5], spacing: [.75, 1.5], 'scale-contrast': [1, 1.5],
7
7
  'space-curve': [.5, 2], 'text-weight': [400, 700], 'outer-depth': [0, 2], 'inner-depth': [0, 2],
8
8
  'response-time': [0, 600],
9
9
  };
@@ -16,12 +16,13 @@ export const themeFields = [
16
16
  ['Colour', 'text', 'Text', '--ui-text', 'colour', '#1a1a1a', 'Readable content; supporting text follows this and Surface.'],
17
17
  ['Colour', 'warmth', 'Surface warmth', null, 'fraction', '0.5', '0% cool, 50% original, 100% warm paper and ink; your brand hue stays independent.'],
18
18
  ['Colour', 'color-intensity', 'Color intensity', null, 'fraction', '0.5', '0% quiet, 50% original, 100% vivid, coordinated across surfaces, actions and status colors.'],
19
- ['Colour', 'palette-relationship', 'Palette relationship', null, 'choice', 'preserve', 'Keep seed hues, or relate quiet surfaces to the brand through neighboring or complementary hues.'],
20
- ['Type', 'body-font', 'Body font', '--ui-font-sans', 'font', 'system-ui, sans-serif', 'The shared interface typeface.'],
21
- ['Type', 'display-font', 'Display font', '--ui-font-display', 'font', 'system-ui, sans-serif', 'The heading typeface.'],
19
+ ['Colour', 'palette-relationship', 'Palette relationship', null, 'choice', 'preserve', 'Keep base hues, or relate quiet surfaces to the brand through neighboring or complementary hues.'],
20
+ ['Type', 'body-font', 'Font', '--ui-font-sans', 'font', 'system-ui, sans-serif', 'The shared interface typeface.'],
21
+ ['Type', 'display-font', 'Heading font', '--ui-font-display', 'font', 'system-ui, sans-serif', 'The heading typeface.'],
22
22
  ['Type', 'text-size', 'Base text size', '--ui-font-size', 'positive-length', '16px', 'Sets the document root in px. Type, spacing and layout use rem relationships and scale from this base once.'],
23
23
  ['Type', 'text-weight', 'Emphasis weight', '--ui-font-medium', 'weight', '500', 'Control-label emphasis; body and headings keep their roles.'],
24
24
  ['Size & spacing', 'control-roominess', 'Control roominess', '--ui-control-roominess', 'positive', '2.5', 'Shared control height as a multiple of base text size; buttons, fields and swatches stay aligned.'],
25
+ ['Size & spacing', 'rhythm', 'Rhythm', '--ui-rhythm', 'positive', '1', 'More or less breathing room between lines and groups. Spacing fine-tunes gaps independently.'],
25
26
  ['Size & spacing', 'spacing', 'Spacing', '--ui-spacing', 'positive', '1', 'More or less breathing room throughout the interface.'],
26
27
  ['Size & spacing', 'scale-contrast', 'Size ratio', '--ui-scale-ratio', 'ratio', '1.2', 'The multiplier between small, medium and large controls. At 1 the sizes are equal; higher values separate them.'],
27
28
  ['Size & spacing', 'space-curve', 'Spacing curve', '--ui-space-curve', 'nonnegative', '1', 'Spacing growth: 1 is linear, 2 is quadratic. Independent of control sizes.'],
@@ -31,7 +32,7 @@ export const themeFields = [
31
32
  ['Material', 'inner-depth', 'Inner depth', '--ui-inner-depth', 'nonnegative', '0.3', 'How strongly fields and pressed controls sit into their surface.'],
32
33
  ['Material', 'glassiness', 'Glassiness', '--ui-glassiness', 'fraction', '0', 'Clear through richly frosted: the upper range adds stronger blur, saturation and glass edges. Content stays readable.'],
33
34
  ['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.'],
34
- ['Material', 'translucency', 'Overlay translucency', '--ui-overlay-translucency', 'fraction', '0', 'How much background comes through floating surfaces.'],
35
+ ['Material', 'translucency', 'Overlay translucency', '--ui-overlay-translucency', 'fraction', '0', 'How much background comes through floating material around opaque content.'],
35
36
  ['Material', 'edge-light', 'Edge light', '--ui-edge-light', 'fraction', '0', 'Light caught by surface edges, independent of depth.'],
36
37
  ['Material', 'finish', 'Finish', '--ui-finish', 'fraction', '0', 'Matte through polished: how strongly the material catches a reflection.'],
37
38
  ['Material', 'glow', 'Glow', '--ui-glow', 'fraction', '0', 'Reflected light beyond a surface, from a quiet halo to a vivid bloom at the upper end.'],
@@ -47,11 +48,11 @@ export const defaultRecipe = () => ({ schemaVersion: 1, values: Object.fromEntri
47
48
  const starting = values => ({ schemaVersion: 1, values: { ...defaultRecipe().values, ...values } });
48
49
  export const startingPoints = {
49
50
  clear: defaultRecipe(),
50
- bookish: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-roominess': '2.625', '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' }),
51
- wiki: starting({ action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-roominess': '2.125', spacing: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
52
- 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' }),
53
- 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)' }),
54
- 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-roominess': '2.875', 'scale-contrast': '1.24', spacing: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
51
+ bookish: starting({ rhythm: '1.05', action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-roominess': '2.625', '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' }),
52
+ wiki: starting({ rhythm: '0.8', 'text-weight': '400', 'space-curve': '0.8', action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-roominess': '2.125', spacing: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
53
+ editorial: starting({ rhythm: '1.3', 'text-size': '17px', spacing: '1.12', 'space-curve': '1.4', 'scale-contrast': '1.3', 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' }),
54
+ night: starting({ rhythm: '0.9', 'display-font': 'ui-monospace, monospace', 'text-weight': '600', 'control-roominess': '2.375', spacing: '0.9', 'scale-contrast': '1.25', 'space-curve': '1.2', finish: '0.6', glow: '0.45', glassiness: '0.45', 'surface-separation': '0.25', 'light-x': '0.25', 'light-spread': '0.65', 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)' }),
55
+ garden: starting({ rhythm: '1.2', 'body-font': 'ui-rounded, system-ui, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'space-curve': '1.15', finish: '0.15', 'light-spread': '1', action: '#256342', canvas: '#f4edda', surface: '#fff8e7', text: '#173121', 'corner-roundness': '22px', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2', 'control-roominess': '2.875', 'scale-contrast': '1.24', spacing: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
55
56
  };
56
57
  export const startingPointLabels = { clear: 'Clear', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
57
58
  const paletteKeys = themeFields.filter(field => field.family === 'Colour').map(field => field.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.37.6",
3
+ "version": "0.38.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.7 || ^0.9.0",
130
+ "markupress": "^0.9.1",
131
131
  "@nextwebwg/htmlkit": "1.0.0-alpha.69",
132
132
  "vite": "^8.3.4"
133
133
  },