@threadlabs/looma 0.39.1 → 0.39.3

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 (50) hide show
  1. package/components/ui-button/ui-button.html +24 -3
  2. package/components/ui-checkbox/ui-checkbox.html +34 -18
  3. package/components/ui-combobox/ui-combobox.html +8 -3
  4. package/components/ui-combobox/ui-combobox.js +3 -2
  5. package/components/ui-dialog/ui-dialog.html +6 -52
  6. package/components/ui-input/ui-input.html +21 -3
  7. package/components/ui-input-group/ui-input-group.html +1 -0
  8. package/components/ui-light-editor/ui-light-editor.html +26 -25
  9. package/components/ui-select/ui-select.html +10 -3
  10. package/dist/index.js +1 -1
  11. package/docs/README.md +18 -7
  12. package/docs/catalog.json +2 -26
  13. package/docs/customizer-render.mjs +37 -33
  14. package/docs/customizer.js +82 -47
  15. package/docs/site.mjs +10 -6
  16. package/docs/theme-session.mjs +7 -8
  17. package/docs/theme.mjs +10 -22
  18. package/package.json +1 -1
  19. package/styles/ui-button.css +24 -1
  20. package/styles/ui-checkbox.css +39 -19
  21. package/styles/ui-combobox.css +16 -6
  22. package/styles/ui-dialog.css +6 -107
  23. package/styles/ui-input-group.css +1 -0
  24. package/styles/ui-input.css +24 -3
  25. package/styles/ui-light-editor.css +111 -0
  26. package/styles/ui-select.css +10 -3
  27. package/theme-system.css +4 -1
  28. package/tokens.css +9 -2
  29. package/vanilla/UiButton.js +14 -12
  30. package/vanilla/UiDialog.js +2 -2
  31. package/vanilla/UiLightEditor.js +2 -2
  32. package/vue/UiButton.vue +28 -7
  33. package/vue/UiCheckbox.vue +34 -19
  34. package/vue/UiCombobox.vue +22 -6
  35. package/vue/UiDialog.d.ts +3 -3
  36. package/vue/UiDialog.vue +18 -111
  37. package/vue/UiInput.vue +30 -3
  38. package/vue/UiInputGroup.vue +1 -0
  39. package/vue/UiLightEditor.vue +156 -40
  40. package/vue/UiSelect.vue +16 -3
  41. package/vue/chunks/UiButton.js +18 -10
  42. package/vue/chunks/UiCheckbox.js +1 -1
  43. package/vue/chunks/UiCombobox.js +1 -1
  44. package/vue/chunks/UiDialog.js +22 -14
  45. package/vue/chunks/UiInput.js +1 -1
  46. package/vue/chunks/UiInputGroup.js +1 -1
  47. package/vue/chunks/UiLightEditor.js +66 -58
  48. package/vue/chunks/UiSelect.js +1 -1
  49. package/vue/components.css +337 -160
  50. package/vue/editor/index.js +46 -36
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
 
@@ -245,7 +256,7 @@ Use the `@threadlabs/looma/docs` entrypoint for programmatic discovery, catalog
245
256
 
246
257
  ## Saving and recovery
247
258
 
248
- The local editor writes only the configured recipe and CSS inside the project. It rejects symlink paths, generated or dependency directories, cross-origin save requests and stale file revisions. A competing edit leaves your unsaved preview in place; reload and reconcile it before saving again. Examples run in isolated frames and receive theme CSS, never the local editing capability.
259
+ The local editor writes only the configured recipe and CSS inside the project. It rejects symlink paths, generated or dependency directories, cross-origin save requests and stale file revisions. A competing edit leaves your unsaved preview in place; reload and reconcile it before saving again. Examples run in isolated frames and receive theme CSS, never the local editing capability. Each preview's compiled runtime is embedded after its content and authorized by an exact content hash in the generated content policy. This keeps previews interactive in Safari while retaining their opaque origin and parent-page isolation.
249
260
 
250
261
  The writer stages complete files, serializes saves with a project lock, and rolls back the recipe if replacing the CSS fails. If a process stops during a save or build, close the old editor before removing its `.looma/theme-write.lock` or `.looma/docs-build.lock`; restart and save the loaded recipe to regenerate a consistent pair. Change the server base, output directory or theme-file paths by restarting `dev`.
251
262
 
package/docs/catalog.json CHANGED
@@ -458,7 +458,7 @@
458
458
  ],
459
459
  "schemaVersion": 1,
460
460
  "package": "@threadlabs/looma",
461
- "version": "0.39.1",
461
+ "version": "0.39.3",
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": {}
@@ -7430,24 +7430,12 @@
7430
7430
  {
7431
7431
  "name": "--ui-border-width"
7432
7432
  },
7433
- {
7434
- "name": "--ui-control-min-block-size"
7435
- },
7436
- {
7437
- "name": "--ui-control-size-sm"
7438
- },
7439
7433
  {
7440
7434
  "name": "--ui-elevation-dialog",
7441
7435
  "fallbacks": [
7442
7436
  "var(--ui-shadow-xl)"
7443
7437
  ]
7444
7438
  },
7445
- {
7446
- "name": "--ui-focus-ring"
7447
- },
7448
- {
7449
- "name": "--ui-focus-width"
7450
- },
7451
7439
  {
7452
7440
  "name": "--ui-font-semibold"
7453
7441
  },
@@ -7493,9 +7481,6 @@
7493
7481
  {
7494
7482
  "name": "--ui-motion-ease"
7495
7483
  },
7496
- {
7497
- "name": "--ui-motion-fast"
7498
- },
7499
7484
  {
7500
7485
  "name": "--ui-overlay-backdrop"
7501
7486
  },
@@ -7527,9 +7512,6 @@
7527
7512
  {
7528
7513
  "name": "--ui-radius-dialog"
7529
7514
  },
7530
- {
7531
- "name": "--ui-radius-round"
7532
- },
7533
7515
  {
7534
7516
  "name": "--ui-shadow-xl"
7535
7517
  },
@@ -7557,9 +7539,6 @@
7557
7539
  "linear-gradient(transparent, transparent)"
7558
7540
  ]
7559
7541
  },
7560
- {
7561
- "name": "--ui-surface-muted"
7562
- },
7563
7542
  {
7564
7543
  "name": "--ui-surface-separation",
7565
7544
  "fallbacks": [
@@ -7568,9 +7547,6 @@
7568
7547
  },
7569
7548
  {
7570
7549
  "name": "--ui-text-primary"
7571
- },
7572
- {
7573
- "name": "--ui-text-secondary"
7574
7550
  }
7575
7551
  ]
7576
7552
  },
@@ -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
  }