@threadlabs/looma 0.35.1 → 0.37.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 (60) hide show
  1. package/components/ui-avatar/ui-avatar.html +1 -1
  2. package/components/ui-avatar-group/ui-avatar-group.html +1 -1
  3. package/components/ui-button/ui-button.html +11 -4
  4. package/components/ui-color-picker/ui-color-picker.html +1 -1
  5. package/components/ui-combobox/ui-combobox.html +1 -1
  6. package/components/ui-curve-editor/ui-curve-editor.html +1 -0
  7. package/components/ui-curve-editor/ui-curve-editor.js +15 -6
  8. package/components/ui-input/ui-input.html +4 -1
  9. package/components/ui-light-editor/ui-light-editor.html +143 -19
  10. package/components/ui-light-editor/ui-light-editor.js +26 -9
  11. package/components/ui-select/ui-select.html +10 -6
  12. package/dist/index.js +1 -1
  13. package/docs/README.md +33 -7
  14. package/docs/card.js +1 -0
  15. package/docs/catalog.json +39 -5
  16. package/docs/customizer-render.mjs +27 -9
  17. package/docs/customizer.js +146 -18
  18. package/docs/fonts.mjs +45 -0
  19. package/docs/index.d.ts +2 -2
  20. package/docs/palette.mjs +67 -0
  21. package/docs/site.mjs +1 -1
  22. package/docs/theme-session.mjs +28 -9
  23. package/docs/theme.mjs +67 -26
  24. package/docs/value-editors.mjs +6 -3
  25. package/package.json +2 -2
  26. package/styles/ui-avatar-group.css +1 -1
  27. package/styles/ui-avatar.css +1 -1
  28. package/styles/ui-button.css +11 -4
  29. package/styles/ui-color-picker.css +1 -1
  30. package/styles/ui-combobox.css +2 -2
  31. package/styles/ui-input.css +4 -1
  32. package/styles/ui-light-editor.css +111 -16
  33. package/styles/ui-select.css +10 -6
  34. package/theme-system.css +17 -4
  35. package/tokens.css +28 -15
  36. package/vanilla/UiCurveEditor.d.ts +1 -0
  37. package/vanilla/UiCurveEditor.js +1 -1
  38. package/vanilla/UiLightEditor.d.ts +1 -0
  39. package/vanilla/UiLightEditor.js +2 -2
  40. package/vue/UiAvatar.vue +1 -1
  41. package/vue/UiAvatarGroup.vue +1 -1
  42. package/vue/UiButton.vue +4 -2
  43. package/vue/UiColorPicker.vue +1 -1
  44. package/vue/UiCombobox.vue +2 -2
  45. package/vue/UiCurveEditor.d.ts +6 -0
  46. package/vue/UiCurveEditor.vue +2 -0
  47. package/vue/UiInput.vue +4 -1
  48. package/vue/UiLightEditor.d.ts +8 -0
  49. package/vue/UiLightEditor.vue +22 -3
  50. package/vue/UiSelect.vue +1 -1
  51. package/vue/chunks/UiAvatar.js +1 -1
  52. package/vue/chunks/UiAvatarGroup.js +1 -1
  53. package/vue/chunks/UiButton.js +1 -1
  54. package/vue/chunks/UiColorPicker.js +1 -1
  55. package/vue/chunks/UiCombobox.js +1 -1
  56. package/vue/chunks/UiCurveEditor.js +5 -3
  57. package/vue/chunks/UiInput.js +1 -1
  58. package/vue/chunks/UiLightEditor.js +16 -6
  59. package/vue/chunks/UiSelect.js +1 -1
  60. package/vue/components.css +43 -16
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 `16px` and sets the document root. Type and geometry derive from `1rem` once; the base editor accepts only px. Other relative length inputs preserve their units; editor ranges convert through the chosen root, and generated guards keep the documented pixel bounds. Visible size samples report actual rem/px heights. Set the base on `:root`; opposite-mode boundaries retain that same rem root.
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`. Base `text-size` is now px-only and sets the document root. Replace older relative base values with an explicit px value using their original root; no compatibility conversion guesses it.
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.37.0",
447
447
  "groups": [
448
448
  {
449
449
  "id": "layout",
@@ -729,7 +729,7 @@
729
729
  "name": "--ui-border-width"
730
730
  },
731
731
  {
732
- "name": "--ui-font-size"
732
+ "name": "--ui-font-size-md"
733
733
  },
734
734
  {
735
735
  "name": "--ui-font-size-sm"
@@ -975,7 +975,7 @@
975
975
  "name": "--ui-border-width"
976
976
  },
977
977
  {
978
- "name": "--ui-font-size"
978
+ "name": "--ui-font-size-md"
979
979
  },
980
980
  {
981
981
  "name": "--ui-font-size-xs"
@@ -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
- const value = recipe.values[field.id], editor = valueEditor(field, value);
19
+ const value = recipe.values[field.id], editor = valueEditor(field, value, { remPixels: parseFloat(recipe.values['text-size']) });
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]}<span data-size-readout></span></ui-text><ui-cluster><ui-button size="${size}">Action</ui-button><ui-input size="${size}" style="--ui-input-inline-size: 10ch" label="${size} size sample" value="Frodo"></ui-input><ui-color-picker size="${size}" label="${size} swatch sample"></ui-color-picker></ui-cluster></ui-stack>`).join('')}</ui-stack><div class="spacing-samples" data-spacing-samples role="img" aria-label="Spacing progression"><span></span><span></span><span></span><span></span></div><ui-disclosure summary="How the scale grows"><ui-stack><p>Control roominess relates every field and button to base text. Size ratio separates small from large. 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); }