@threadlabs/looma 0.33.1 → 0.34.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 (184) hide show
  1. package/components/shared/overlay.js +8 -2
  2. package/components/shared/surface-material.js +21 -0
  3. package/components/ui-button/ui-button.html +63 -47
  4. package/components/ui-card/ui-card.html +44 -2
  5. package/components/ui-curve-editor/curve.js +10 -0
  6. package/components/ui-curve-editor/ui-curve-editor.html +181 -0
  7. package/components/ui-curve-editor/ui-curve-editor.js +51 -0
  8. package/components/ui-dialog/ui-dialog.html +62 -1
  9. package/components/ui-dialog/ui-dialog.js +9 -1
  10. package/components/ui-icon-button/ui-icon-button.html +75 -23
  11. package/components/ui-menu/ui-menu.html +60 -0
  12. package/components/ui-menu/ui-menu.js +1 -1
  13. package/components/ui-number-input/arithmetic.js +47 -0
  14. package/components/ui-number-input/ui-number-input.html +62 -0
  15. package/components/ui-number-input/ui-number-input.js +44 -0
  16. package/components/ui-popover/ui-popover.html +64 -1
  17. package/components/ui-popover/ui-popover.js +1 -1
  18. package/components/ui-slider/ui-slider.html +84 -0
  19. package/components/ui-slider/ui-slider.js +29 -0
  20. package/components/ui-tabs/ui-tabs.html +6 -1
  21. package/components/ui-text/ui-text.html +31 -6
  22. package/components/ui-tooltip/ui-tooltip.html +60 -0
  23. package/components/ui-tooltip/ui-tooltip.js +1 -1
  24. package/dist/index.js +46 -43
  25. package/docs/README.md +34 -4
  26. package/docs/card.html +16 -2
  27. package/docs/card.js +17 -2
  28. package/docs/catalog.json +1154 -144
  29. package/docs/customizer-render.mjs +35 -9
  30. package/docs/customizer.js +83 -20
  31. package/docs/documentation.css +9 -1
  32. package/docs/index.d.ts +4 -2
  33. package/docs/index.mjs +1 -1
  34. package/docs/preview.js +2 -0
  35. package/docs/render.mjs +15 -6
  36. package/docs/site.mjs +50 -9
  37. package/docs/theme-session.mjs +51 -0
  38. package/docs/theme.mjs +134 -15
  39. package/docs/value-editors.mjs +33 -0
  40. package/package.json +2 -2
  41. package/styles/ui-button.css +45 -45
  42. package/styles/ui-card.css +47 -0
  43. package/styles/ui-curve-editor.css +109 -0
  44. package/styles/ui-dialog.css +132 -0
  45. package/styles/ui-icon-button.css +63 -20
  46. package/styles/ui-menu.css +72 -0
  47. package/styles/ui-number-input.css +34 -0
  48. package/styles/ui-popover.css +74 -0
  49. package/styles/ui-slider.css +53 -0
  50. package/styles/ui-tabs.css +6 -1
  51. package/styles/ui-text.css +23 -1
  52. package/styles/ui-tooltip.css +72 -0
  53. package/theme-system.css +26 -10
  54. package/tokens.css +8 -2
  55. package/vanilla/UiButton.d.ts +1 -0
  56. package/vanilla/UiButton.js +195 -130
  57. package/vanilla/UiCard.d.ts +1 -1
  58. package/vanilla/UiCard.js +1 -1
  59. package/vanilla/UiCurveEditor.d.ts +28 -0
  60. package/vanilla/UiCurveEditor.js +134 -0
  61. package/vanilla/UiIconButton.d.ts +2 -1
  62. package/vanilla/UiIconButton.js +58 -31
  63. package/vanilla/UiNumberInput.d.ts +33 -0
  64. package/vanilla/UiNumberInput.js +79 -0
  65. package/vanilla/UiSlider.d.ts +33 -0
  66. package/vanilla/UiSlider.js +125 -0
  67. package/vanilla/UiText.d.ts +2 -2
  68. package/vanilla/UiText.js +45 -11
  69. package/vanilla/index.js +3 -0
  70. package/vue/UiButton.d.ts +6 -0
  71. package/vue/UiButton.vue +155 -56
  72. package/vue/UiCard.d.ts +2 -2
  73. package/vue/UiCard.vue +55 -5
  74. package/vue/UiCurveEditor.d.ts +53 -0
  75. package/vue/UiCurveEditor.js +2 -0
  76. package/vue/UiCurveEditor.vue +427 -0
  77. package/vue/UiDialog.vue +177 -1
  78. package/vue/UiIconButton.d.ts +8 -2
  79. package/vue/UiIconButton.vue +164 -34
  80. package/vue/UiMenu.vue +89 -0
  81. package/vue/UiNumberInput.d.ts +83 -0
  82. package/vue/UiNumberInput.js +2 -0
  83. package/vue/UiNumberInput.vue +370 -0
  84. package/vue/UiPopover.vue +92 -1
  85. package/vue/UiSlider.d.ts +83 -0
  86. package/vue/UiSlider.js +2 -0
  87. package/vue/UiSlider.vue +412 -0
  88. package/vue/UiTabs.vue +6 -1
  89. package/vue/UiText.d.ts +7 -5
  90. package/vue/UiText.vue +104 -22
  91. package/vue/UiTooltip.vue +89 -0
  92. package/vue/chunks/UiActionBar.js +1 -1
  93. package/vue/chunks/UiAffordanceScope.js +2 -2
  94. package/vue/chunks/UiAvatar.js +2 -2
  95. package/vue/chunks/UiAvatarGroup.js +2 -2
  96. package/vue/chunks/UiBadge.js +2 -2
  97. package/vue/chunks/UiBreadcrumbItem.js +2 -2
  98. package/vue/chunks/UiBreadcrumbs.js +2 -2
  99. package/vue/chunks/UiButton.js +94 -11
  100. package/vue/chunks/UiCallout.js +2 -2
  101. package/vue/chunks/UiCard.js +6 -5
  102. package/vue/chunks/UiCheckbox.js +2 -2
  103. package/vue/chunks/UiCluster.js +2 -2
  104. package/vue/chunks/UiColorPicker.js +2 -2
  105. package/vue/chunks/UiCombobox.js +2 -2
  106. package/vue/chunks/UiContainer.js +2 -2
  107. package/vue/chunks/UiContextMenu.js +2 -2
  108. package/vue/chunks/UiCover.js +2 -2
  109. package/vue/chunks/UiCurveEditor.js +413 -0
  110. package/vue/chunks/UiDescriptionItem.js +2 -2
  111. package/vue/chunks/UiDescriptionList.js +2 -2
  112. package/vue/chunks/UiDialog.js +4 -4
  113. package/vue/chunks/UiDisclosure.js +2 -2
  114. package/vue/chunks/UiEditable.js +2 -2
  115. package/vue/chunks/UiEditorBlockActions.js +2 -2
  116. package/vue/chunks/UiEditorChipEditor.js +2 -2
  117. package/vue/chunks/UiEditorCodeLanguage.js +2 -2
  118. package/vue/chunks/UiEditorFootnoteEditor.js +2 -2
  119. package/vue/chunks/UiEditorImageToolbar.js +3 -3
  120. package/vue/chunks/UiEditorInsertTableGrid.js +2 -2
  121. package/vue/chunks/UiEditorLinkCard.js +3 -3
  122. package/vue/chunks/UiEditorLinkForm.js +3 -3
  123. package/vue/chunks/UiEditorMentionMenu.js +2 -2
  124. package/vue/chunks/UiEditorMentionMenuItem.js +2 -2
  125. package/vue/chunks/UiEditorSlashMenu.js +2 -2
  126. package/vue/chunks/UiEditorSlashMenuGroup.js +2 -2
  127. package/vue/chunks/UiEditorSlashMenuItem.js +2 -2
  128. package/vue/chunks/UiEditorSurface.js +2 -2
  129. package/vue/chunks/UiEditorTableContextMenu.js +2 -2
  130. package/vue/chunks/UiEditorTableOverlay.js +2 -2
  131. package/vue/chunks/UiEditorTableToolbar.js +2 -2
  132. package/vue/chunks/UiEditorToolbar.js +2 -2
  133. package/vue/chunks/UiFloatingActionButton.js +2 -2
  134. package/vue/chunks/UiFormField.js +2 -2
  135. package/vue/chunks/UiGrid.js +2 -2
  136. package/vue/chunks/UiIcon.js +2 -2
  137. package/vue/chunks/UiIconButton.js +77 -11
  138. package/vue/chunks/UiImage.js +2 -2
  139. package/vue/chunks/UiInput.js +2 -2
  140. package/vue/chunks/UiInputGroup.js +2 -2
  141. package/vue/chunks/UiList.js +2 -2
  142. package/vue/chunks/UiListItem.js +2 -2
  143. package/vue/chunks/UiListbox.js +2 -2
  144. package/vue/chunks/UiMenu.js +3 -3
  145. package/vue/chunks/UiMenuGroup.js +2 -2
  146. package/vue/chunks/UiMenuItem.js +2 -2
  147. package/vue/chunks/UiMeter.js +2 -2
  148. package/vue/chunks/UiNavItem.js +2 -2
  149. package/vue/chunks/UiNumberInput.js +390 -0
  150. package/vue/chunks/UiPageHeader.js +2 -2
  151. package/vue/chunks/UiPopover.js +4 -4
  152. package/vue/chunks/UiRadio.js +2 -2
  153. package/vue/chunks/UiRadioGroup.js +2 -2
  154. package/vue/chunks/UiReel.js +2 -2
  155. package/vue/chunks/UiScrollArea.js +2 -2
  156. package/vue/chunks/UiSearchResultRow.js +2 -2
  157. package/vue/chunks/UiSearchShell.js +2 -2
  158. package/vue/chunks/UiSection.js +2 -2
  159. package/vue/chunks/UiSelect.js +2 -2
  160. package/vue/chunks/UiSeparator.js +2 -2
  161. package/vue/chunks/UiSidebar.js +2 -2
  162. package/vue/chunks/UiSlider.js +396 -0
  163. package/vue/chunks/UiSpinner.js +2 -2
  164. package/vue/chunks/UiStack.js +2 -2
  165. package/vue/chunks/UiStatusMessage.js +2 -2
  166. package/vue/chunks/UiSwatch.js +2 -2
  167. package/vue/chunks/UiSwitch.js +2 -2
  168. package/vue/chunks/UiSwitcher.js +2 -2
  169. package/vue/chunks/UiTable.js +2 -2
  170. package/vue/chunks/UiTabs.js +3 -3
  171. package/vue/chunks/UiText.js +53 -12
  172. package/vue/chunks/UiTextarea.js +2 -2
  173. package/vue/chunks/UiToast.js +2 -2
  174. package/vue/chunks/UiToastRegion.js +2 -2
  175. package/vue/chunks/UiTooltip.js +3 -3
  176. package/vue/chunks/UiTopBar.js +1 -1
  177. package/vue/chunks/UiTree.js +2 -2
  178. package/vue/chunks/UiTreeItem.js +2 -2
  179. package/vue/chunks/_plugin-vue_export-helper.js +68 -2
  180. package/vue/chunks/props.js +24 -1
  181. package/vue/components.css +2215 -1203
  182. package/vue/editor/index.js +623 -211
  183. package/vue/index.d.ts +3 -0
  184. package/vue/index.js +5 -2
package/docs/theme.mjs CHANGED
@@ -1,3 +1,10 @@
1
+ const bounds = {
2
+ 'text-size': [14, 24], 'control-size': [32, 64], 'corner-roundness': [0, 32],
3
+ 'line-weight': [.5, 3], density: [.75, 1.5], 'scale-contrast': [1, 1.5],
4
+ 'space-curve': [.5, 2], 'text-weight': [400, 700], 'outer-depth': [0, 2], 'inner-depth': [0, 2],
5
+ 'response-time': [0, 600],
6
+ };
7
+
1
8
  /** The shared authoring schema; ids remain stable in saved recipes. */
2
9
  export const themeFields = [
3
10
  ['Colour', 'action', 'Action', '--ui-accent', 'colour', '#5b55d6', 'Primary commands and selected controls.'],
@@ -16,22 +23,40 @@ export const themeFields = [
16
23
  ['Size & spacing', 'line-weight', 'Line weight', '--ui-border-width', 'length', '1px', 'Shared edge weight; focus stays independently visible.'],
17
24
  ['Material', 'outer-depth', 'Outer depth', '--ui-outer-depth', 'nonnegative', '0.6', 'How strongly controls and floating surfaces rise above the page.'],
18
25
  ['Material', 'inner-depth', 'Inner depth', '--ui-inner-depth', 'nonnegative', '0.3', 'How strongly fields and pressed controls sit into their surface.'],
26
+ ['Material', 'glassiness', 'Glassiness', '--ui-glassiness', 'fraction', '0', 'Frosted blur, richer background colour and light-catching glass edges. Content stays readable.'],
27
+ ['Material', 'surface-separation', 'Surface separation', '--ui-surface-separation', 'fraction', '0', 'One material offset, scaled by popup purpose and actual size: quiet tooltips through substantial dialogs.'],
19
28
  ['Material', 'translucency', 'Overlay translucency', '--ui-overlay-translucency', 'fraction', '0', 'How much background comes through floating surfaces.'],
20
29
  ['Material', 'edge-light', 'Edge light', '--ui-edge-light', 'fraction', '0', 'Light caught by surface edges, independent of depth.'],
21
30
  ['Motion', 'response-time', 'Response time', '--ui-motion-fast', 'time', '120ms', 'Shared feedback pace; floating transitions take proportionally longer.'],
22
31
  ['Motion', 'response-curve', 'Response curve', '--ui-motion-ease', 'easing', 'cubic-bezier(0.2,0,0.2,1)', 'How movement accelerates and settles.'],
23
- ].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description }));
32
+ ].map(([family, id, label, property, type, defaultValue, description]) => Object.freeze({ family, id, label, property, type, defaultValue, description, ...(bounds[id] ? { min: bounds[id][0], max: bounds[id][1] } : type === 'fraction' ? { min: 0, max: 1 } : {}) }));
24
33
 
25
34
  export const defaultRecipe = () => ({ schemaVersion: 1, values: Object.fromEntries(themeFields.map(field => [field.id, field.defaultValue])) });
26
35
  const starting = values => ({ schemaVersion: 1, values: { ...defaultRecipe().values, ...values } });
27
36
  export const startingPoints = {
28
37
  clear: defaultRecipe(),
29
- library: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-size': '42px', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', translucency: '0.18', 'edge-light': '1', 'response-time': '150ms', 'response-curve': 'ease' }),
38
+ bookish: starting({ action: '#2e1228', canvas: '#f7f7f5', surface: '#fffefd', text: '#0d0c0c', 'body-font': 'Arial, sans-serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'control-size': '42px', 'scale-contrast': '1.18', 'outer-depth': '1', 'inner-depth': '1', glassiness: '0.8', 'surface-separation': '0.65', translucency: '0.18', 'edge-light': '1', 'response-time': '150ms', 'response-curve': 'ease' }),
39
+ wiki: starting({ action: '#4f46e5', canvas: '#ffffff', surface: '#ffffff', text: '#1a1a1a', 'control-size': '34px', density: '0.85', 'scale-contrast': '1.15', 'corner-roundness': '6px', 'outer-depth': '0.35', 'inner-depth': '0', 'response-time': '110ms' }),
30
40
  editorial: starting({ action: '#182c24', canvas: '#eee9dc', surface: '#fffdf5', text: '#182c24', 'corner-roundness': '0px', 'outer-depth': '0', 'inner-depth': '0', 'body-font': 'Georgia, serif', 'display-font': 'Georgia, serif', 'text-weight': '400', 'line-weight': '2px', 'response-time': '90ms', 'response-curve': 'linear' }),
31
41
  night: starting({ action: '#a7b8ff', canvas: '#11141c', surface: '#202634', text: '#edf1fb', 'corner-roundness': '12px', 'outer-depth': '1.4', 'inner-depth': '0.45', translucency: '0.2', 'edge-light': '0.65', 'response-time': '210ms', 'response-curve': 'cubic-bezier(0.2,0.8,0.2,1)' }),
32
42
  garden: starting({ action: '#256342', canvas: '#eef5e7', surface: '#fcfff6', text: '#173121', 'corner-roundness': '22px', 'outer-depth': '0.35', 'inner-depth': '0', 'edge-light': '0.2', 'control-size': '46px', 'scale-contrast': '1.24', density: '1.05', 'response-time': '180ms', 'response-curve': 'cubic-bezier(0.16,1,0.3,1)' }),
33
43
  };
34
- export const startingPointLabels = { clear: 'Clear', library: 'Library', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
44
+ // Preserve the public historical startingPoints.library import without a duplicate UI preset.
45
+ Object.defineProperty(startingPoints, 'library', { value: startingPoints.bookish });
46
+ export const startingPointLabels = { clear: 'Clear', bookish: 'Bookish', wiki: 'Wiki', editorial: 'Editorial', night: 'Night', garden: 'Garden' };
47
+ const paletteKeys = ['action', 'canvas', 'surface', 'text'];
48
+ // Palette choices move only the shared color seeds; size, material and motion stay independent.
49
+ export const themePalettes = Object.fromEntries([
50
+ ['clear', 'Indigo & mist'], ['bookish', 'Plum & paper'], ['wiki', 'Indigo & chalk'],
51
+ ['editorial', 'Ink & parchment'], ['night', 'Midnight'], ['garden', 'Forest & cream'],
52
+ ].map(([id, label]) => [id, { label, values: Object.fromEntries(paletteKeys.map(key => [key, startingPoints[id].values[key]])) }]));
53
+
54
+ /** Identify a coordinated palette without coupling it to the rest of a recipe. */
55
+ export function paletteFor(input) {
56
+ const { values } = validateRecipe(input);
57
+ return Object.entries(themePalettes).find(([, palette]) => paletteKeys.every(key => values[key].toLowerCase() === palette.values[key].toLowerCase()))?.[0] ?? 'custom';
58
+ }
59
+
35
60
  const scalar = /^[+]?(?:\d+(?:\.\d*)?|\.\d+)$/;
36
61
  const length = /^(?:0|[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:px|rem|em|ex|ch|cap|ic|lh|rlh|vw|vh|vi|vb|vmin|vmax|svw|svh|lvw|lvh|dvw|dvh|cm|mm|Q|in|pt|pc))$/;
37
62
  const font = /^(?:"[^"\\;{}<>@\x00-\x1f]+"|'[^'\\;{}<>@\x00-\x1f]+'|[\p{L}_-][\p{L}\p{N}_ -]*)(?:\s*,\s*(?:"[^"\\;{}<>@\x00-\x1f]+"|'[^'\\;{}<>@\x00-\x1f]+'|[\p{L}_-][\p{L}\p{N}_ -]*))*$/u;
@@ -39,7 +64,21 @@ const time = /^[+]?(?:\d+(?:\.\d*)?|\.\d+)(?:ms|s)$/;
39
64
  function easing(value) {
40
65
  if (/^(ease|linear|ease-in|ease-out|ease-in-out|step-start|step-end)$/.test(value)) return true;
41
66
  const match = /^cubic-bezier\(\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*,\s*([\d.+-]+)\s*\)$/.exec(value);
42
- return Boolean(match && match.slice(1).every(n => Number.isFinite(Number(n))) && [1, 3].every(i => Number(match[i]) >= 0 && Number(match[i]) <= 1));
67
+ return Boolean(match && match.slice(1).every(n => Number.isFinite(Number(n))) && [1, 3].every(i => Number(match[i]) >= 0 && Number(match[i]) <= 1) && [2, 4].every(i => Number(match[i]) >= -1 && Number(match[i]) <= 2));
68
+ }
69
+
70
+ // Authoring bounds are shared by every entry path. Relative lengths keep their units;
71
+ // viewport/font-metric lengths are constrained at use time rather than guessed in JS.
72
+
73
+ const pixels = { px: 1, cm: 96 / 2.54, mm: 96 / 25.4, Q: 96 / 101.6, in: 96, pt: 96 / 72, pc: 16 };
74
+ function boundedNumber(field, value) {
75
+ if (field.type === 'time') return parseFloat(value) * (value.endsWith('ms') ? 1 : 1000);
76
+ if (field.type.includes('length')) {
77
+ if (value === '0') return 0;
78
+ const unit = value.match(/[a-z]+$/i)?.[0];
79
+ return Object.hasOwn(pixels, unit) ? parseFloat(value) * pixels[unit] : undefined;
80
+ }
81
+ return Number(value);
43
82
  }
44
83
 
45
84
  /** Validate the whole recipe before previewing or writing any output. */
@@ -67,6 +106,10 @@ export function validateRecipe(input) {
67
106
  }
68
107
  }
69
108
  if (!valid) throw new Error(`${field.label}: enter a valid ${field.type.replaceAll('-', ' ')} value.`);
109
+ if (Object.hasOwn(bounds, field.id)) {
110
+ const number = boundedNumber(field, value), [min, max] = bounds[field.id];
111
+ if (number !== undefined && (number < min || number > max)) throw new Error(`${field.label}: choose a value between ${min} and ${max}${field.type.includes('length') ? 'px (or equivalent)' : field.type === 'time' ? 'ms' : ''}.`);
112
+ }
70
113
  }
71
114
  return { schemaVersion: 1, values };
72
115
  }
@@ -83,18 +126,94 @@ const luminance = hex => rgb(hex).map(n => { const s = n / 255; return s <= .040
83
126
  const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + .05) / (Math.min(luminance(a), luminance(b)) + .05);
84
127
  const foreground = (fill, light, dark) => [light, dark, '#ffffff', '#000000'].sort((a, b) => contrast(fill, b) - contrast(fill, a))[0];
85
128
 
86
- /** Shared public seeds; combine with theme-system.css for a standalone exported theme. */
87
- export function themeCSS(input, systemCSS = '') {
129
+ /** Find the nearest mixture with a known readable endpoint, including every supplied surface. */
130
+ function readableColour(seed, surfaces, minimum, endpoint) {
131
+ if (surfaces.every(surface => contrast(seed, surface) >= minimum)) return seed;
132
+ for (let step = 1; step <= 256; step++) {
133
+ const next = mix(seed, endpoint, 1 - step / 256);
134
+ if (surfaces.every(surface => contrast(next, surface) >= minimum)) return next;
135
+ }
136
+ return endpoint;
137
+ }
138
+
139
+ /** Resolve editable colour seeds into a coordinated set of readable semantic roles. */
140
+ export function deriveTheme(input, { mode } = {}) {
141
+ if (mode !== undefined && !['light', 'dark'].includes(mode)) throw new Error('Unknown theme color mode.');
88
142
  const { values } = validateRecipe(input);
89
143
  const tokens = Object.fromEntries(themeFields.map(field => [field.property, values[field.id]]));
90
- tokens['--ui-on-accent'] = foreground(values.action, values.surface, values.text);
91
- tokens['--ui-surface-sunken'] = mix(values.surface, values.canvas, .92);
92
- let muted = values.text;
93
- for (let weight = .72; weight <= 1; weight += .01) { muted = mix(values.text, values.surface, weight); if (contrast(muted, values.surface) >= 4.5) break; }
94
- tokens['--ui-text-secondary'] = muted;
95
- let danger = '#c92d1e';
96
- for (let weight = 0; weight <= 1; weight += .02) { danger = mix('#c92d1e', values.text, 1 - weight); if (Math.min(contrast(danger, values.surface), contrast(danger, values.canvas)) >= 4.5) break; }
97
- tokens['--ui-danger'] = danger; tokens['--ui-on-danger'] = foreground(danger, values.surface, values.text);
144
+ for (const field of themeFields.filter(field => field.type.includes('length') && Object.hasOwn(bounds, field.id))) {
145
+ if (boundedNumber(field, values[field.id]) === undefined) {
146
+ const [min, max] = bounds[field.id]; tokens[field.property] = `clamp(${min}px, ${values[field.id]}, ${max}px)`;
147
+ }
148
+ }
149
+ const adjustments = [];
150
+ const nativeInk = foreground(values.canvas, '#ffffff', '#000000');
151
+ const ink = mode === 'light' ? '#000000' : mode === 'dark' ? '#ffffff' : nativeInk;
152
+ const paper = ink === '#000000' ? '#ffffff' : '#000000';
153
+ // Preserve the requested palette in its native mode. The companion keeps a restrained
154
+ // tint of the same surface seeds; contrast resolution below governs all usable roles.
155
+ const companionSurface = mix(values.text, paper, .8);
156
+ const surface = seed => readableColour(nativeInk === ink ? seed : mix(seed, companionSurface, .02), [ink], 7, paper);
157
+ tokens['--ui-surface'] = surface(values.canvas);
158
+ tokens['--ui-surface-raised'] = surface(values.surface);
159
+ tokens['--ui-surface-sunken'] = surface(mix(tokens['--ui-surface-raised'], tokens['--ui-surface'], .92));
160
+ const surfaces = ['--ui-surface', '--ui-surface-raised', '--ui-surface-sunken'].map(name => tokens[name]);
161
+ const tones = { accent: values.action, danger: '#c92d1e', success: '#007a33', warning: '#ba5b0b', info: '#0066cc' };
162
+ for (const [tone, seed] of Object.entries(tones)) {
163
+ const fill = readableColour(seed, surfaces, 5, ink);
164
+ tokens[`--ui-${tone}`] = fill;
165
+ tokens[`--ui-on-${tone}`] = foreground(fill, '#ffffff', '#000000');
166
+ tokens[`--ui-${tone}-on-strong`] = tokens[`--ui-on-${tone}`];
167
+ tokens[`--ui-${tone}-hover`] = mix(fill, ink, .86);
168
+ tokens[`--ui-${tone}-active`] = mix(fill, ink, .74);
169
+ tokens[`--ui-${tone}-soft`] = mix(fill, tokens['--ui-surface-raised'], .08);
170
+ }
171
+ // Readable roles cover the quiet tinted surfaces as well as the neutral family.
172
+ const readingSurfaces = [...surfaces, ...Object.keys(tones).flatMap(tone => surfaces.map(background => mix(tokens[`--ui-${tone}`], background, .12)))];
173
+ for (const tone of Object.keys(tones)) {
174
+ tokens[`--ui-${tone}-subtle-text`] = readableColour(tokens[`--ui-${tone}`], readingSurfaces, 4.5, ink);
175
+ }
176
+ const textSeed = nativeInk === ink ? values.text : mix(values.text, ink, .08);
177
+ tokens['--ui-text'] = readableColour(textSeed, readingSurfaces, 4.5, ink);
178
+ tokens['--ui-text-secondary'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .72), readingSurfaces, 4.5, ink);
179
+ tokens['--ui-text-muted'] = tokens['--ui-text-secondary'];
180
+ tokens['--ui-selection-surface'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .1);
181
+ tokens['--ui-selection-text'] = tokens['--ui-accent-subtle-text'];
182
+ tokens['--ui-selection-border'] = mix(tokens['--ui-accent'], tokens['--ui-surface-raised'], .2);
183
+ tokens['--ui-control-surface'] = tokens['--ui-surface-raised'];
184
+ tokens['--ui-control-border'] = readableColour(mix(tokens['--ui-text'], tokens['--ui-surface-raised'], .43), readingSurfaces, 3, ink);
185
+ tokens['--ui-control-border-hover'] = tokens['--ui-control-border'];
186
+ tokens['--ui-border-strong'] = tokens['--ui-control-border'];
187
+ tokens['--ui-disabled-text'] = tokens['--ui-text-secondary'];
188
+ tokens['--ui-disabled-surface'] = tokens['--ui-surface-sunken'];
189
+ tokens['--ui-disabled-border'] = tokens['--ui-control-border'];
190
+ tokens['--ui-focus-ring'] = readableColour(tokens['--ui-accent'], readingSurfaces, 3, ink);
98
191
  tokens['--ui-focus-width'] = 'max(2px, calc(var(--ui-border-width) * 2))';
99
- return `/* Looma shared semantic theme. */\n${systemCSS}\n:root, [data-theme], [data-contrast] {\n${Object.entries(tokens).map(([name, value]) => ` ${name}: ${value};`).join('\n')}\n}\n@media (prefers-reduced-motion: reduce) {\n :root, [data-theme], [data-contrast] { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n`;
192
+ tokens['color-scheme'] = ink === '#000000' ? 'light' : 'dark';
193
+ for (const [id, property] of [['action', '--ui-accent'], ['canvas', '--ui-surface'], ['surface', '--ui-surface-raised'], ['text', '--ui-text']]) {
194
+ if (tokens[property].toLowerCase() !== values[id].toLowerCase()) adjustments.push({ id, requested: values[id], resolved: tokens[property], reason: 'Adjusted to keep the shared palette readable.' });
195
+ }
196
+ return { tokens, adjustments };
197
+ }
198
+
199
+ /** Shared public seeds; combine with theme-system.css for a standalone exported theme. */
200
+ export function themeCSS(input, systemCSS = '', { scope = 'portable' } = {}) {
201
+ if (!['portable', 'documentation'].includes(scope)) throw new Error('Unknown theme scope.');
202
+ // The early documentation stylesheet wins over the baseline loaded later without !important.
203
+ const selector = scope === 'documentation'
204
+ ? ':root[data-looma-documentation-theme], :root[data-looma-documentation-theme] [data-theme], :root[data-looma-documentation-theme] [data-contrast]'
205
+ : ':root, [data-theme], [data-contrast]';
206
+ const light = deriveTheme(input, { mode: 'light' }).tokens;
207
+ const dark = deriveTheme(input, { mode: 'dark' }).tokens;
208
+ const palette = [], geometry = [];
209
+ for (const [name, value] of Object.entries(light)) {
210
+ if (name === 'color-scheme') continue;
211
+ if (/^#[\da-f]{6}$/i.test(value)) palette.push(` ${name}: light-dark(${value}, ${dark[name]});`);
212
+ else geometry.push(` ${name}: ${value};`);
213
+ }
214
+ const geometrySelector = scope === 'documentation' ? ':root[data-looma-documentation-theme]' : ':root';
215
+ // Resolve colors at their point of use, so a nearer mode boundary wins independently
216
+ // of the selector specificity needed to restore an early documentation theme.
217
+ // Keep the first comment stable: the local writer recognizes its generated files by this marker.
218
+ return `/* Looma shared semantic theme. */\n/* Always includes light and dark.\n Set data-theme="light" or "dark" on any element to fix that subtree's mode.\n Leave the root attribute unset to follow the system; a switch can set/remove it. */\n${systemCSS}\n${geometrySelector} {\n${geometry.join('\n')}\n}\n${selector} {\n${palette.join('\n')}\n}\n:root:not([data-theme]) { color-scheme: light dark; }\n[data-theme="light"] { color-scheme: light; }\n[data-theme="dark"] { color-scheme: dark; }\n@media (prefers-reduced-motion: reduce) {\n ${selector} { --ui-motion-fast: 0ms; --ui-motion-base: 0ms; --ui-motion-press: 0ms; --ui-press-scale: 1; }\n}\n`;
100
219
  }
@@ -0,0 +1,33 @@
1
+ /** Translate canonical recipe values for the reusable value editors; never duplicate design bounds here. */
2
+ const lengthUnits = { px: 1, rem: 16, em: 16, cm: 96 / 2.54, mm: 96 / 25.4, Q: 96 / 101.6, in: 96, pt: 96 / 72, pc: 16 };
3
+ const timeUnits = { ms: 1, s: 1000 };
4
+ const rounded = value => String(Number(Number(value).toFixed(6)));
5
+
6
+ export function valueEditor(field, value, { remPixels = 16 } = {}) {
7
+ if (field.type === 'easing') return { kind: /^step-/.test(value) ? 'text' : 'curve', value };
8
+ if (['colour', 'font'].includes(field.type)) return { kind: 'text', value };
9
+ if (field.type.includes('length') || field.type === 'time') {
10
+ const unit = value.match(/[a-z]+$/i)?.[0] ?? 'px';
11
+ const units = field.type === 'time' ? timeUnits : { ...lengthUnits, rem: remPixels, em: remPixels };
12
+ const factor = units[unit];
13
+ return { kind: 'number', value: value === '0' ? '0px' : value, unit,
14
+ min: factor ? field.min / factor : .001, max: factor ? field.max / factor : 1000000,
15
+ step: (field.type === 'time' ? 10 : field.id === 'line-weight' ? .25 : 1) / (factor ?? 1),
16
+ units: field.type === 'time' ? ['ms', 's'] : ['px', 'rem'], convertible: Boolean(factor) };
17
+ }
18
+ const scale = field.type === 'fraction' || field.id === 'density' ? 100 : 1;
19
+ return { kind: field.type === 'weight' ? 'number' : 'slider', value: field.type === 'weight' ? value : Number(value) * scale,
20
+ min: field.min * scale, max: field.max * scale, step: field.type === 'weight' ? 50 : scale === 100 ? 1 : .01,
21
+ unit: scale === 100 ? '%' : '', scale };
22
+ }
23
+
24
+ export function editorValue(field, value) {
25
+ return field.type === 'fraction' || field.id === 'density' ? rounded(Number(value) / 100) : String(value);
26
+ }
27
+
28
+ export function convertValueUnit(value, nextUnit, { remPixels = 16 } = {}) {
29
+ const unit = value.match(/[a-z]+$/i)?.[0] ?? 'px';
30
+ const units = Object.hasOwn(timeUnits, unit) ? timeUnits : { ...lengthUnits, rem: remPixels, em: remPixels };
31
+ if (!Object.hasOwn(units, unit) || !Object.hasOwn(units, nextUnit)) throw new Error('Choose compatible units.');
32
+ return rounded(parseFloat(value) * units[unit] / units[nextUnit]) + nextUnit;
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@threadlabs/looma",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "type": "module",
5
5
  "description": "Looma components as HTML, Vue, and plain DOM, with design tokens.",
6
6
  "license": "MIT",
@@ -127,7 +127,7 @@
127
127
  "@tiptap/pm": ">=2 <3",
128
128
  "@tiptap/vue-3": ">=2 <3",
129
129
  "vue": "^3.5.43",
130
- "markupress": "^0.8.0",
130
+ "markupress": "^0.8.3",
131
131
  "@nextwebwg/htmlkit": "1.0.0-alpha.69",
132
132
  "vite": "^8.3.4"
133
133
  },
@@ -167,7 +167,7 @@
167
167
 
168
168
  /* A count is an adornment, not another line of label text. Keep the standard control height
169
169
  with enough inset for a 24px mark; large content can still grow the button naturally. */
170
- :scope:where([data-component~="ui-button"]):not([variant="link"]):has(.badge:not(:empty)) {
170
+ :scope:where([data-component~="ui-button"]):not([data-appearance="link"]):has(.badge:not(:empty)) {
171
171
  padding-block: var(--ui-space-0-5) !important;
172
172
  }
173
173
 
@@ -212,7 +212,7 @@
212
212
 
213
213
  /* Card actions inherit Button's palette and behavior; only their shared layout changes.
214
214
  The icon may contain any decorative composition. Empty icons remove their column. */
215
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=card"] {
215
+ :scope:where([data-component~="ui-button"])[data-appearance="card"] {
216
216
  display: grid !important;
217
217
  grid-template-columns: auto minmax(0, 1fr) auto;
218
218
  align-content: center !important;
@@ -225,10 +225,10 @@
225
225
  box-shadow: none;
226
226
  background-image: none;
227
227
  }
228
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=card"][data-ui-button-state~="size=lg"] {
228
+ :scope:where([data-component~="ui-button"])[data-appearance="card"][size="lg"] {
229
229
  min-block-size: max(var(--ui-control-min-block-size), var(--ui-control-size-lg));
230
230
  }
231
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=card"]:has(.card-icon:empty) {
231
+ :scope:where([data-component~="ui-button"])[data-appearance="card"]:has(.card-icon:empty) {
232
232
  grid-template-columns: minmax(0, 1fr) auto;
233
233
  }
234
234
  .card-icon {
@@ -255,11 +255,11 @@
255
255
  }
256
256
 
257
257
  /* A neutral card encloses content quietly; ordinary outlines keep their action-tone edge. */
258
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=card"][data-ui-button-state~="tone=neutral"] {
258
+ :scope:where([data-component~="ui-button"])[data-appearance="card"][data-tone="neutral"] {
259
259
  border-color: var(--ui-button-border, var(--ui-border));
260
260
  }
261
261
 
262
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=card"][data-ui-button-state~="tone=neutral"]:hover:not(:disabled, [aria-disabled="true"]) {
262
+ :scope:where([data-component~="ui-button"])[data-appearance="card"][data-tone="neutral"]:hover:not(:disabled, [aria-disabled="true"]) {
263
263
  border-color: var(--ui-button-hover-border, var(--ui-border-strong));
264
264
  }
265
265
 
@@ -271,19 +271,19 @@
271
271
  box-shadow: var(--ui-pressed);
272
272
  }
273
273
 
274
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=solid"], :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=danger"] {
274
+ :scope:where([data-component~="ui-button"])[data-appearance="solid"], :scope:where([data-component~="ui-button"])[data-appearance="danger"] {
275
275
  border-color: var(--_tone);
276
276
  background-color: var(--_tone);
277
277
  color: var(--_on-tone);
278
278
  box-shadow: var(--ui-button-solid-shadow, var(--ui-action-shadow, var(--_ui-action-shadow)));
279
279
  }
280
280
 
281
- :is(:scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=solid"], :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=danger"]):hover:not(:disabled, [aria-disabled="true"]) {
281
+ :is(:scope:where([data-component~="ui-button"])[data-appearance="solid"], :scope:where([data-component~="ui-button"])[data-appearance="danger"]):hover:not(:disabled, [aria-disabled="true"]) {
282
282
  background-color: var(--_tone-hover);
283
283
  border-color: var(--_tone-hover);
284
284
  }
285
285
 
286
- :is(:scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=solid"], :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=danger"]):active:not(:disabled, [aria-disabled="true"]) {
286
+ :is(:scope:where([data-component~="ui-button"])[data-appearance="solid"], :scope:where([data-component~="ui-button"])[data-appearance="danger"]):active:not(:disabled, [aria-disabled="true"]) {
287
287
  background-color: var(--_tone-active);
288
288
  border-color: var(--_tone-active);
289
289
  background-image: none;
@@ -292,7 +292,7 @@
292
292
 
293
293
  /* Tone is the colour, variant is the volume. Every pairing works: a danger outline is a
294
294
  destructive secondary action, not a special case. */
295
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="tone=neutral"] {
295
+ :scope:where([data-component~="ui-button"])[data-tone="neutral"] {
296
296
  --_hue: var(--ui-text);
297
297
  --_hue-hover: var(--ui-text);
298
298
  --_hue-active: var(--ui-text);
@@ -300,40 +300,40 @@
300
300
  --_on-hue: var(--ui-surface);
301
301
  }
302
302
 
303
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="tone=danger"], :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=danger"] {
303
+ :scope:where([data-component~="ui-button"])[data-tone="danger"], :scope:where([data-component~="ui-button"])[data-appearance="danger"] {
304
304
  --_hue: var(--ui-danger);
305
305
  --_hue-hover: var(--ui-danger-hover);
306
- --_hue-active: var(--ui-danger-hover);
307
- --_hue-text: var(--ui-danger-hover);
306
+ --_hue-active: var(--ui-danger-active, var(--ui-danger-hover));
307
+ --_hue-text: var(--ui-danger-subtle-text);
308
308
  --_on-hue: var(--ui-on-danger);
309
309
  }
310
310
 
311
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="tone=success"] {
311
+ :scope:where([data-component~="ui-button"])[data-tone="success"] {
312
312
  --_hue: var(--ui-success);
313
- --_hue-hover: color-mix(in srgb, var(--ui-success) 85%, #000);
314
- --_hue-active: color-mix(in srgb, var(--ui-success) 75%, #000);
315
- --_hue-text: color-mix(in srgb, var(--ui-success) 85%, #000);
316
- --_on-hue: var(--ui-surface);
313
+ --_hue-hover: var(--ui-success-hover);
314
+ --_hue-active: var(--ui-success-active);
315
+ --_hue-text: var(--ui-success-subtle-text);
316
+ --_on-hue: var(--ui-success-on-strong);
317
317
  }
318
318
 
319
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="tone=warning"] {
319
+ :scope:where([data-component~="ui-button"])[data-tone="warning"] {
320
320
  --_hue: var(--ui-warning);
321
- --_hue-hover: color-mix(in srgb, var(--ui-warning) 85%, #000);
322
- --_hue-active: color-mix(in srgb, var(--ui-warning) 75%, #000);
323
- --_hue-text: color-mix(in srgb, var(--ui-warning) 85%, #000);
324
- --_on-hue: var(--ui-surface);
321
+ --_hue-hover: var(--ui-warning-hover);
322
+ --_hue-active: var(--ui-warning-active);
323
+ --_hue-text: var(--ui-warning-subtle-text);
324
+ --_on-hue: var(--ui-warning-on-strong);
325
325
  }
326
326
 
327
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="tone=info"] {
327
+ :scope:where([data-component~="ui-button"])[data-tone="info"] {
328
328
  --_hue: var(--ui-info);
329
- --_hue-hover: color-mix(in srgb, var(--ui-info) 85%, #000);
330
- --_hue-active: color-mix(in srgb, var(--ui-info) 75%, #000);
331
- --_hue-text: color-mix(in srgb, var(--ui-info) 85%, #000);
332
- --_on-hue: var(--ui-surface);
329
+ --_hue-hover: var(--ui-info-hover);
330
+ --_hue-active: var(--ui-info-active);
331
+ --_hue-text: var(--ui-info-subtle-text);
332
+ --_on-hue: var(--ui-info-on-strong);
333
333
  }
334
334
 
335
335
  /* A value picker is a field, with Select's quiet edge and resting lift. */
336
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=field"] {
336
+ :scope:where([data-component~="ui-button"])[data-appearance="field"] {
337
337
  --_ui-field-shadow: var(--ui-shadow-sm);
338
338
  border-color: var(--ui-button-border, var(--ui-control-border, var(--ui-border-strong)));
339
339
  border-radius: var(--ui-button-radius, var(--ui-field-radius, var(--ui-radius-md)));
@@ -343,23 +343,23 @@
343
343
  box-shadow: var(--ui-button-shadow, var(--_ui-field-shadow));
344
344
  }
345
345
 
346
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=field"]:hover:not(:disabled, [aria-disabled="true"]) {
346
+ :scope:where([data-component~="ui-button"])[data-appearance="field"]:hover:not(:disabled, [aria-disabled="true"]) {
347
347
  border-color: var(--ui-button-hover-border, var(--ui-control-border-hover, var(--ui-text-secondary)));
348
348
  background-color: var(--ui-button-hover-surface, var(--ui-control-surface, var(--ui-surface-elevated)));
349
349
  }
350
350
 
351
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=field"]:is(:active, [aria-expanded="true"]):not(:disabled, [aria-disabled="true"]) {
351
+ :scope:where([data-component~="ui-button"])[data-appearance="field"]:is(:active, [aria-expanded="true"]):not(:disabled, [aria-disabled="true"]) {
352
352
  --_ui-field-shadow: var(--ui-pressed);
353
353
  }
354
354
 
355
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=field"]:focus-visible {
355
+ :scope:where([data-component~="ui-button"])[data-appearance="field"]:focus-visible {
356
356
  border-color: var(--ui-control-focus, var(--ui-accent-solid));
357
357
  box-shadow:
358
358
  var(--ui-button-shadow, var(--_ui-field-shadow)),
359
359
  var(--ui-control-focus-halo-shadow);
360
360
  }
361
361
 
362
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=ghost"] {
362
+ :scope:where([data-component~="ui-button"])[data-appearance="ghost"] {
363
363
  border-color: transparent;
364
364
  background: transparent;
365
365
  box-shadow: none;
@@ -367,14 +367,14 @@
367
367
  }
368
368
 
369
369
  /* Tints derive from the text colour, not a surface token, so hover and press show on any background. */
370
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=ghost"]:hover:not(:disabled, [aria-disabled="true"]) {
370
+ :scope:where([data-component~="ui-button"])[data-appearance="ghost"]:hover:not(:disabled, [aria-disabled="true"]) {
371
371
  background-color: var(--ui-button-ghost-hover-surface, oklch(from var(--_tone) l c h / 10%));
372
372
  border-color: transparent;
373
373
  }
374
374
 
375
375
  /* Pressed, or holding its menu open, a ghost button sinks slightly: a light surface with a faint
376
376
  edge and a soft inner shadow. Lighter than hover, so an open menu's trigger never reads as heavy. */
377
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=ghost"]:is(:active, [aria-expanded="true"]):not(:disabled, [aria-disabled="true"]) {
377
+ :scope:where([data-component~="ui-button"])[data-appearance="ghost"]:is(:active, [aria-expanded="true"]):not(:disabled, [aria-disabled="true"]) {
378
378
  background-color: var(--ui-button-ghost-active-surface, color-mix(in oklch, var(--ui-surface), var(--_tone) 3%));
379
379
  border-color: transparent;
380
380
  box-shadow: inset 0 0 0 1px oklch(from var(--_tone) l c h / 10%), inset 0 1px 2px oklch(from var(--_tone) l c h / 8%);
@@ -388,13 +388,13 @@
388
388
  var(--ui-focus-halo);
389
389
  }
390
390
 
391
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=solid"]:focus-visible {
391
+ :scope:where([data-component~="ui-button"])[data-appearance="solid"]:focus-visible {
392
392
  box-shadow:
393
393
  var(--ui-button-solid-shadow, var(--ui-action-shadow, var(--_ui-action-shadow))),
394
394
  var(--ui-focus-halo);
395
395
  }
396
396
 
397
- :is(:scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=ghost"], :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=link"], :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=card"]):focus-visible {
397
+ :is(:scope:where([data-component~="ui-button"])[data-appearance="ghost"], :scope:where([data-component~="ui-button"])[data-appearance="link"], :scope:where([data-component~="ui-button"])[data-appearance="card"]):focus-visible {
398
398
  box-shadow: var(--ui-focus-halo);
399
399
  }
400
400
 
@@ -416,7 +416,7 @@
416
416
  }
417
417
 
418
418
  /* Ghost keeps no border, but gains the same neutral surface as other disabled buttons. */
419
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=ghost"]:is(:disabled, [aria-disabled="true"]:not([aria-busy="true"])) {
419
+ :scope:where([data-component~="ui-button"])[data-appearance="ghost"]:is(:disabled, [aria-disabled="true"]:not([aria-busy="true"])) {
420
420
  background-color: var(--ui-disabled-surface);
421
421
  border-color: transparent;
422
422
  }
@@ -433,12 +433,12 @@
433
433
  a link-style one keeps its text size, so a standalone one ("See all" under a list) needs the
434
434
  area. xs sits inside a line of text the same way, so it keeps its size and gains the area too.
435
435
  Only these need it: a hit area on every button would reach over its neighbours. */
436
- html[data-ui-input-modality="touch"] :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=link"],
436
+ html[data-ui-input-modality="touch"] :scope:where([data-component~="ui-button"])[data-appearance="link"],
437
437
  html[data-ui-input-modality="touch"] :scope:where([data-component~="ui-button"])[data-ui-button-state~="size=xs"] {
438
438
  position: relative;
439
439
  }
440
440
 
441
- html[data-ui-input-modality="touch"] :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=link"]::after,
441
+ html[data-ui-input-modality="touch"] :scope:where([data-component~="ui-button"])[data-appearance="link"]::after,
442
442
  html[data-ui-input-modality="touch"] :scope:where([data-component~="ui-button"])[data-ui-button-state~="size=xs"]::after {
443
443
  position: absolute;
444
444
  inset: 50% auto auto 50%;
@@ -450,7 +450,7 @@
450
450
 
451
451
  /* Link: an inline text action. No box, padding, or minimum height (inline targets are exempt under
452
452
  WCAG 2.5.8); the inherited font; an underline that strengthens on hover; a visible focus ring. */
453
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=link"] {
453
+ :scope:where([data-component~="ui-button"])[data-appearance="link"] {
454
454
  display: inline !important;
455
455
  min-block-size: 0;
456
456
  padding: 0 !important;
@@ -467,24 +467,24 @@
467
467
  transition: color var(--ui-motion-fast) var(--ui-motion-ease), text-decoration-color var(--ui-motion-fast) var(--ui-motion-ease);
468
468
  }
469
469
 
470
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=link"]:hover:not(:disabled, [aria-disabled="true"]) {
470
+ :scope:where([data-component~="ui-button"])[data-appearance="link"]:hover:not(:disabled, [aria-disabled="true"]) {
471
471
  color: var(--ui-button-link-hover-text, var(--ui-text-primary));
472
472
  text-decoration-color: currentColor;
473
473
  background: none;
474
474
  transform: none;
475
475
  }
476
476
 
477
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=link"]:active:not(:disabled, [aria-disabled="true"]) {
477
+ :scope:where([data-component~="ui-button"])[data-appearance="link"]:active:not(:disabled, [aria-disabled="true"]) {
478
478
  background: none;
479
479
  transform: none;
480
480
  }
481
481
 
482
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=link"]:is(:disabled, [aria-disabled="true"]:not([aria-busy="true"])) {
482
+ :scope:where([data-component~="ui-button"])[data-appearance="link"]:is(:disabled, [aria-disabled="true"]:not([aria-busy="true"])) {
483
483
  background: none;
484
484
  border-color: transparent;
485
485
  }
486
486
 
487
- :scope:where([data-component~="ui-button"])[data-ui-button-state~="variant=link"]:focus-visible {
487
+ :scope:where([data-component~="ui-button"])[data-appearance="link"]:focus-visible {
488
488
  box-shadow: none;
489
489
  /* Fallback keeps the ring if the token is missing: an unresolved var() would void the outline. */
490
490
  outline: var(--ui-focus-width) solid var(--ui-focus-ring);
@@ -49,6 +49,47 @@
49
49
  box-shadow: var(--ui-shadow-lg);
50
50
  }
51
51
 
52
+ :scope:where([data-component~="ui-card"])[data-ui-card-state~="variant=glass"] {
53
+ --_ui-panel-glass: clamp(0, var(--ui-glassiness, 0), 1);
54
+ --_ui-panel-separation: clamp(0, var(--ui-surface-separation, 0), 1);
55
+ --_ui-panel-rim: calc((var(--_ui-panel-separation) + var(--_ui-panel-glass) * 0.25) * 0.65 * 8px);
56
+ border-width: calc(var(--ui-border-width) + var(--_ui-panel-rim));
57
+ border-color: transparent;
58
+ background:
59
+ linear-gradient(var(--_ui-card-surface), var(--_ui-card-surface)) padding-box,
60
+ linear-gradient(var(--ui-material-glass, var(--ui-surface-raised)), var(--ui-material-glass, var(--ui-surface-raised))) border-box,
61
+ var(--_ui-card-surface) padding-box;
62
+ box-shadow:
63
+ var(--ui-shadow-lg),
64
+ inset 0 0 0 var(--ui-border-width) var(--ui-material-edge, var(--ui-border-strong));
65
+ backdrop-filter: blur(calc(var(--_ui-panel-glass) * 0.65 * 18px)) saturate(calc(1 + var(--_ui-panel-glass) * 0.65 * 0.18));
66
+ -webkit-backdrop-filter: blur(calc(var(--_ui-panel-glass) * 0.65 * 18px)) saturate(calc(1 + var(--_ui-panel-glass) * 0.65 * 0.18));
67
+ }
68
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
69
+ :scope:where([data-component~="ui-card"])[data-ui-card-state~="variant=glass"] {
70
+ background: var(--_ui-card-surface);
71
+ }
72
+ }
73
+ @media (prefers-reduced-transparency: reduce) {
74
+ :scope:where([data-component~="ui-card"])[data-ui-card-state~="variant=glass"] {
75
+ background: var(--_ui-card-surface);
76
+ border-width: var(--ui-border-width);
77
+ border-color: var(--ui-border-strong);
78
+ backdrop-filter: none;
79
+ -webkit-backdrop-filter: none;
80
+ }
81
+ }
82
+ @media (forced-colors: active) {
83
+ :scope:where([data-component~="ui-card"])[data-ui-card-state~="variant=glass"] {
84
+ background: Canvas;
85
+ color: CanvasText;
86
+ border: var(--ui-border-width) solid CanvasText;
87
+ box-shadow: none;
88
+ backdrop-filter: none;
89
+ -webkit-backdrop-filter: none;
90
+ }
91
+ }
92
+
52
93
  :scope:where([data-component~="ui-card"])[data-ui-card-state~="tone=accent"] {
53
94
  border-color: color-mix(in srgb, var(--ui-accent) 25%, var(--ui-border-default));
54
95
  --_ui-card-surface: color-mix(in srgb, var(--ui-accent) 6%, var(--ui-surface-default));
@@ -116,6 +157,12 @@
116
157
  @scope ([data-component~="ui-card"]) to ([data-component]) {
117
158
  /* A surface that groups content. Its children stack with even space between them, so a card
118
159
  needs no layout of its own. */
160
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
161
+ }
162
+ @media (prefers-reduced-transparency: reduce) {
163
+ }
164
+ @media (forced-colors: active) {
165
+ }
119
166
 
120
167
  /* A colored panel shades rows in its own hue, without changing button intent. */
121
168