@threadlabs/looma 0.39.0 → 0.39.2

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.
package/docs/README.md CHANGED
@@ -86,17 +86,28 @@ The generated values follow rules. The medium control height is base text size t
86
86
 
87
87
  Choose a coordinated palette, then refine surface warmth, color intensity and palette relationship. Warmth runs from cool to warm paper and ink, with 0.5 neutral; intensity runs from quiet to vivid, with 0.5 retaining original seed intensity. Keep my colors preserves the base hues; Related colors and Contrasting colors tint quiet surfaces toward neighboring and opposite hues. These decisions regenerate paired light/dark colors through Oklab/OKLCH operations, gamut repair and readability checks. They do not transform existing CSS colors live. Four Base colors remain visible for precise edits; the light/dark range previews and resolved values explain adjustments.
88
88
 
89
- The customizer uses Starting point, Palette and Material as its controls sidebar, with documentation navigation left out of this workspace. Detailed type, size and motion controls, example selection and exports open when needed.
89
+ Documentation navigation stays out of the customization workspace. **Material & light** opens a movable light source, real component samples and bounded surface controls. **Palette** opens the coordinated light/dark ranges and their four Base colors. Each editor changes the same persistent theme. Returning to the overview, navigating to a component, or reloading keeps the result.
90
90
 
91
- The main customizer offers Material and Palette presets. **Refine material & light** opens a focused view with a movable light source, real component samples and bounded surface controls. **Refine palette** opens the coordinated light/dark ranges and their four Base colors. Both views edit the same theme as the main customizer. Returning to the overview, navigating to a component, or reloading keeps the result.
91
+ A refined preset keeps its name with “customized” beside it. **Reset material** resets only material and light; **Reset palette** resets only the palette. These family choices are recorded in the recipe's optional `presets` mapping. The `values` mapping remains the complete source of the generated theme; a preset is a named combination of those values.
92
92
 
93
- A refined preset keeps its name with “customized” beside it. **Restore material preset** resets only material and light; **Restore palette preset** resets only the palette. These family choices are recorded in the recipe's optional `presets` mapping. The `values` mapping remains the complete source of the generated theme; a preset is a named combination of those values.
94
-
95
- Number controls accept arithmetic such as `1rem / 1.2` or `150ms * 2`, calculate on entry and show the result. Unit changes preserve the quantity. Sliders include exact entry, and curve handles support dragging and arrow keys alongside numeric coordinates. Light and curve drags preserve the grab offset; Escape or pointer cancellation restores the gesture start without creating a saved edit.
93
+ Number controls accept arithmetic such as `1rem / 1.2` or `150ms * 2`, calculate on entry and show the result. Unit changes preserve the quantity. Sliders include exact entry, and curve handles support dragging and arrow keys alongside numeric coordinates. LightEditor keeps its coordinate fields under **Exact position**. Light and curve drags preserve the grab offset; Escape or pointer cancellation restores the gesture start without creating a saved edit.
96
94
 
97
95
  Lengths retain their CSS units, such as `rem`; times retain `ms` or `s`. The downloaded CSS includes the canonical shared CSS derivation rules as well as resolved theme values. Download JSON separately to retain editable decisions and font/preset provenance. Import a downloaded JSON recipe to continue editing it. Keep hand-written CSS in a separate overrides file so regenerating the theme does not replace it.
98
96
 
99
- Undo and Redo move through accepted recipe changes. There is no page-entry comparison state: reopening the customizer does not create a hidden before/after reference. **Explore nearby styles** previews Warmer, Quieter, More expressive or More compact before you apply it. Palette, Material and Type & spacing locks preserve those families during variations. Cancel preview returns to the current recipe.
97
+ The customizer opens on a dashboard summarizing the current starting theme, palette,
98
+ material, typography, spacing and motion. Open a summary to edit that subject; use
99
+ **Overview** to return to the same dashboard position. Only the active editor shows
100
+ its controls. Precise adjustments sit inside the relevant editor's detail sections.
101
+
102
+ Typography pairs font selection with headings and body text. Size & spacing shows
103
+ the shared control ladder and grouped content. Motion keeps a replayable study and
104
+ a real popover beside duration and curve editing; the study follows the shared
105
+ curve, travels at three times the feedback duration, and respects reduced motion.
106
+ **Browse components** searches the complete catalog, with previews loaded in batches.
107
+ **Export & save** contains downloads, recipe import and local repository saving.
108
+
109
+ Edits apply immediately. Undo and Redo move through completed changes; Reset restores
110
+ the current starting theme. There is no separate variation preview or apply step.
100
111
 
101
112
  Changes apply throughout the documentation and remain in this browser across navigation and reload until reset. Repository files change only when you save locally. Starting points update the shell and all selected cards. Reset restores the selected starting theme’s values and remains undoable. Removing the header’s Custom theme chip instead clears the browser override and restores the repository baseline. Invalid input keeps the last valid preview and explains which decision needs attention.
102
113
 
package/docs/catalog.json CHANGED
@@ -458,7 +458,7 @@
458
458
  ],
459
459
  "schemaVersion": 1,
460
460
  "package": "@threadlabs/looma",
461
- "version": "0.39.0",
461
+ "version": "0.39.2",
462
462
  "groups": [
463
463
  {
464
464
  "id": "layout",
@@ -4220,7 +4220,7 @@
4220
4220
  "name": "99-theme-sampler",
4221
4221
  "title": "Theme sampler",
4222
4222
  "description": "A compact collection of real controls and a surface for judging a shared theme together.",
4223
- "markup": "<ui-card treatment=\"expressive\" padding=\"sm\"><ui-stack gap=\"s\">\n <ui-cluster gap=\"xs\" justify=\"between\"><ui-text variant=\"eyebrow\">Fellowship office</ui-text><ui-badge tone=\"success\">Ready</ui-badge></ui-cluster>\n <ui-text variant=\"heading\" as=\"h3\">Second breakfast, sorted.</ui-text>\n <ui-text>Frodo plans. Sam follows through.</ui-text>\n <ui-text variant=\"caption\">Briefing at nine · Bring the maps.</ui-text>\n <ui-cluster gap=\"s\">\n <ui-button size=\"sm\" variant=\"solid\" tone=\"accent\">Solid accent</ui-button>\n <ui-button size=\"sm\" variant=\"outline\" tone=\"accent\">Outline accent</ui-button>\n <ui-button size=\"sm\" variant=\"solid\" tone=\"neutral\">Solid neutral</ui-button>\n <ui-button size=\"sm\" variant=\"outline\" tone=\"neutral\">Outline neutral</ui-button>\n <ui-icon-button size=\"sm\" variant=\"outline\" tone=\"neutral\" match-button label=\"Acknowledge memo\"><ui-icon name=\"circle-check\"></ui-icon></ui-icon-button>\n <ui-button size=\"sm\" variant=\"ghost\" tone=\"neutral\">Ghost</ui-button>\n </ui-cluster>\n <ui-input-group><ui-icon slot=\"prefix\" name=\"search\"></ui-icon><ui-input\n size=\"sm\"\n aria-label=\"Search office\"\n placeholder=\"Search Fellowship office\"\n ></ui-input></ui-input-group>\n <ui-cluster gap=\"s\"><ui-checkbox size=\"sm\" checked>Notify Sam</ui-checkbox><ui-switch size=\"sm\" checked>Available</ui-switch><ui-badge tone=\"accent\"\n >Draft</ui-badge></ui-cluster>\n </ui-stack></ui-card>",
4223
+ "markup": "<ui-card treatment=\"expressive\"><ui-stack gap=\"l\">\n <ui-cluster gap=\"xs\" justify=\"between\"><ui-text variant=\"eyebrow\">Fellowship office</ui-text><ui-badge tone=\"success\">Ready</ui-badge></ui-cluster>\n <ui-stack gap=\"s\"><ui-text variant=\"heading\" as=\"h3\">Second breakfast, sorted.</ui-text>\n <ui-text>Frodo plans. Sam follows through.</ui-text>\n <ui-text variant=\"caption\">Briefing at nine · Bring the maps.</ui-text></ui-stack>\n <ui-cluster>\n <ui-button variant=\"solid\" tone=\"accent\" title=\"Solid accent\">Send</ui-button>\n <ui-button variant=\"outline\" tone=\"accent\" title=\"Outline accent\">Save</ui-button>\n <ui-button variant=\"solid\" tone=\"neutral\" title=\"Solid neutral\">File</ui-button>\n <ui-button variant=\"outline\" tone=\"neutral\" title=\"Outline neutral\">Archive</ui-button>\n <ui-icon-button variant=\"outline\" tone=\"neutral\" match-button label=\"Acknowledge memo\"><ui-icon name=\"circle-check\"></ui-icon></ui-icon-button>\n <ui-button variant=\"ghost\" tone=\"neutral\" title=\"Ghost neutral\">Later</ui-button>\n </ui-cluster>\n <ui-input-group><ui-icon slot=\"prefix\" name=\"search\"></ui-icon><ui-input\n aria-label=\"Search office\"\n placeholder=\"Search Fellowship office\"\n ></ui-input></ui-input-group>\n <ui-cluster><ui-checkbox checked>Notify Sam</ui-checkbox><ui-switch checked>Available</ui-switch><ui-badge tone=\"accent\">Draft</ui-badge></ui-cluster>\n </ui-stack></ui-card>",
4224
4224
  "resizable": false,
4225
4225
  "bindings": "",
4226
4226
  "frameworks": {}
@@ -20455,7 +20455,7 @@
20455
20455
  "tag": "ui-skeleton",
20456
20456
  "package": "@threadlabs/looma",
20457
20457
  "root": "span",
20458
- "description": "Reserve the shape of incoming content without announcing each placeholder. Text follows the surrounding line metrics; circles and rectangles stand in for avatars and media. Use one status message and aria-busy on the loading region, then replace the placeholders with real content. Keep existing content visible during refresh when possible. Animation is optional and respects reduced motion.",
20458
+ "description": "Reserve the shape of incoming content without announcing each placeholder. Text follows the surrounding line metrics; circles and rectangles stand in for avatars and media. Use one status message and aria-busy on the loading region, then replace the placeholders with real content. Keep existing content visible during refresh when possible. A broad shimmer sweeps across the placeholders together by default, continuously from left to right. Reduced-motion preferences always keep it static.",
20459
20459
  "designTokens": {
20460
20460
  "sources": [
20461
20461
  "packages/looma/src/components/ui-skeleton/ui-skeleton.html"
@@ -20499,9 +20499,9 @@
20499
20499
  {
20500
20500
  "name": "animated",
20501
20501
  "property": "animated",
20502
- "description": "Gently pulses the placeholder. Off by default, and always still when the reader requests reduced motion.",
20502
+ "description": "Moves a subtle highlight from left to right. On by default; pass false to keep it static. Reduced-motion preferences always disable the shimmer.",
20503
20503
  "type": "boolean",
20504
- "default": false
20504
+ "default": true
20505
20505
  },
20506
20506
  {
20507
20507
  "name": "height",
@@ -20533,9 +20533,9 @@
20533
20533
  "properties": [
20534
20534
  {
20535
20535
  "name": "animated",
20536
- "description": "Gently pulses the placeholder. Off by default, and always still when the reader requests reduced motion.",
20536
+ "description": "Moves a subtle highlight from left to right. On by default; pass false to keep it static. Reduced-motion preferences always disable the shimmer.",
20537
20537
  "type": "boolean",
20538
- "default": false
20538
+ "default": true
20539
20539
  },
20540
20540
  {
20541
20541
  "name": "height",
@@ -20605,9 +20605,9 @@
20605
20605
  {
20606
20606
  "id": "@threadlabs/looma/ui-skeleton/04-animation",
20607
20607
  "name": "04-animation",
20608
- "title": "Optional motion",
20609
- "description": "Still by default. Opt in to a gentle pulse when it helps convey loading; reduced-motion preferences always keep it still.",
20610
- "markup": "<ui-stack gap=\"s\">\n <ui-text tone=\"secondary\" size=\"sm\">Still</ui-text>\n <ui-skeleton width=\"75%\"></ui-skeleton>\n <ui-text tone=\"secondary\" size=\"sm\">Animated when motion is welcome</ui-text>\n <ui-skeleton animated width=\"75%\"></ui-skeleton>\n</ui-stack>",
20608
+ "title": "Shared shimmer",
20609
+ "description": "One broad gradient stays fixed to the viewport, so every placeholder reveals the same moving highlight. It loops continuously and stops for reduced motion.",
20610
+ "markup": "<ui-stack gap=\"s\">\n <ui-cluster gap=\"s\">\n <ui-skeleton shape=\"circle\"></ui-skeleton>\n <ui-skeleton width=\"50%\"></ui-skeleton>\n </ui-cluster>\n <ui-skeleton shape=\"rectangle\" width=\"75%\" height=\"3rem\"></ui-skeleton>\n</ui-stack>",
20611
20611
  "resizable": false,
20612
20612
  "bindings": "",
20613
20613
  "frameworks": {}
@@ -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, startingPoints, startingPointLabels, startingPointFor, deriveTheme, applyRefinement, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState, themeVariations } from './theme.mjs';
5
+ import { defaultRecipe, themeFields, startingPoints, startingPointLabels, startingPointFor, deriveTheme, applyRefinement, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState } from './theme.mjs';
6
6
 
7
7
  export function customizerResource(site, input = defaultRecipe()) {
8
8
  const recipe = validateRecipe(input);
@@ -21,7 +21,7 @@ export function customizerResource(site, input = defaultRecipe()) {
21
21
  let control;
22
22
  if (field.type === 'font') {
23
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>`;
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>${escape(value.split(',')[0].replaceAll('"', '').trim())}</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="caption" data-font-status="${field.id}" role="status">Google fonts preview here and are included 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') {
@@ -31,8 +31,9 @@ export function customizerResource(site, input = defaultRecipe()) {
31
31
  const numeric = `<${tag} id="theme-${field.id}" ${common} data-editor-kind="${editor.kind}" value="${escape(editor.value)}" unit="${editor.unit}" min="${editor.min}" max="${editor.max}" step="${editor.step}"></${tag}>`;
32
32
  control = editor.units ? `<ui-cluster wrap="nowrap" gap="xs">${numeric}<ui-select size="sm" style="--ui-select-inline-size: auto" data-unit-for="${field.id}" aria-label="${escape(field.label)} unit" value="${editor.unit}">${[...new Set([...editor.units, editor.unit])].map(unit => `<option value="${unit}">${unit}</option>`).join('')}</ui-select></ui-cluster>` : numeric;
33
33
  } else {
34
- control = `<ui-cluster wrap="nowrap" gap="xs">${field.type === 'colour' ? `<ui-color-picker size="sm" data-token="${field.id}" value="${escape(value)}" label="${escape(field.label)} colour picker"></ui-color-picker>` : ''}<ui-input id="theme-${field.id}" size="sm" data-token="${field.id}" value="${escape(value)}" aria-label="${escape(field.label)}"></ui-input></ui-cluster>`;
34
+ control = field.type === 'colour' ? `<ui-color-picker id="theme-${field.id}" data-token="${field.id}" value="${escape(value)}" label="${escape(field.label)} colour picker"></ui-color-picker>` : `<ui-input id="theme-${field.id}" ${common} value="${escape(value)}"></ui-input>`;
35
35
  }
36
+ if (field.type === 'font') return `<ui-stack gap="s">${control}</ui-stack>`;
36
37
  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
38
  };
38
39
  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>`;
@@ -52,52 +53,52 @@ export function customizerResource(site, input = defaultRecipe()) {
52
53
  ['neighboring', 'Related colors', 'Tint neutral surfaces with a neighboring hue for a closely coordinated palette.'],
53
54
  ['complementary', 'Contrasting colors', 'Tint neutral surfaces toward the opposite hue for warm–cool contrast.'],
54
55
  ].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>`;
55
- const supplements = {
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>`,
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>`,
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')}`,
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>`,
60
- };
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('');
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>`;
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>`);
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>`);
56
+ const sizePreview = `<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>`;
57
+ const fieldsFor = ids => ids.map(id => renderField(themeFields.find(field => field.id === id))).join('');
58
+ const viewTitles = { material: 'Material & light', palette: 'Palette', typography: 'Typography', layout: 'Size & spacing', motion: 'Motion', themes: 'Starting theme', export: 'Export & save', components: 'Component library' };
59
+ const refinement = (family, content) => `<section id="${family}" data-refinement-view="${family}" hidden tabindex="-1" aria-label="${viewTitles[family]} editor"><ui-stack gap="l"><ui-cluster><ui-button data-action="back"><ui-icon name="arrow-left"></ui-icon>Overview</ui-button><ui-text variant="heading" as="h2">${viewTitles[family]}</ui-text></ui-cluster>${content}</ui-stack></section>`;
60
+ const editor = (family, preview, controls) => refinement(family, `<div class="refinement-layout"><div class="editor-preview" data-editor-preview="${family}">${preview}</div><ui-stack>${controls}</ui-stack></div>`);
61
+ const typographyView = editor('typography', `<ui-card><ui-stack><ui-text variant="heading" as="h2">There and back again.</ui-text><ui-text>Frodo plans the journey. Sam brings lunch. Clear notes keep the Fellowship moving.</ui-text><ui-text variant="label">Memo ready</ui-text><ui-text variant="caption">Notes by Sméagol · Updated today</ui-text></ui-stack></ui-card>`, `${fieldsFor(['body-font','text-size'])}<ui-disclosure summary="Heading font"><ui-stack>${fieldsFor(['display-font'])}</ui-stack></ui-disclosure><ui-disclosure summary="Label emphasis"><ui-stack>${fieldsFor(['text-weight'])}</ui-stack></ui-disclosure>`);
62
+ const layoutView = editor('layout', `<ui-stack>${sizePreview}</ui-stack>`, `${fieldsFor(['control-roominess','rhythm','spacing','corner-roundness'])}<ui-disclosure summary="Scale & edges"><ui-stack>${fieldsFor(['scale-contrast','space-curve','line-weight'])}</ui-stack></ui-disclosure>`);
63
+ const motionView = editor('motion', `<ui-stack><ui-card><ui-stack><ui-text variant="eyebrow">Motion study</ui-text><div class="motion-track"><span data-motion-object aria-hidden="true"></span></div><ui-cluster><ui-button data-action="replay-motion"><ui-icon name="play"></ui-icon>Replay motion</ui-button><ui-text variant="caption" data-motion-readout></ui-text></ui-cluster><ui-text variant="caption" data-motion-preference></ui-text></ui-stack></ui-card></ui-stack>`, `${fieldsFor(['response-time'])}<ui-disclosure summary="Shape the curve"><ui-stack>${fieldsFor(['response-curve'])}</ui-stack></ui-disclosure><ui-text variant="caption">Replay shows the shared curve. Try the popover to feel it in a real component. System reduced-motion preferences are respected.</ui-text><ui-disclosure summary="Try a popover">${documentationCard('ui-popover', null, true)}</ui-disclosure>`);
64
+ const materialView = editor('material', `<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-stack>`, `<ui-cluster>${renderPreset('material', 'refinement')}<ui-button data-action="restore-material">Reset material</ui-button></ui-cluster>${fieldsFor(['finish','edge-light','glow'])}<ui-text variant="caption" data-material-dependencies role="status"></ui-text><ui-disclosure summary="Light source"><ui-stack>${fieldsFor(['light-spread','light-strength'])}</ui-stack></ui-disclosure><ui-disclosure summary="Depth, glass and separation"><ui-stack>${fieldsFor(['outer-depth','inner-depth','glassiness','surface-separation','translucency'])}</ui-stack></ui-disclosure><ui-disclosure summary="Try floating surfaces"><ui-stack>${documentationCard('ui-card','/04-expressive-surface')}${documentationCard('ui-tooltip')}${documentationCard('ui-popover')}${documentationCard('ui-dialog')}</ui-stack></ui-disclosure>`);
65
+ const paletteView = refinement('palette', `<ui-cluster>${renderPreset('palette', 'refinement')}<ui-button data-action="restore-palette">Reset palette</ui-button></ui-cluster><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('')}<ui-disclosure summary="Adjust color character"><ui-stack>${themeFields.filter(field => field.family === 'Colour' && field.type !== 'colour' && field.id !== 'palette-relationship').map(renderField).join('')}</ui-stack></ui-disclosure><ui-disclosure summary="Color relationships">${paletteRelationships}</ui-disclosure></ui-stack></div>`);
65
66
  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('');
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');
67
+ 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','ui-list','ui-list-item'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
67
68
  return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
68
69
  <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>
69
70
  <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>
70
71
  </defs>
71
72
  <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 ?? '')}">
72
- <ui-stack gap="l"><ui-text>Choose a starting point, refine shared decisions, and see them on real documentation examples.</ui-text>
73
- <ui-text variant="caption" role="status" aria-live="polite" data-message>Changes are remembered across the documentation.</ui-text>
73
+ <ui-stack gap="l">
74
+ <ui-text variant="caption" role="status" aria-live="polite" data-message></ui-text>
74
75
  <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>
76
- ${materialView}${paletteView}
77
- <div class="customizer-layout" data-customizer-overview>
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('')}`)}
79
- ${supplements.Colour}${supplements.Material}
80
- <ui-disclosure summary="Type, size & motion"><ui-stack>${families}</ui-stack></ui-disclosure>
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>
76
+ ${materialView}${paletteView}${typographyView}${layoutView}${motionView}
77
+ ${refinement('themes', `<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('')}`)}<ui-text variant="caption">A starting theme coordinates color, typography, space, material, and motion. Refine any of them from the overview.</ui-text>${documentationCard('ui-cluster', '/99-theme-sampler', true)}</ui-stack>`)}
78
+ ${refinement('export', `<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>
82
79
  <input type="file" accept="application/json,.json" hidden data-recipe-file>
83
80
  <ui-button data-action="save" variant="solid" hidden>Save to repository</ui-button>
84
81
  <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>
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>
82
+ <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>`)}
83
+ <div class="customizer-layout" data-customizer-overview>
84
+ <aside aria-label="Theme controls"><ui-stack gap="s"><ui-button variant="card" data-action="refine-themes"><ui-stack gap="xs"><ui-text variant="eyebrow">Starting theme</ui-text><ui-text variant="heading" data-theme-summary></ui-text></ui-stack></ui-button>
85
+ <ui-list>${[['palette','Palette','Colors for light & dark'],['material','Material & light','Surface character'],['typography','Typography','Fonts & readable type'],['layout','Size & spacing','Proportions & breathing room'],['motion','Motion','Pace & response']].map(([id,label,description]) => `<ui-list-item wrap>${id === 'palette' ? `<span slot="leading">${swatches()}</span>` : ''}<a href="#${id}" data-action="refine-${id}">${label}</a><span slot="description" data-summary="${id}">${description}</span><ui-icon slot="trailing" name="chevron-right"></ui-icon></ui-list-item>`).join('')}</ui-list>
86
+ <ui-cluster><ui-button data-action="refine-components">Browse components</ui-button><ui-button data-action="refine-export">Export & save</ui-button></ui-cluster>
86
87
  </ui-stack></aside>
87
88
  <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>
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>
91
89
  <noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
92
- </ui-stack></section></div></ui-stack></div>
90
+ </div>${refinement('components', ` <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>
91
+ <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>
92
+ <ui-button data-action="more-examples">Show more components</ui-button></ui-stack></section>
93
+ `)}</ui-stack></div>
93
94
  <style>
94
- [data-customizer-overview][hidden], [data-refinement-view][hidden], [data-variation-confirm][hidden] { display: none !important; }
95
+ [data-customizer-overview][hidden], [data-refinement-view][hidden] { display: none !important; }
95
96
  .customizer-layout { display: grid !important; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
96
- aside { grid-row: 1 / span 2; 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; scroll-padding: max(var(--ui-space-2), calc(var(--ui-focus-width) * 2)); }
97
+ aside { grid-row: 1 / span 2; position: sticky; inset-block-start: calc(var(--_editor-chrome-height, 4.25rem) + var(--ui-space-2)); max-block-size: calc(100dvh - var(--_editor-chrome-height, 4.25rem) - var(--ui-space-4)); overflow: auto; padding: max(var(--ui-space-2), calc(var(--ui-focus-width) * 2)) !important; scroll-padding: max(var(--ui-space-2), calc(var(--ui-focus-width) * 2)); }
97
98
  .examples { min-inline-size: 0; }
98
99
  .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; }
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; }
100
+ [data-refinement-view] { scroll-margin-block-start: calc(var(--_editor-chrome-height, 4.25rem) + var(--ui-space-2)); }
101
+ .palette-preview, .editor-preview { position: sticky; inset-block-start: calc(var(--_editor-chrome-height, 4.25rem) + var(--ui-space-2)); max-block-size: calc(100dvh - var(--_editor-chrome-height, 4.25rem) - var(--ui-space-4)); overflow: auto; padding: max(var(--ui-space-2), calc(var(--ui-focus-width) * 2)) !important; }
101
102
  .spacing-samples { display: flex !important; align-items: center !important; gap: 0 !important; padding-block: var(--ui-space-3) !important; }
102
103
  .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); }
103
104
  .spacing-samples > span:nth-child(2) { margin-inline-start: var(--ui-space-2) !important; }
@@ -111,6 +112,9 @@ export function customizerResource(site, input = defaultRecipe()) {
111
112
  .palette-canvas { background: var(--_palette-canvas, var(--ui-surface)); }
112
113
  .palette-content { background: var(--_palette-content, var(--ui-surface-raised)); }
113
114
  .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; } }
115
+ .motion-track { overflow: clip; display: flex; align-items: center; block-size: 5rem; padding: var(--ui-space-3); border-radius: var(--ui-radius-md); background: var(--ui-surface-muted); }
116
+ [data-motion-object] { display: block; inline-size: 2rem; block-size: 2rem; border-radius: var(--ui-radius-sm); background: var(--ui-accent-solid); box-shadow: var(--ui-action-shadow); }
117
+ @media (max-width: 64rem) { .customizer-layout, .refinement-layout { grid-template-columns: minmax(0, 1fr); } aside { grid-row: auto; order: 1; position: static; max-block-size: none; } .palette-preview, .editor-preview { position: sticky; inset-block-start: calc(var(--_editor-chrome-height, 4.25rem) + var(--ui-space-2)); z-index: 1; background: var(--ui-surface); max-block-size: none; overflow: visible; padding: 0 !important; } [data-theme-sampler] { order: 2; } .examples { order: 3; } }
118
+ @media (max-width: 64rem) { .palette-preview[data-page-flow], .editor-preview[data-page-flow] { position: static; } }
115
119
  </style></template>`;
116
120
  }
@@ -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, nearbyVariation } from './theme.mjs';
4
+ import { startingPoints, startingPointFor, startingPointLabels, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme } from './theme.mjs';
5
5
 
6
6
  import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
7
7
 
@@ -13,6 +13,7 @@ export function connect(host) {
13
13
  const message = root.querySelector('[data-message]');
14
14
  const file = root.querySelector('[data-recipe-file]');
15
15
  const theme = documentationTheme(view);
16
+ const motionPreference = view.matchMedia('(prefers-reduced-motion: reduce)');
16
17
  let saved = validateRecipe(JSON.parse(root.dataset.recipe));
17
18
  let recipe = theme?.recipe ?? structuredClone(saved);
18
19
  let revision = root.dataset.revision;
@@ -24,8 +25,8 @@ export function connect(host) {
24
25
  const catalogExamples = [...root.querySelectorAll('[data-add-example] option[data-example]')].map(option => JSON.parse(option.dataset.example));
25
26
  const removedExamples = new Set();
26
27
  let previewQuery = '', previewLimit = 9;
27
- let refinementTrigger;
28
- let previewFrame = 0, pending = false, candidate, gestureStart;
28
+ let refinementTrigger, overviewScroll = 0, motionAnimation, motionSignature;
29
+ let previewFrame = 0, pending = false, gestureStart;
29
30
  const abort = new AbortController();
30
31
  const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
31
32
  const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
@@ -63,7 +64,38 @@ export function connect(host) {
63
64
  }
64
65
  }
65
66
  };
67
+ const header = document.querySelector('[data-component~="markupress-header"]');
68
+ const previews = [...root.querySelectorAll('[data-editor-preview], .palette-preview')];
69
+ const updatePreviewLayout = () => {
70
+ const chrome = header?.getBoundingClientRect().height ?? 0;
71
+ root.style.setProperty('--_editor-chrome-height', `${chrome}px`);
72
+ for (const preview of previews) preview.toggleAttribute('data-page-flow', preview.offsetHeight > (view.innerHeight - chrome) / 2);
73
+ };
74
+ const replayMotion = () => {
75
+ const element = root.querySelector('[data-motion-object]');
76
+ const duration = parseFloat(convertValueUnit(recipe.values['response-time'], 'ms', unitContext()));
77
+ motionAnimation?.cancel();
78
+ const available = Math.max(0, element.parentElement.clientWidth - element.offsetWidth - 2 * parseFloat(view.getComputedStyle(element.parentElement).paddingInlineStart));
79
+ const start = available * .3, end = available * .7;
80
+ element.style.transform = `translateX(${start}px)`;
81
+ if (motionPreference.matches) return;
82
+ motionAnimation = element.animate([{ transform: `translateX(${start}px)` }, { transform: `translateX(${end}px)` }], { duration: Math.max(0, duration) * 3, easing: recipe.values['response-curve'], iterations: 2, direction: 'alternate', fill: 'none' });
83
+ };
66
84
  const paint = () => {
85
+ const values = recipe.values;
86
+ root.querySelector('[data-theme-summary]').textContent = startingPointLabels[recipe.origin ?? startingPointFor(recipe)] ?? 'Your theme';
87
+ const summaries = {
88
+ palette: `${themePalettes[refinementState(recipe, 'palette').id]?.label ?? 'Custom colors'} · Light & dark`,
89
+ material: `${materialPresets[refinementState(recipe, 'material').id]?.label ?? 'Custom material'}`,
90
+ typography: `${recipe.fonts?.['body-font'] ?? values['body-font'].split(',')[0]} · ${values['text-size']}`,
91
+ layout: `${Number(values.rhythm) * Number(values.spacing) < .95 ? 'Compact' : Number(values.rhythm) * Number(values.spacing) > 1.2 ? 'Relaxed' : 'Comfortable'} · ${parseFloat(values['corner-roundness']) === 0 ? 'Square' : 'Rounded'} corners`,
92
+ motion: `${values['response-time']} · ${values['response-curve'].startsWith('cubic-bezier') ? 'Custom curve' : values['response-curve']}`,
93
+ };
94
+ for (const [family, text] of Object.entries(summaries)) root.querySelector(`[data-summary="${family}"]`).textContent = text;
95
+ root.querySelector('[data-motion-readout]').textContent = `${values['response-time']} feedback · ${parseFloat(convertValueUnit(values['response-time'], 'ms', unitContext())) * 3}ms travel`;
96
+ componentRegistry.update(root.querySelector('[data-action="replay-motion"]'), { disabled: motionPreference.matches });
97
+ root.querySelector('[data-motion-preference]').textContent = motionPreference.matches ? 'Reduced motion is on. The study stays still.' : 'Travel is three times the feedback duration, like a larger surface transition.';
98
+
67
99
  const css = theme?.css || themeCSS(recipe);
68
100
  if (boundsBase !== recipe.values['text-size']) {
69
101
  boundsBase = recipe.values['text-size'];
@@ -86,13 +118,16 @@ export function connect(host) {
86
118
  }
87
119
  componentRegistry.update(root.querySelector(`[data-action="restore-${family}"]`), { disabled: state.id === 'custom' || !state.customized });
88
120
  }
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') });
121
+ for (const control of root.querySelectorAll('[data-font-picker]')) {
122
+ control.querySelector('option[value=custom]').textContent = recipe.values[control.dataset.fontPicker].split(',')[0].replaceAll('"', '').trim();
123
+ 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') });
124
+ }
90
125
  componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
91
126
  const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
92
127
  const report = root.querySelector('[data-palette-adjustments]');
93
128
  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
129
  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.';
95
- for (const card of root.querySelectorAll('[data-example-card]')) if (card.getClientRects().length) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
130
+ for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
96
131
  const cssControl = root.querySelector('[data-css]');
97
132
  if (cssControl.getClientRects().length) updateControl(cssControl, css);
98
133
  for (const action of ['undo','redo']) componentRegistry.update(root.querySelector(`[data-action="${action}"]`), { disabled: !theme?.[action === 'undo' ? 'canUndo' : 'canRedo'] });
@@ -110,6 +145,11 @@ export function connect(host) {
110
145
  const gaps = root.querySelector('[data-spacing-samples]');
111
146
  gaps.setAttribute('aria-label', `Spacing progression with curve ${recipe.values['space-curve']}`);
112
147
  updateSizes();
148
+ updatePreviewLayout();
149
+ const nextMotion = `${values['response-time']}/${values['response-curve']}`;
150
+ if (!root.querySelector('[data-refinement-view="motion"]').hidden && motionSignature !== nextMotion) {
151
+ motionSignature = nextMotion; replayMotion();
152
+ }
113
153
  };
114
154
  const show = (next, persist = true) => {
115
155
  recipe = validateRecipe(next);
@@ -120,15 +160,9 @@ export function connect(host) {
120
160
  changed = JSON.stringify(recipe) !== JSON.stringify(saved);
121
161
  paint();
122
162
  };
123
- const clearPreview = () => {
124
- if (!candidate) return;
125
- candidate = undefined; theme?.compare();
126
- root.querySelector('[data-variation-confirm]').hidden = true;
127
- };
128
163
  const preview = next => {
129
164
  const validated = validateRecipe(next);
130
165
  if (JSON.stringify(validated.values) === JSON.stringify(recipe.values)) return;
131
- clearPreview();
132
166
  if (!pending) gestureStart = structuredClone(recipe);
133
167
  recipe = validated; pending = true;
134
168
  if (!previewFrame) previewFrame = view.requestAnimationFrame(() => {
@@ -205,49 +239,42 @@ export function connect(host) {
205
239
  const link = document.createElement('a'); link.href = url; link.download = name; link.click();
206
240
  setTimeout(() => URL.revokeObjectURL(url), 0);
207
241
  };
242
+ const renderView = () => {
243
+ const family = view.location.hash.slice(1);
244
+ const section = [...root.querySelectorAll('[data-refinement-view]')].find(element => element.dataset.refinementView === family);
245
+ const overview = root.querySelector('[data-customizer-overview]');
246
+ if (section) refinementTrigger = root.querySelector(`[data-action="refine-${family}"]`);
247
+ for (const element of root.querySelectorAll('[data-refinement-view]')) element.hidden = element !== section;
248
+ overview.hidden = Boolean(section);
249
+ motionAnimation?.cancel();
250
+ if (family === 'motion') motionSignature = undefined;
251
+ paint();
252
+ if (section) { section.focus(); section.scrollIntoView({ block: 'start' }); }
253
+ else { refinementTrigger?.focus({ preventScroll: true }); view.scrollTo({ top: overviewScroll, behavior: 'instant' }); }
254
+ };
255
+ const navigate = family => {
256
+ flush();
257
+ view.history.pushState(null, '', view.location.pathname + view.location.search + (family ? '#' + family : ''));
258
+ renderView();
259
+ };
208
260
  const click = async event => {
209
261
  const button = event.target.closest?.('[data-action]');
210
262
  if (!button) { view.requestAnimationFrame(paint); return; }
211
263
  if (button.dataset.action === 'save' && saving) return;
264
+ if (button.matches('a')) event.preventDefault();
212
265
  flush();
213
- if (!['preview-variation', 'apply-variation', 'cancel-variation'].includes(button.dataset.action)) clearPreview();
214
266
  try {
215
267
  switch (button.dataset.action) {
268
+ case 'replay-motion': replayMotion(); break;
216
269
  case 'undo': case 'redo':
217
270
  theme?.[button.dataset.action](); show(theme?.recipe ?? recipe, false); sayApplied('Previous theme decision restored.'); break;
218
- case 'preview-variation': {
219
- clearPreview();
220
- const locks = [...root.querySelectorAll('[data-family-lock]')].filter(control => control.querySelector('input')?.checked).map(control => control.dataset.familyLock);
221
- candidate = nearbyVariation(recipe, root.querySelector('[data-variation-choice]').dataset.selectedValue, locks);
222
- theme?.compare(candidate); root.querySelector('[data-variation-confirm]').hidden = false;
223
- const differences = themeFields.filter(field => recipe.values[field.id] !== candidate.values[field.id]);
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.';
225
- break;
226
- }
227
- case 'apply-variation': {
228
- const selected = candidate; clearPreview();
229
- if (selected) show(selected); sayApplied('Variation applied. Undo returns to your previous theme.'); break;
230
- }
231
- case 'cancel-variation': clearPreview(); paint(); say('Variation cancelled; your theme is unchanged.'); break;
232
- case 'light-top': case 'light-left': case 'light-center': {
233
- const positions = { 'light-top': [.5, 0], 'light-left': [.15, .15], 'light-center': [.5, .5] };
234
- const [x, y] = positions[button.dataset.action];
235
- show(refinedRecipe({ ...recipe.values, 'light-x': String(x), 'light-y': String(y) }));
236
- sayApplied('Light direction applied throughout your theme.'); break;
237
- }
238
- case 'refine-material': case 'refine-palette': {
271
+ case 'refine-material': case 'refine-palette': case 'refine-typography': case 'refine-layout': case 'refine-motion': case 'refine-themes': case 'refine-export': case 'refine-components': {
239
272
  const family = button.dataset.action.slice(7);
240
- refinementTrigger = button;
241
- root.querySelector('[data-customizer-overview]').hidden = true;
242
- for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = section.dataset.refinementView !== family;
243
- const section = root.querySelector(`[data-refinement-view="${family}"]`);
244
- paint(); section.focus(); section.scrollIntoView({ block: 'start' });
273
+ refinementTrigger = button; overviewScroll = view.scrollY;
274
+ navigate(family);
245
275
  break;
246
276
  }
247
- case 'back':
248
- for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = true;
249
- root.querySelector('[data-customizer-overview]').hidden = false;
250
- paint(); refinementTrigger?.focus(); break;
277
+ case 'back': navigate(''); break;
251
278
  case 'restore-material': case 'restore-palette': {
252
279
  const family = button.dataset.action.slice(8), state = refinementState(recipe, family);
253
280
  if (state.id !== 'custom') { show(applyRefinement(recipe, family, state.id)); sayApplied(`${family === 'material' ? 'Material' : 'Palette'} preset restored; other theme decisions are unchanged.`); }
@@ -302,7 +329,7 @@ export function connect(host) {
302
329
  if (event.target.matches('[data-light-editor]') || event.target.closest?.('[data-token]')) {
303
330
  input(event); flush(); sayApplied('Theme remembered in this browser. Undo restores the previous decision.'); return;
304
331
  }
305
- flush(); clearPreview();
332
+ flush();
306
333
  if (event.target.matches('[data-unit-for]')) {
307
334
  const field = fields.get(event.target.dataset.unitFor);
308
335
  try {
@@ -328,14 +355,14 @@ export function connect(host) {
328
355
  const choose = event => {
329
356
  if (!event.target.matches('[data-choice]') || event.detail.kind !== 'selection') return;
330
357
  event.target.dataset.selectedValue = event.detail.value;
331
- if (!event.target.matches('[data-variation-choice]')) change(event);
358
+ change(event);
332
359
  };
333
360
  const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
334
361
  const synchronize = () => { if (!pending && theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
335
362
  const cancel = event => { if (event.target.matches('[data-light-editor], [data-editor-kind="curve"]')) { flush(false); say('Drag cancelled; the previous value is restored.'); } };
336
363
  const keyboard = event => {
337
364
  if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z' || event.target.closest?.('input,textarea,select,[contenteditable="true"]')) return;
338
- event.preventDefault(); flush(); clearPreview(); theme?.[event.shiftKey ? 'redo' : 'undo'](); show(theme?.recipe ?? recipe, false);
365
+ event.preventDefault(); flush(); theme?.[event.shiftKey ? 'redo' : 'undo'](); show(theme?.recipe ?? recipe, false);
339
366
  };
340
367
  const ready = event => {
341
368
  if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
@@ -352,7 +379,7 @@ export function connect(host) {
352
379
  let fontRequest = 0;
353
380
  const selectFont = async event => {
354
381
  const control = event.target.closest?.('[data-font-picker]'); if (!control) return;
355
- flush(); clearPreview();
382
+ flush();
356
383
  const id = control.dataset.fontPicker, family = event.detail.value;
357
384
  const fonts = { ...recipe.fonts };
358
385
  if (family === 'system' || family === 'inherit') delete fonts[id]; else fonts[id] = family;
@@ -377,6 +404,11 @@ export function connect(host) {
377
404
  selectedExample = event.detail.value ?? '';
378
405
  filterExamples();
379
406
  };
407
+ const motionPreferenceChanged = () => { motionAnimation?.cancel(); paint(); };
408
+ const previewObserver = new view.ResizeObserver(updatePreviewLayout);
409
+ for (const element of [...previews, ...(header ? [header] : [])]) previewObserver.observe(element);
410
+ view.addEventListener('resize', updatePreviewLayout);
411
+ motionPreference.addEventListener('change', motionPreferenceChanged);
380
412
  root.addEventListener('toggle', updateSizes, true);
381
413
  root.addEventListener('looma-example-remove', remove);
382
414
  root.addEventListener('input', input);
@@ -389,9 +421,12 @@ export function connect(host) {
389
421
  root.addEventListener('change', change);
390
422
  root.addEventListener('click', click);
391
423
  view.addEventListener('message', ready);
424
+ view.addEventListener('popstate', renderView);
425
+ view.addEventListener('hashchange', renderView);
392
426
  view.addEventListener('beforeunload', flush);
393
427
  view.addEventListener(themeEvent, synchronize);
394
428
  show(recipe, false); filterExamples();
429
+ if (view.location.hash) renderView();
395
430
  if (theme?.storageAvailable === false) sayApplied('');
396
431
  if (root.dataset.mode === 'local') {
397
432
  fetch(root.dataset.base + '_looma/session', { signal: abort.signal }).then(async response => {
@@ -403,5 +438,5 @@ export function connect(host) {
403
438
  root.querySelector('[data-action="save"]').hidden = false;
404
439
  }).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
405
440
  }
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); };
441
+ return () => { previewObserver.disconnect(); view.removeEventListener('resize', updatePreviewLayout); motionPreference.removeEventListener('change', motionPreferenceChanged); root.removeEventListener('toggle', updateSizes, true); motionAnimation?.cancel(); flush(); 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('popstate', renderView); view.removeEventListener('hashchange', renderView); view.removeEventListener(themeEvent, synchronize); };
407
442
  }
package/docs/site.mjs CHANGED
@@ -144,7 +144,7 @@ export async function prepareDocumentation({ root, config = {}, catalog }) {
144
144
  const customizer = path.join(generated, 'customizer.html');
145
145
  await write(customizer, customizerResource({ catalog: combined, config, base }, config.recipe));
146
146
  const customizerPage = path.join(content, 'customize.md');
147
- await write(customizerPage, `---\ntitle: Customize\nsidebar: false\noutline: false\nprevious: false\nnext: false\n---\n\n# Weave your design system\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
147
+ await write(customizerPage, `---\ntitle: Customize\nsidebar: false\noutline: false\nprevious: false\nnext: false\n---\n\n# Customize your theme\n\n<link rel="component" href="${relativeHref(customizerPage, customizer)}">\n<looma-customizer></looma-customizer>\n`);
148
148
  await write(path.join(generated, 'catalog.json'), JSON.stringify(combined));
149
149
  const redirects = {};
150
150
  for (const component of combined.components) {