@threadlabs/looma 0.35.1 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/components/ui-button/ui-button.html +11 -4
  2. package/components/ui-color-picker/ui-color-picker.html +1 -1
  3. package/components/ui-combobox/ui-combobox.html +1 -1
  4. package/components/ui-curve-editor/ui-curve-editor.html +1 -0
  5. package/components/ui-curve-editor/ui-curve-editor.js +15 -6
  6. package/components/ui-input/ui-input.html +4 -1
  7. package/components/ui-light-editor/ui-light-editor.html +143 -19
  8. package/components/ui-light-editor/ui-light-editor.js +26 -9
  9. package/components/ui-select/ui-select.html +10 -6
  10. package/dist/index.js +1 -1
  11. package/docs/README.md +33 -7
  12. package/docs/card.js +1 -0
  13. package/docs/catalog.json +37 -3
  14. package/docs/customizer-render.mjs +26 -8
  15. package/docs/customizer.js +122 -18
  16. package/docs/fonts.mjs +45 -0
  17. package/docs/index.d.ts +2 -2
  18. package/docs/palette.mjs +67 -0
  19. package/docs/site.mjs +1 -1
  20. package/docs/theme-session.mjs +28 -9
  21. package/docs/theme.mjs +67 -27
  22. package/docs/value-editors.mjs +6 -4
  23. package/package.json +2 -2
  24. package/styles/ui-button.css +11 -4
  25. package/styles/ui-color-picker.css +1 -1
  26. package/styles/ui-combobox.css +2 -2
  27. package/styles/ui-input.css +4 -1
  28. package/styles/ui-light-editor.css +111 -16
  29. package/styles/ui-select.css +10 -6
  30. package/theme-system.css +14 -2
  31. package/tokens.css +10 -0
  32. package/vanilla/UiCurveEditor.d.ts +1 -0
  33. package/vanilla/UiCurveEditor.js +1 -1
  34. package/vanilla/UiLightEditor.d.ts +1 -0
  35. package/vanilla/UiLightEditor.js +2 -2
  36. package/vue/UiButton.vue +4 -2
  37. package/vue/UiColorPicker.vue +1 -1
  38. package/vue/UiCombobox.vue +2 -2
  39. package/vue/UiCurveEditor.d.ts +6 -0
  40. package/vue/UiCurveEditor.vue +2 -0
  41. package/vue/UiInput.vue +4 -1
  42. package/vue/UiLightEditor.d.ts +8 -0
  43. package/vue/UiLightEditor.vue +22 -3
  44. package/vue/UiSelect.vue +1 -1
  45. package/vue/chunks/UiButton.js +1 -1
  46. package/vue/chunks/UiColorPicker.js +1 -1
  47. package/vue/chunks/UiCombobox.js +1 -1
  48. package/vue/chunks/UiCurveEditor.js +5 -3
  49. package/vue/chunks/UiInput.js +1 -1
  50. package/vue/chunks/UiLightEditor.js +16 -6
  51. package/vue/chunks/UiSelect.js +1 -1
  52. package/vue/components.css +41 -14
package/docs/README.md CHANGED
@@ -40,11 +40,11 @@ pnpm exec looma-docs preview
40
40
 
41
41
  ## Change the rule, keep the family consistent
42
42
 
43
- The authoring model groups shared decisions as Colour, Type, Size & spacing, Material and Motion. The controls expose shared intent: a control size, spaciousness, corners, inner and outer depth, and response pace.
43
+ The authoring model groups shared decisions as Colour, Type, Size & spacing, Material and Motion. The controls expose shared intent: control roominess relative to text, spacing, corners, inner and outer depth, and response pace.
44
44
 
45
- The generated values follow rules. Small and large control heights follow a ratio around the shared control size. Spacing follows an independent curve: linear at 1, increasingly curved toward 2, with stable endpoints so the largest gaps do not grow without bound. Depth generates families of shadows; it does not add motion amplitude. Fields and buttons with the same size setting use the same box height, while content can still grow when it needs more room.
45
+ The generated values follow rules. The medium control height is base text size times control roominess; small and large heights follow the size ratio. On touch, small rises to at least the comfortable 44px floor and medium/large grow from it, preserving the ratio. Spacing derives from base text independently of control roominess and follows its own curve: linear at 1, increasingly curved toward 2, with stable endpoints so the largest gaps do not grow without bound. Depth generates families of shadows; it does not add motion amplitude. Fields and buttons with the same size setting use the same box height, while content can still grow when it needs more room.
46
46
 
47
- Choose a coordinated palette to change related paper, ink and action seeds together without changing geometry or motion. Palette seeds remain available for precise edits; the light/dark range previews and resolved values explain readability adjustments.
47
+ 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. Tonal keeps seed hues; neighboring and complementary relate quiet hues to the action hue. 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 color seeds remain available for precise edits; the light/dark range previews and resolved values explain adjustments.
48
48
 
49
49
  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.
50
50
 
@@ -52,19 +52,45 @@ The main customizer offers Material and Palette presets. **Refine material & lig
52
52
 
53
53
  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.
54
54
 
55
- Number controls accept arithmetic such as `42px / 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.
55
+ 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.
56
56
 
57
- Lengths retain their CSS units, such as `rem`; times retain `ms` or `s`. The downloaded CSS includes the canonical shared recipe as well as your inputs. 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.
57
+ 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.
58
+
59
+ Undo and Redo move through accepted recipe changes. **Compare entry theme** temporarily previews the theme present when this customizer opened; it does not replace your edits. **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.
58
60
 
59
61
  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 returns to the repository baseline. Invalid input keeps the last valid preview and explains which decision needs attention.
60
62
 
61
63
  Text offers body, display, heading, label, caption, eyebrow and code roles. Choose a heading's document level with `as` independently from its visual role. Button and IconButton keep appearance and tone independent, with four optional shortcuts: primary (solid accent), standard (solid neutral), quiet (ghost neutral) and destructive (solid danger). A named preset takes precedence over the separate choices; `preset="none"` preserves their existing defaults.
62
64
 
65
+ ## Theme decisions and public properties
66
+
67
+ The [public token reference](https://github.com/threadlabs-studio/looma/blob/main/apps/docs-next/docs/03.%28Foundations%29/tokens.md#optional-semantic-theme-recipe) lists defaults, customizer ranges, dependencies and derived outputs for every family. The recipe's `values` keys map to public CSS properties or generator decisions as follows. Values are strings; omitted keys take defaults and unknown keys are rejected.
68
+
69
+ | Family | Recipe decisions → CSS properties |
70
+ | --- | --- |
71
+ | Palette seeds | `action` → `--ui-accent`; `canvas` → `--ui-surface`; `surface` → `--ui-surface-raised`; `text` → `--ui-text` |
72
+ | Palette generation (recipe only) | `warmth`, `color-intensity`, `palette-relationship`; compiled into resolved color roles, with no matching CSS custom properties |
73
+ | Type | `body-font` → `--ui-font-sans`; `display-font` → `--ui-font-display`; `text-size` → `--ui-font-size`; `text-weight` → `--ui-font-medium` |
74
+ | Size & spacing | `control-roominess` → `--ui-control-roominess`; `spacing` → `--ui-spacing`; `scale-contrast` → `--ui-scale-ratio`; `space-curve` → `--ui-space-curve`; `corner-roundness` → `--ui-radius-md`; `line-weight` → `--ui-border-width` |
75
+ | Depth and transmission | `outer-depth` → `--ui-outer-depth`; `inner-depth` → `--ui-inner-depth`; `glassiness` → `--ui-glassiness`; `surface-separation` → `--ui-surface-separation`; `translucency` → `--ui-overlay-translucency` |
76
+ | Finish and illumination | `finish` → `--ui-finish`; `edge-light` → `--ui-edge-light`; `glow` → `--ui-glow`; `light-x` → `--ui-light-x`; `light-y` → `--ui-light-y`; `light-spread` → `--ui-light-spread`; `light-strength` → `--ui-light-strength` |
77
+ | Motion | `response-time` → `--ui-motion-fast`; `response-curve` → `--ui-motion-ease` |
78
+
79
+ Palette generation decisions exist only in the JSON recipe; edit it and regenerate to change resolved colors. Geometry, material and motion properties feed the included CSS recipe. `--ui-control-size` remains a derived public height role: author numeric roominess (default 2.5, range 2–4) instead of an absolute control size. Spacing (default 1, range 0.75–1.5) changes space without changing heights. Base text defaults to `1rem`. Relative length bounds use `rem` endpoints in generated CSS.
80
+
81
+ Before 1.0 there are no compatibility aliases. Migrate recipe `density` to `spacing` and CSS `--ui-density` to `--ui-spacing`. Replace length-valued recipe `control-size` with numeric `control-roominess` by dividing the desired medium height by base text size: `40px / 16px` becomes `2.5`.
82
+
83
+ ## Choose fonts
84
+
85
+ **System font** is the default and uses the operating system's native `system-ui` face without a download. Search the curated Google Fonts collection for body and display roles; selected faces load in the previews. Generated/downloaded CSS and locally saved CSS include the selected Google Fonts `@import` before rules, so the consuming application loads the same faces.
86
+
87
+ An optional recipe `fonts` mapping records Google family names by `body-font` and/or `display-font`, while `values` keeps their matching CSS stacks. Import/export preserves both. The advanced **Custom font stack** field is for already loaded or self-hosted faces; it does not download fonts. Load those faces in the consuming application.
88
+
63
89
  ## Describe material and light
64
90
 
65
91
  Light position is relative to each surface: horizontal and vertical positions run from 0 to 1, with 0.5, 0 at top centre. Drag the source, use its arrow keys (Shift moves faster), or enter exact percentages. Home returns it to top centre. Spread runs from a concentrated spot to broad illumination; strength controls its intensity. The source influences reflections, illuminated edges and the direction of depth shadows throughout the theme.
66
92
 
67
- Finish controls how strongly a material catches a reflection, from matte to polished. Edge light catches its boundary; glow spills softly beyond it. Glassiness and surface separation govern the decorative material around floating content, scaled by popup purpose and rendered size. The content plane stays opaque. Reflection peaks are bounded for readable text, and reduced transparency disables the ornamental reflection and glow as well as backdrop blur.
93
+ Depth controls elevation and recession independently from finish, transmission and motion. Finish controls how strongly a material catches a reflection, from matte to polished; it needs nonzero light strength. Light spread shapes that reflection, while position also directs shadows. A light strength of zero does not remove depth shadows. Edge light catches its boundary; glow spills softly beyond it. Glassiness and surface separation govern the decorative material around floating content, scaled by popup purpose and rendered size. The content plane stays opaque. Overlay translucency changes the shared overlay fill and decorative glass; it does not turn material content transparent. Separation can create a rim even with glassiness at zero. Reflection peaks are bounded for readable text, and reduced transparency disables the ornamental reflection and glow as well as backdrop blur.
68
94
 
69
95
  Card keeps surface structure and decorative treatment independent:
70
96
 
@@ -118,7 +144,7 @@ Opt into source directories in `looma.docs.json`:
118
144
  }
119
145
  ```
120
146
 
121
- `content` and `overrides` are optional. Create files you configure before building. Markdown guidance supplements the generated reference; it cannot replace an API page. Relative component and asset dependencies keep their original project locations, and code examples remain literal. Local web fonts referenced by override CSS are embedded in the documentation's theme stylesheet so isolated examples render the same fonts on static hosts. Load your chosen web fonts in the consuming application; downloaded theme CSS selects their font families.
147
+ `content` and `overrides` are optional. Create files you configure before building. Markdown guidance supplements the generated reference; it cannot replace an API page. Relative component and asset dependencies keep their original project locations, and code examples remain literal. Local web fonts referenced by override CSS are embedded in the documentation's theme stylesheet so isolated examples render the same fonts on static hosts. Google font selections travel in the generated CSS import. Load custom or self-hosted faces in the consuming application; their stacks only select families.
122
148
 
123
149
  A resource can use any valid HTML Next component tag:
124
150
 
package/docs/card.js CHANGED
@@ -10,6 +10,7 @@ export function connect(host) {
10
10
  const documentTheme = () => { theme = documentationTheme(view)?.css ?? ''; send(); };
11
11
  const preference = view.matchMedia('(prefers-color-scheme: dark)');
12
12
  const send = () => {
13
+ if (!host.element.getClientRects().length) return;
13
14
  const scheme = view.getComputedStyle(host.element).colorScheme;
14
15
  const mode = scheme === 'dark' || (scheme.includes('dark') && scheme.includes('light') && preference.matches) ? 'dark' : 'light';
15
16
  frame.contentWindow?.postMessage({ type: 'looma-theme', css: theme, mode, reference: Boolean(host.props.reference.value) }, '*');
package/docs/catalog.json CHANGED
@@ -443,7 +443,7 @@
443
443
  ],
444
444
  "schemaVersion": 1,
445
445
  "package": "@threadlabs/looma",
446
- "version": "0.35.1",
446
+ "version": "0.36.0",
447
447
  "groups": [
448
448
  {
449
449
  "id": "layout",
@@ -4489,7 +4489,7 @@
4489
4489
  "var(--ui-control-size-sm)",
4490
4490
  "var(--ui-control-size-lg)",
4491
4491
  "max(var(--ui-control-size-md), var(--ui-control-min-block-size))",
4492
- "var(--ui-control-min-block-size)"
4492
+ "max(var(--ui-control-size-sm), var(--ui-control-min-block-size))"
4493
4493
  ]
4494
4494
  },
4495
4495
  {
@@ -6785,6 +6785,19 @@
6785
6785
  }
6786
6786
  ],
6787
6787
  "events": [
6788
+ {
6789
+ "name": "cancel",
6790
+ "detailType": "{ readonly value: string }",
6791
+ "detailSchema": "object",
6792
+ "fields": [
6793
+ {
6794
+ "path": "value",
6795
+ "type": "string",
6796
+ "required": true
6797
+ }
6798
+ ],
6799
+ "detailDocs": "Emitted with the declared detail payload."
6800
+ },
6788
6801
  {
6789
6802
  "name": "change",
6790
6803
  "detailType": "{ readonly value: string }",
@@ -13365,7 +13378,7 @@
13365
13378
  "var(--ui-control-size-sm)",
13366
13379
  "var(--ui-control-size-lg)",
13367
13380
  "max(var(--ui-control-size-md), var(--ui-control-min-block-size))",
13368
- "var(--ui-control-min-block-size)"
13381
+ "max(var(--ui-control-size-sm), var(--ui-control-min-block-size))"
13369
13382
  ]
13370
13383
  },
13371
13384
  {
@@ -14112,6 +14125,9 @@
14112
14125
  {
14113
14126
  "name": "--ui-focus-width"
14114
14127
  },
14128
+ {
14129
+ "name": "--ui-font-size-lg"
14130
+ },
14115
14131
  {
14116
14132
  "name": "--ui-font-size-sm"
14117
14133
  },
@@ -14227,6 +14243,24 @@
14227
14243
  }
14228
14244
  ],
14229
14245
  "events": [
14246
+ {
14247
+ "name": "cancel",
14248
+ "detailType": "{ readonly x: number; readonly y: number }",
14249
+ "detailSchema": "object",
14250
+ "fields": [
14251
+ {
14252
+ "path": "x",
14253
+ "type": "number",
14254
+ "required": true
14255
+ },
14256
+ {
14257
+ "path": "y",
14258
+ "type": "number",
14259
+ "required": true
14260
+ }
14261
+ ],
14262
+ "detailDocs": "Emitted with the declared detail payload."
14263
+ },
14230
14264
  {
14231
14265
  "name": "change",
14232
14266
  "detailType": "{ readonly x: number; readonly y: number }",
@@ -1,7 +1,8 @@
1
+ import { googleFonts, systemFont } from './fonts.mjs';
1
2
  import { escapeHtml as escape } from './catalog.mjs';
2
3
  import { componentPath, previewPath } from './render.mjs';
3
4
  import { valueEditor } from './value-editors.mjs';
4
- import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState } from './theme.mjs';
5
+ import { defaultRecipe, themeFields, startingPointLabels, startingPointFor, themeCSS, validateRecipe, themePalettes, materialPresets, refinementState, themeVariations } from './theme.mjs';
5
6
 
6
7
  export function customizerResource(site, input = defaultRecipe()) {
7
8
  const recipe = validateRecipe(input);
@@ -10,11 +11,20 @@ export function customizerResource(site, input = defaultRecipe()) {
10
11
  const toCard = example => Object.fromEntries(cardFields.map(key => [key, example[key]]));
11
12
  const initial = ['ui-button', 'ui-input', 'ui-color-picker', 'ui-checkbox', 'ui-card', 'ui-switch', 'ui-text', 'ui-dialog', 'ui-popover'].map(tag => examples.find(example => example.tag === tag && (tag !== 'ui-button' || example.id.endsWith('/02-variant')) && (tag !== 'ui-text' || example.id.endsWith('/02-variants')) && (tag !== 'ui-card' || example.id.endsWith('/04-expressive-surface'))) ?? examples.find(example => example.tag === tag)).filter(Boolean).map(toCard);
12
13
  if (!initial.length && examples.length) initial.push(toCard(examples[0]));
14
+ const documentationCard = (tag, suffix) => {
15
+ const example = examples.find(item => item.tag === tag && (!suffix || item.id.endsWith(suffix))) ?? examples.find(item => item.tag === tag);
16
+ return example ? `<looma-example-card ${cardFields.filter(key => key !== 'id').map(key => `${key.replace(/[A-Z]/g, letter => '-' + letter.toLowerCase())}="${escape(String(example[key]))}"`).join(' ')} identity="${escape(example.id)}" loading="lazy"></looma-example-card>` : '';
17
+ };
13
18
  const renderField = field => {
14
19
  const value = recipe.values[field.id], editor = valueEditor(field, value);
15
20
  const common = `size="sm" data-token="${field.id}" label="${escape(field.label)}"`;
16
21
  let control;
17
- if (field.type === 'easing') {
22
+ if (field.type === 'font') {
23
+ const selected = recipe.fonts?.[field.id] ?? (value === systemFont ? 'system' : 'custom');
24
+ control = `<ui-combobox id="theme-${field.id}" data-font-picker="${field.id}" label="${escape(field.label)}" value="${escape(selected)}" placeholder="Search Google Fonts…" disclosure><option value="system">System font</option><option value="custom" disabled>Custom font stack</option>${Object.values(googleFonts).map(font => `<option value="${escape(font.family)}" data-description="Google Fonts · ${font.category}">${escape(font.family)}</option>`).join('')}</ui-combobox><ui-text variant="${field.id === 'display-font' ? 'heading' : 'body'}">Frodo’s quarterly review · Aa 0123</ui-text><ui-text variant="caption" data-font-status="${field.id}" role="status">System fonts need no download. Google selections load in this preview and travel in your CSS.</ui-text><ui-disclosure summary="Custom font stack"><ui-input id="theme-${field.id}-custom" ${common} value="${escape(value)}" aria-label="${escape(field.label)} stack"></ui-input><ui-text variant="caption">For an already loaded or self-hosted font.</ui-text></ui-disclosure>`;
25
+ } else if (field.type === 'choice') {
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
+ } else if (field.type === 'easing') {
18
28
  control = `<ui-input id="theme-${field.id}" size="sm" data-token="${field.id}" aria-label="${escape(field.label)}" value="${escape(value)}"></ui-input><div data-curve-for="${field.id}"${editor.kind === 'text' ? ' hidden' : ''}><ui-curve-editor ${common} data-editor-kind="curve" value="${escape(value)}"></ui-curve-editor></div>`;
19
29
  } else if (editor.kind === 'number' || editor.kind === 'slider') {
20
30
  const tag = editor.kind === 'slider' ? 'ui-slider' : 'ui-number-input';
@@ -30,24 +40,27 @@ export function customizerResource(site, input = defaultRecipe()) {
30
40
  const state = refinementState(recipe, family), label = family === 'material' ? 'Material' : 'Palette';
31
41
  return `<ui-form-field><label slot="label" for="${location}-${family}">${label} preset</label><ui-select id="${location}-${family}" data-refinement-preset="${family}" aria-label="${label} preset" value="${state.id}"><option value="custom" disabled>Your ${family}</option>${Object.entries(presets).map(([id, preset]) => `<option value="${id}">${escape(preset.label)}${state.id === id && state.customized ? ' · customized' : ''}</option>`).join('')}</ui-select></ui-form-field>`;
32
42
  };
33
- const paletteDetails = `<ui-cluster>${['light', 'dark'].map(mode => `<ui-card data-theme="${mode}" padding="sm"><ui-text variant="label">${mode === 'light' ? 'Light' : 'Dark'} range</ui-text><div class="palette-range" role="img" aria-label="${mode} canvas, content and action range"><span class="palette-canvas"></span><span class="palette-content"></span><span class="palette-action"></span></div><ui-text variant="caption">Fellowship office</ui-text></ui-card>`).join('')}</ui-cluster><p data-palette-adjustments role="status" aria-live="polite"></p><ui-disclosure summary="Resolved palette values"><p data-palette-adjustment-details></p></ui-disclosure>`;
43
+ const paletteDetails = `<ui-cluster>${['light', 'dark'].map(mode => `<ui-card data-theme="${mode}" padding="sm"><ui-text variant="label">${mode === 'light' ? 'Light' : 'Dark'} range</ui-text><div class="palette-range" role="img" aria-label="${mode} canvas, content and action range"><span class="palette-canvas"></span><span class="palette-content"></span><span class="palette-action"></span></div><ui-text variant="caption">Fellowship office</ui-text>${documentationCard('ui-text', '/02-variants')}</ui-card>`).join('')}</ui-cluster><p data-palette-adjustments role="status" aria-live="polite"></p><ui-disclosure summary="Resolved palette values"><p data-palette-adjustment-details></p></ui-disclosure>`;
34
44
  const supplements = {
35
45
  'Colour': `${renderPreset('palette', 'overview')}<ui-text variant="caption">Coordinated light and dark ranges.</ui-text><ui-button data-action="refine-palette">Refine palette</ui-button>`,
36
46
  '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>`,
37
- 'Size & spacing': `<ui-cluster><ui-button size="sm">Small</ui-button><ui-button>Medium</ui-button><ui-button size="lg">Large</ui-button></ui-cluster><ui-disclosure summary="How the scale grows"><ui-stack><p>Control size anchors one shared height. Size ratio separates small from large. Density and spacing curve change breathing room independently.</p>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
47
+ 'Motion': `<ui-text variant="caption">Open and close this real documentation example to feel the response pace and curve. Reduced motion follows your system preference.</ui-text>${documentationCard('ui-popover')}`,
48
+ 'Size & spacing': `<ui-text variant="caption">Shared size ladder: buttons, fields and swatches align at each step. Touch raises the family together.</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]}</ui-text><ui-cluster wrap="nowrap"><ui-button size="${size}">Action</ui-button><ui-input size="${size}" label="${size} size sample" value="Frodo"></ui-input><ui-color-picker size="${size}" label="${size} swatch sample"></ui-color-picker></ui-cluster></ui-stack>`).join('')}</ui-stack><div class="spacing-samples" data-spacing-samples role="img" aria-label="Spacing progression"><span></span><span></span><span></span><span></span></div><ui-disclosure summary="How the scale grows"><ui-stack><p>Control roominess relates every field and button to base text. Size ratio separates small from large. On touch, the whole family grows together to keep targets comfortable. Spacing and its curve shape page rhythm independently.</p>${themeFields.filter(item => ['scale-contrast','space-curve'].includes(item.id)).map(renderField).join('')}</ui-stack></ui-disclosure>`,
38
49
  };
39
50
  const families = [...new Set(themeFields.map(field => field.family))].filter(family => !['Colour', 'Material'].includes(family)).map(family => `<ui-disclosure summary="${family}"><ui-stack>${themeFields.filter(item => item.family === family && !['scale-contrast', 'space-curve'].includes(item.id)).map(renderField).join('')}${supplements[family] ?? ''}</ui-stack></ui-disclosure>`).join('');
40
51
  const refinement = (family, content) => `<section data-refinement-view="${family}" hidden tabindex="-1" aria-label="${family === 'material' ? 'Material and light refinement' : 'Palette refinement'}"><ui-stack gap="xl"><ui-cluster justify="between"><ui-button data-action="back">Back to customization</ui-button><ui-button data-action="restore-${family}">Restore ${family} preset</ui-button></ui-cluster><ui-text variant="heading" as="h2">${family === 'material' ? 'Refine material & light' : 'Refine your palette'}</ui-text><p>These are advanced refinements of the same persistent theme. Every change applies throughout your documentation.</p>${renderPreset(family, 'refinement')}${content}</ui-stack></section>`;
41
- const materialView = refinement('material', `<div class="refinement-layout"><ui-stack><ui-light-editor data-light-editor x="${recipe.values['light-x']}" y="${recipe.values['light-y']}" label="Theme light position"><ui-stack gap="s"><ui-cluster><ui-button preset="primary">Join briefing</ui-button><ui-button preset="standard">Read memo</ui-button></ui-cluster><ui-input label="Office search" placeholder="Search the Fellowship office"></ui-input></ui-stack></ui-light-editor><ui-card variant="elevated" treatment="expressive"><ui-text variant="eyebrow">Fellowship office</ui-text><ui-text variant="heading" as="h3">Second breakfast briefing</ui-text><ui-text>Sam has prepared the agenda. Frodo has the maps, and Sméagol is taking minutes.</ui-text><ui-cluster><ui-button preset="primary">Join briefing</ui-button><ui-button preset="quiet">Read agenda</ui-button></ui-cluster></ui-card><ui-card variant="elevated"><ui-text variant="label">Standard elevated surface</ui-text><ui-text>Subtle lift stays independent from expressive treatment.</ui-text></ui-card></ui-stack><ui-stack><ui-text variant="label">Surface response</ui-text>${themeFields.filter(field => ['finish','edge-light','glow'].includes(field.id)).map(renderField).join('')}<ui-text variant="label">Light source</ui-text>${themeFields.filter(field => ['light-spread','light-strength'].includes(field.id)).map(renderField).join('')}<ui-disclosure summary="Depth, glass and separation"><ui-stack>${themeFields.filter(field => ['outer-depth','inner-depth','glassiness','surface-separation','translucency'].includes(field.id)).map(renderField).join('')}</ui-stack></ui-disclosure></ui-stack></div>`);
42
- const paletteView = refinement('palette', `<div class="refinement-layout"><ui-stack>${paletteDetails}<ui-card><ui-text variant="eyebrow">Fellowship office</ui-text><ui-text variant="heading" as="h3">There and back by Friday</ui-text><ui-text>Choose a coordinated range, then refine action, paper and ink. Readable roles follow automatically in both modes.</ui-text><ui-cluster><ui-button preset="primary">Share route</ui-button><ui-button preset="standard">Save draft</ui-button></ui-cluster></ui-card></ui-stack><ui-stack>${themeFields.filter(field => field.family === 'Colour').map(renderField).join('')}</ui-stack></div>`);
52
+ const materialView = refinement('material', `<div class="refinement-layout"><ui-stack><ui-light-editor data-light-editor x="${recipe.values['light-x']}" y="${recipe.values['light-y']}" label="Theme light position"><ui-stack gap="s"><ui-cluster><ui-button preset="primary">Join briefing</ui-button><ui-button preset="standard">Read memo</ui-button></ui-cluster><ui-input label="Office search" placeholder="Search the Fellowship office"></ui-input></ui-stack></ui-light-editor><ui-cluster><ui-button size="sm" data-action="light-top">Top light</ui-button><ui-button size="sm" data-action="light-left">Upper left</ui-button><ui-button size="sm" data-action="light-center">Centered</ui-button></ui-cluster><ui-text variant="caption" data-material-dependencies role="status"></ui-text>${documentationCard('ui-card','/04-expressive-surface')}<ui-disclosure summary="Try floating surfaces"><ui-stack>${documentationCard('ui-tooltip')}${documentationCard('ui-popover')}${documentationCard('ui-dialog')}</ui-stack></ui-disclosure></ui-stack><ui-stack><ui-text variant="label">Surface response</ui-text>${themeFields.filter(field => ['finish','edge-light','glow'].includes(field.id)).map(renderField).join('')}<ui-text variant="label">Light source</ui-text>${themeFields.filter(field => ['light-spread','light-strength'].includes(field.id)).map(renderField).join('')}<ui-disclosure summary="Depth, glass and separation"><ui-stack>${themeFields.filter(field => ['outer-depth','inner-depth','glassiness','surface-separation','translucency'].includes(field.id)).map(renderField).join('')}</ui-stack></ui-disclosure></ui-stack></div>`);
53
+ const paletteView = refinement('palette', `<div class="refinement-layout"><ui-stack>${paletteDetails}<ui-card><ui-text variant="eyebrow">Fellowship office</ui-text><ui-text variant="heading" as="h3">There and back by Friday</ui-text><ui-text>Choose a palette, then shift warmth, intensity or the relationship of surface hues. Both modes keep one identity, with readable roles derived automatically.</ui-text><ui-cluster><ui-button preset="primary">Share route</ui-button><ui-button preset="standard">Save draft</ui-button></ui-cluster></ui-card></ui-stack><ui-stack>${themeFields.filter(field => field.family === 'Colour' && field.type !== 'colour').map(renderField).join('')}<ui-disclosure summary="Precise color seeds"><ui-stack>${themeFields.filter(field => field.type === 'colour').map(renderField).join('')}</ui-stack></ui-disclosure></ui-stack></div>`);
43
54
  const options = site.catalog.groups.map(group => `<optgroup label="${escape(group.label)}">${examples.filter(example => example.category === group.id).map(example => `<option value="${escape(example.id)}" data-description="${escape(example.category + ' · ' + example.tag)}" data-example="${escape(JSON.stringify(example))}" data-search="${escape(`${example.title} ${example.tag} ${example.description} ${example.exampleTitle}`.toLocaleLowerCase())}">${escape(example.title)} · ${escape(example.exampleTitle)}</option>`).join('')}</optgroup>`).join('');
44
- const links = ['ui-stack','ui-grid','ui-cluster','ui-form-field','ui-input','ui-select','ui-color-picker','ui-button','ui-disclosure','ui-textarea','ui-number-input','ui-slider','ui-curve-editor','ui-card','ui-text','ui-light-editor','ui-combobox'].map(tag => `<link rel="component" href="@threadlabs/looma/components/${tag}/${tag}.html">`).join('\n');
55
+ 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');
45
56
  return `${links}\n<link rel="component" href="./card.html">\n<template component="looma-customizer" module="./customizer.js"><defs>
46
57
  <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>
47
58
  </defs>
48
59
  <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 ?? '')}">
49
60
  <ui-stack gap="l"><ui-text>Choose a starting point, refine shared decisions, and see them on real documentation examples.</ui-text>
50
61
  <ui-text variant="caption" role="status" aria-live="polite" data-message>Changes are remembered across the documentation.</ui-text>
62
+ <ui-cluster><ui-text variant="label" data-theme-origin></ui-text><ui-button size="sm" data-action="undo" disabled>Undo</ui-button><ui-button size="sm" data-action="redo" disabled>Redo</ui-button><ui-button size="sm" data-action="compare" aria-pressed="false">Compare entry theme</ui-button></ui-cluster>
63
+ <ui-disclosure summary="Explore nearby styles"><ui-stack><ui-text variant="caption">Preview a small change before applying it. Locks preserve your selected families.</ui-text><ui-cluster>${[['palette','Palette'],['material','Material'],['type','Type & spacing']].map(([id,label]) => `<ui-checkbox size="sm" data-family-lock="${id}">Lock ${label}</ui-checkbox>`).join('')}</ui-cluster><ui-form-field><label slot="label" for="nearby-style">Nearby style</label><ui-select id="nearby-style" data-variation-choice>${Object.entries(themeVariations).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}</ui-select></ui-form-field><ui-button data-action="preview-variation">Preview variation</ui-button><div data-variation-confirm hidden><ui-stack><ui-text data-variation-description role="status"></ui-text><ui-cluster><ui-button preset="primary" data-action="apply-variation">Apply variation</ui-button><ui-button data-action="cancel-variation">Cancel preview</ui-button></ui-cluster></ui-stack></div></ui-stack></ui-disclosure>
51
64
  ${materialView}${paletteView}
52
65
  <div class="customizer-layout" data-customizer-overview>
53
66
  <aside aria-label="Theme controls"><ui-stack><ui-form-field><label slot="label" for="theme-start">Starting point</label><ui-select id="theme-start" data-preset value="${startingPointFor(recipe)}"><option value="custom" disabled>Your theme</option>${Object.entries(startingPointLabels).map(([id,label]) => `<option value="${id}">${label}</option>`).join('')}</ui-select></ui-form-field>
@@ -65,12 +78,17 @@ export function customizerResource(site, input = defaultRecipe()) {
65
78
  <noscript>Enable JavaScript to edit the theme. The component reference and example previews remain available.</noscript>
66
79
  </ui-stack></section></div></ui-stack></div>
67
80
  <style>
68
- [data-customizer-overview][hidden], [data-refinement-view][hidden] { display: none !important; }
81
+ [data-customizer-overview][hidden], [data-refinement-view][hidden], [data-variation-confirm][hidden] { display: none !important; }
69
82
  .customizer-layout { display: grid !important; grid-template-columns: minmax(16rem, 20rem) minmax(0, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
70
83
  aside { position: sticky; inset-block-start: 5rem; max-block-size: calc(100dvh - 6rem); overflow: auto; padding-inline-end: var(--ui-space-2) !important; }
71
84
  .examples { min-inline-size: 0; }
72
85
  .refinement-layout { display: grid !important; grid-template-columns: minmax(0, 1.2fr) minmax(16rem, 1fr); gap: var(--ui-space-6) !important; align-items: start !important; }
73
86
  [data-refinement-view] { scroll-margin-block-start: 5rem; }
87
+ .spacing-samples { display: flex !important; align-items: center !important; gap: 0 !important; padding-block: var(--ui-space-3) !important; }
88
+ .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); }
89
+ .spacing-samples > span:nth-child(2) { margin-inline-start: var(--ui-space-2) !important; }
90
+ .spacing-samples > span:nth-child(3) { margin-inline-start: var(--ui-space-3) !important; }
91
+ .spacing-samples > span:nth-child(4) { margin-inline-start: var(--ui-space-4) !important; }
74
92
  .palette-range { display: flex !important; min-block-size: var(--ui-control-size-sm); border: var(--ui-border-width) solid var(--ui-control-border); border-radius: var(--ui-radius-sm); overflow: hidden; }
75
93
  .palette-range > span { flex: 1; min-inline-size: var(--ui-space-5); }
76
94
  .palette-canvas { background: var(--ui-surface); }
@@ -1,6 +1,7 @@
1
+ import { googleFontStack, systemFont, loadGoogleFonts } from './fonts.mjs';
1
2
  import { documentationTheme, themeEvent } from './theme-session.mjs';
2
3
  import { componentRegistry } from '@nextwebwg/html-next/runtime';
3
- import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme } from './theme.mjs';
4
+ import { startingPoints, startingPointFor, themeCSS, validateRecipe, themeFields, themePalettes, materialPresets, applyRefinement, refinementState, deriveTheme, startingPointLabels, nearbyVariation } from './theme.mjs';
4
5
 
5
6
  import { valueEditor, editorValue, convertValueUnit } from './value-editors.mjs';
6
7
 
@@ -21,6 +22,8 @@ export function connect(host) {
21
22
  let alive = true;
22
23
  let selectedExample = '';
23
24
  let refinementTrigger;
25
+ let previewFrame = 0, pending = false, comparing = false, candidate, gestureStart;
26
+ const entryRecipe = structuredClone(recipe);
24
27
  const abort = new AbortController();
25
28
  const say = (text, error = false) => { message.textContent = text; message.setAttribute('role', error ? 'alert' : 'status'); };
26
29
  const sayApplied = text => say(theme?.storageAvailable ? text : 'Theme applies to this page. Browser storage is unavailable; download CSS or a recipe to keep it.');
@@ -29,7 +32,7 @@ export function connect(host) {
29
32
  const updateControl = (element, value) => {
30
33
  const field = fields.get(element.dataset.token);
31
34
  if (!field || !element.dataset.editorKind) { componentRegistry.update(element, { value }); return; }
32
- const editor = valueEditor(field, value, unitContext());
35
+ const editor = valueEditor(field, value);
33
36
  if (element.dataset.editorKind === 'curve') {
34
37
  element.closest('[data-curve-for]').hidden = editor.kind !== 'curve';
35
38
  if (editor.kind === 'curve') componentRegistry.update(element, { value });
@@ -37,7 +40,7 @@ export function connect(host) {
37
40
  };
38
41
  const updateUnits = () => {
39
42
  for (const control of root.querySelectorAll('[data-unit-for]')) {
40
- const field = fields.get(control.dataset.unitFor), editor = valueEditor(field, recipe.values[field.id], unitContext());
43
+ const field = fields.get(control.dataset.unitFor), editor = valueEditor(field, recipe.values[field.id]);
41
44
  if (![...control.options].some(option => option.value === editor.unit)) control.add(new view.Option(editor.unit, editor.unit));
42
45
  componentRegistry.update(control, { value: editor.unit });
43
46
  }
@@ -52,13 +55,24 @@ export function connect(host) {
52
55
  }
53
56
  componentRegistry.update(root.querySelector(`[data-action="restore-${family}"]`), { disabled: state.id === 'custom' || !state.customized });
54
57
  }
58
+ for (const control of root.querySelectorAll('[data-font-picker]')) componentRegistry.update(control, { value: recipe.fonts?.[control.dataset.fontPicker] ?? (recipe.values[control.dataset.fontPicker] === systemFont ? 'system' : 'custom') });
55
59
  componentRegistry.update(root.querySelector('[data-light-editor]'), { x: Number(recipe.values['light-x']), y: Number(recipe.values['light-y']) });
56
60
  const adjustments = ['light', 'dark'].flatMap(mode => deriveTheme(recipe, { mode }).adjustments.map(item => ({ ...item, mode })));
57
61
  const report = root.querySelector('[data-palette-adjustments]');
58
62
  report.textContent = adjustments.length ? 'Paired ranges are derived for readability. Your seed colors stay editable; resolved values are shown below.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
59
63
  root.querySelector('[data-palette-adjustment-details]').textContent = adjustments.length ? adjustments.map(item => `${item.mode} ${fields.get(item.id).label.toLowerCase()} ${item.requested} → ${item.resolved}`).join('; ') + '. Seed colors remain editable.' : 'Both ranges preserve your seeds with readable text and essential outlines.';
60
- for (const card of root.querySelectorAll('[data-example-card]')) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
61
- updateControl(root.querySelector('[data-css]'), css);
64
+ for (const card of root.querySelectorAll('[data-example-card]')) if (card.getClientRects().length) card.dispatchEvent(new CustomEvent('looma-theme-preview', { detail: { css } }));
65
+ const cssControl = root.querySelector('[data-css]');
66
+ if (cssControl.getClientRects().length) updateControl(cssControl, css);
67
+ for (const action of ['undo','redo']) componentRegistry.update(root.querySelector(`[data-action="${action}"]`), { disabled: !theme?.[action === 'undo' ? 'canUndo' : 'canRedo'] });
68
+ const origin = recipe.origin ?? startingPointFor(recipe);
69
+ root.querySelector('[data-theme-origin]').textContent = startingPointLabels[origin] ? `${startingPointLabels[origin]}${startingPointFor(recipe) === origin ? '' : ' · customized'}` : 'Your theme';
70
+ root.querySelector('[data-material-dependencies]').textContent = Number(recipe.values['light-strength']) === 0
71
+ ? 'Light strength is zero: reflections, glow and bright edge catches are off. Depth shadows and the dark edge remain; depth still follows the source direction.'
72
+ : Number(recipe.values.finish) === 0 ? 'This material is matte: light spread has no reflection to shape. Raise Finish or choose Polished to see it; depth and illuminated edges remain independent.'
73
+ : 'Finish catches the light. Spread shapes its reflection; strength affects reflection, edge light and glow together.';
74
+ const gaps = root.querySelector('[data-spacing-samples]');
75
+ gaps.setAttribute('aria-label', `Spacing progression with curve ${recipe.values['space-curve']}`);
62
76
  };
63
77
  const show = (next, persist = true) => {
64
78
  recipe = validateRecipe(next);
@@ -69,10 +83,38 @@ export function connect(host) {
69
83
  changed = JSON.stringify(recipe) !== JSON.stringify(saved);
70
84
  paint();
71
85
  };
86
+ const clearComparison = () => {
87
+ if (!comparing && !candidate) return;
88
+ comparing = false; candidate = undefined; theme?.compare();
89
+ root.querySelector('[data-action="compare"]').setAttribute('aria-pressed', 'false');
90
+ root.querySelector('[data-variation-confirm]').hidden = true;
91
+ };
92
+ const preview = next => {
93
+ const validated = validateRecipe(next);
94
+ if (JSON.stringify(validated.values) === JSON.stringify(recipe.values)) return;
95
+ clearComparison();
96
+ if (!pending) gestureStart = structuredClone(recipe);
97
+ recipe = validated; pending = true;
98
+ if (!previewFrame) previewFrame = view.requestAnimationFrame(() => {
99
+ previewFrame = 0;
100
+ theme?.preview(recipe);
101
+ show(recipe, false);
102
+ });
103
+ };
104
+ const flush = (commit = true) => {
105
+ if (previewFrame) view.cancelAnimationFrame(previewFrame);
106
+ previewFrame = 0;
107
+ if (!pending) return;
108
+ pending = false;
109
+ const netChange = JSON.stringify(recipe.values) !== JSON.stringify(gestureStart.values);
110
+ if (commit && netChange) theme?.apply(recipe); else theme?.cancel();
111
+ show(theme?.recipe ?? (netChange && commit ? recipe : gestureStart), false);
112
+ gestureStart = undefined;
113
+ };
72
114
  const filterExamples = () => {
73
115
  const words = root.querySelector('[data-example-query]').value.toLocaleLowerCase().trim().split(/\s+/).filter(Boolean);
74
116
  let visible = 0;
75
- for (const card of root.querySelectorAll('[data-example-card]')) {
117
+ for (const card of root.querySelectorAll('[data-customizer-overview] .examples [data-example-card]')) {
76
118
  const matches = words.every(word => card.textContent.toLocaleLowerCase().includes(word) || card.dataset.componentCard.includes(word));
77
119
  card.closest('section').hidden = !matches;
78
120
  if (matches) visible++;
@@ -86,14 +128,16 @@ export function connect(host) {
86
128
  const state = refinementState(recipe, family);
87
129
  if (!presets[family] && state.id !== 'custom') presets[family] = state.id;
88
130
  }
89
- return { ...recipe, presets, values };
131
+ const origin = recipe.origin ?? (startingPointFor(recipe) === 'custom' ? undefined : startingPointFor(recipe));
132
+ const fonts = { ...recipe.fonts };
133
+ for (const key of Object.keys(fonts)) if (values[key] !== googleFontStack(fonts[key])) delete fonts[key];
134
+ return { ...recipe, fonts, ...(origin ? { origin } : {}), presets, values };
90
135
  };
91
136
  const input = event => {
92
137
  if (event.target.matches('[data-example-query]')) { filterExamples(); return; }
93
138
  if (event.target.matches('[data-light-editor]')) {
94
139
  try {
95
- show(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
96
- sayApplied('Light position remembered throughout your design system.');
140
+ preview(refinedRecipe({ ...recipe.values, 'light-x': String(event.detail.x), 'light-y': String(event.detail.y) }));
97
141
  } catch (error) { say(error.message, true); }
98
142
  return;
99
143
  }
@@ -104,13 +148,12 @@ export function connect(host) {
104
148
  const value = control.dataset.editorKind ? editorValue(field, raw) : String(raw);
105
149
  const next = refinedRecipe({ ...recipe.values, [control.dataset.token]: value });
106
150
  try {
107
- recipe = validateRecipe(next);
108
- theme?.apply(recipe); changed = true;
151
+ preview(next); changed = JSON.stringify(recipe) !== JSON.stringify(saved);
109
152
  updateControl(root.querySelector('[data-preset]'), startingPointFor(recipe));
110
153
  control.removeAttribute('aria-invalid');
111
154
  for (const sibling of root.querySelectorAll('[data-token]')) if (sibling.dataset.token === control.dataset.token) updateControl(sibling, value);
112
155
  updateUnits();
113
- paint(); sayApplied('Theme remembered in this browser. Download it or save it to your repository.');
156
+ say('Previewing your theme. Finish the edit to remember it.');
114
157
  } catch (error) { control.setAttribute('aria-invalid', 'true'); say(error.message, true); }
115
158
  };
116
159
  const download = (data, name, type) => {
@@ -120,22 +163,51 @@ export function connect(host) {
120
163
  };
121
164
  const click = async event => {
122
165
  const button = event.target.closest?.('[data-action]');
123
- if (!button || (button.dataset.action === 'save' && saving)) return;
166
+ if (!button) { view.requestAnimationFrame(paint); return; }
167
+ if (button.dataset.action === 'save' && saving) return;
168
+ flush();
169
+ if (!['compare', 'preview-variation', 'apply-variation', 'cancel-variation'].includes(button.dataset.action)) clearComparison();
124
170
  try {
125
171
  switch (button.dataset.action) {
172
+ case 'undo': case 'redo':
173
+ theme?.[button.dataset.action](); show(theme?.recipe ?? recipe, false); sayApplied('Previous theme decision restored.'); break;
174
+ case 'compare':
175
+ candidate = undefined; comparing = !comparing; theme?.compare(comparing ? entryRecipe : undefined);
176
+ button.setAttribute('aria-pressed', String(comparing)); root.querySelector('[data-variation-confirm]').hidden = true;
177
+ say(comparing ? 'Comparing with the theme when you entered. Your edits remain intact. Choose Compare again to return.' : 'Back to your edited theme.'); break;
178
+ case 'preview-variation': {
179
+ clearComparison();
180
+ const locks = [...root.querySelectorAll('[data-family-lock]')].filter(control => control.querySelector('input')?.checked).map(control => control.dataset.familyLock);
181
+ candidate = nearbyVariation(recipe, root.querySelector('[data-variation-choice]').value, locks);
182
+ theme?.compare(candidate); root.querySelector('[data-variation-confirm]').hidden = false;
183
+ const differences = themeFields.filter(field => recipe.values[field.id] !== candidate.values[field.id]);
184
+ 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.';
185
+ break;
186
+ }
187
+ case 'apply-variation': {
188
+ const selected = candidate; clearComparison();
189
+ if (selected) show(selected); sayApplied('Variation applied. Undo returns to your previous theme.'); break;
190
+ }
191
+ case 'cancel-variation': clearComparison(); paint(); say('Variation cancelled; your theme is unchanged.'); break;
192
+ case 'light-top': case 'light-left': case 'light-center': {
193
+ const positions = { 'light-top': [.5, 0], 'light-left': [.15, .15], 'light-center': [.5, .5] };
194
+ const [x, y] = positions[button.dataset.action];
195
+ show(refinedRecipe({ ...recipe.values, 'light-x': String(x), 'light-y': String(y) }));
196
+ sayApplied('Light direction applied throughout your theme.'); break;
197
+ }
126
198
  case 'refine-material': case 'refine-palette': {
127
199
  const family = button.dataset.action.slice(7);
128
200
  refinementTrigger = button;
129
201
  root.querySelector('[data-customizer-overview]').hidden = true;
130
202
  for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = section.dataset.refinementView !== family;
131
203
  const section = root.querySelector(`[data-refinement-view="${family}"]`);
132
- section.focus(); section.scrollIntoView({ block: 'start' });
204
+ paint(); section.focus(); section.scrollIntoView({ block: 'start' });
133
205
  break;
134
206
  }
135
207
  case 'back':
136
208
  for (const section of root.querySelectorAll('[data-refinement-view]')) section.hidden = true;
137
209
  root.querySelector('[data-customizer-overview]').hidden = false;
138
- refinementTrigger?.focus(); break;
210
+ paint(); refinementTrigger?.focus(); break;
139
211
  case 'restore-material': case 'restore-palette': {
140
212
  const family = button.dataset.action.slice(8), state = refinementState(recipe, family);
141
213
  if (state.id !== 'custom') { show(applyRefinement(recipe, family, state.id)); sayApplied(`${family === 'material' ? 'Material' : 'Palette'} preset restored; other theme decisions are unchanged.`); }
@@ -177,6 +249,10 @@ export function connect(host) {
177
249
  finally { if (button.dataset.action === 'save') saving = false; }
178
250
  };
179
251
  const change = async event => {
252
+ if (event.target.matches('[data-light-editor]') || event.target.closest?.('[data-token]')) {
253
+ input(event); flush(); sayApplied('Theme remembered in this browser. Undo restores the previous decision.'); return;
254
+ }
255
+ flush(); clearComparison();
180
256
  if (event.target.matches('[data-unit-for]')) {
181
257
  const field = fields.get(event.target.dataset.unitFor);
182
258
  try {
@@ -192,7 +268,7 @@ export function connect(host) {
192
268
  show(applyRefinement(recipe, family, event.target.value));
193
269
  sayApplied(`${family === 'material' ? 'Material' : 'Coordinated palette'} preset applied and remembered in both modes.`);
194
270
  }
195
- if (event.target.matches('[data-preset]')) { show(structuredClone(startingPoints[event.target.value])); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
271
+ if (event.target.matches('[data-preset]')) { show({ ...structuredClone(startingPoints[event.target.value]), origin: event.target.value }); sayApplied('Starting point applied throughout the documentation and remembered in this browser.'); }
196
272
  if (event.target === file && file.files[0]) {
197
273
  try { show(JSON.parse(await file.files[0].text())); sayApplied('Recipe applied throughout the documentation and remembered in this browser.'); }
198
274
  catch (error) { say(error.message, true); }
@@ -200,11 +276,35 @@ export function connect(host) {
200
276
  }
201
277
  };
202
278
  const blur = event => { const control = event.target; if (control.closest?.('[data-token]')) queueMicrotask(() => { if (alive && control.validity?.customError) say(control.validationMessage, true); }); };
203
- const synchronize = () => { if (theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
279
+ const synchronize = () => { if (!pending && theme && JSON.stringify(recipe) !== JSON.stringify(theme.recipe)) show(theme.recipe, false); };
280
+ const cancel = event => { if (event.target.matches('[data-light-editor], [data-editor-kind="curve"]')) { flush(false); say('Drag cancelled; the previous value is restored.'); } };
281
+ const keyboard = event => {
282
+ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z' || event.target.closest?.('input,textarea,select,[contenteditable="true"]')) return;
283
+ event.preventDefault(); flush(); clearComparison(); theme?.[event.shiftKey ? 'redo' : 'undo'](); show(theme?.recipe ?? recipe, false);
284
+ };
204
285
  const ready = event => {
205
286
  if (event.data?.type === 'looma-preview-ready' && [...root.querySelectorAll('iframe')].some(frame => frame.contentWindow === event.source)) paint();
206
287
  };
207
288
  const remove = event => { host.state.cards = host.state.cards.filter(card => card.id !== event.detail.id); view.requestAnimationFrame(filterExamples); };
289
+ let fontRequest = 0;
290
+ const selectFont = async event => {
291
+ const control = event.target.closest?.('[data-font-picker]'); if (!control) return;
292
+ flush(); clearComparison();
293
+ const id = control.dataset.fontPicker, family = event.detail.value;
294
+ const fonts = { ...recipe.fonts };
295
+ if (family === 'system') delete fonts[id]; else fonts[id] = family;
296
+ const next = { ...refinedRecipe({ ...recipe.values, [id]: family === 'system' ? systemFont : googleFontStack(family) }), fonts };
297
+ const status = root.querySelector(`[data-font-status="${id}"]`), request = ++fontRequest;
298
+ show(next); status.textContent = family === 'system' ? 'Using your operating system’s native font.' : `Loading ${family} from Google Fonts…`;
299
+ try {
300
+ await loadGoogleFonts(document, fonts);
301
+ if (alive && request === fontRequest) for (const [field, selected] of Object.entries(fonts)) {
302
+ root.querySelector(`[data-font-status="${field}"]`).textContent = `${selected} loaded. Included in downloaded and locally saved CSS.`;
303
+ }
304
+ } catch (error) {
305
+ if (alive && request === fontRequest) for (const field of Object.keys(fonts)) root.querySelector(`[data-font-status="${field}"]`).textContent = error.message;
306
+ }
307
+ };
208
308
  const selectExample = event => {
209
309
  if (!event.target.matches('[data-add-example]')) return;
210
310
  selectedExample = event.detail.value ?? '';
@@ -212,11 +312,15 @@ export function connect(host) {
212
312
  };
213
313
  root.addEventListener('looma-example-remove', remove);
214
314
  root.addEventListener('input', input);
315
+ root.addEventListener('cancel', cancel);
316
+ root.addEventListener('keydown', keyboard);
215
317
  root.addEventListener('value-change', selectExample);
318
+ root.addEventListener('value-change', selectFont);
216
319
  root.addEventListener('blur', blur, true);
217
320
  root.addEventListener('change', change);
218
321
  root.addEventListener('click', click);
219
322
  view.addEventListener('message', ready);
323
+ view.addEventListener('beforeunload', flush);
220
324
  view.addEventListener(themeEvent, synchronize);
221
325
  show(recipe, false); filterExamples();
222
326
  if (theme?.storageAvailable === false) sayApplied('');
@@ -230,5 +334,5 @@ export function connect(host) {
230
334
  root.querySelector('[data-action="save"]').hidden = false;
231
335
  }).catch(error => { if (alive && error.name !== 'AbortError') say(error.message, true); });
232
336
  }
233
- return () => { alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('value-change', selectExample); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
337
+ return () => { flush(); clearComparison(); root.removeEventListener('cancel', cancel); root.removeEventListener('keydown', keyboard); alive = false; abort.abort(); root.removeEventListener('looma-example-remove', remove); root.removeEventListener('input', input); root.removeEventListener('value-change', selectExample); root.removeEventListener('value-change', selectFont); root.removeEventListener('blur', blur, true); root.removeEventListener('change', change); root.removeEventListener('click', click); view.removeEventListener('beforeunload', flush); view.removeEventListener('message', ready); view.removeEventListener(themeEvent, synchronize); };
234
338
  }