@adia-ai/web-components 0.8.3 → 0.8.5

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 (95) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/USAGE.md +60 -25
  3. package/components/accordion/accordion.css +5 -0
  4. package/components/adia-mark/adia-mark.a2ui.json +123 -0
  5. package/components/adia-mark/adia-mark.class.js +64 -0
  6. package/components/adia-mark/adia-mark.css +48 -0
  7. package/components/adia-mark/adia-mark.d.ts +20 -0
  8. package/components/adia-mark/adia-mark.examples.md +26 -0
  9. package/components/adia-mark/adia-mark.js +17 -0
  10. package/components/adia-mark/adia-mark.test.js +69 -0
  11. package/components/adia-mark/adia-mark.yaml +121 -0
  12. package/components/agent-artifact/agent-artifact.css +1 -0
  13. package/components/agent-questions/agent-questions.a2ui.json +2 -1
  14. package/components/agent-questions/agent-questions.class.js +96 -53
  15. package/components/agent-questions/agent-questions.css +27 -106
  16. package/components/agent-questions/agent-questions.yaml +1 -0
  17. package/components/agent-reasoning/agent-reasoning.css +9 -5
  18. package/components/button/button.class.js +9 -2
  19. package/components/button/button.css +1 -1
  20. package/components/calendar-grid/calendar-grid.a2ui.json +3 -1
  21. package/components/calendar-grid/calendar-grid.class.js +11 -6
  22. package/components/calendar-grid/calendar-grid.css +42 -20
  23. package/components/calendar-grid/calendar-grid.yaml +5 -0
  24. package/components/calendar-picker/calendar-picker.a2ui.json +3 -1
  25. package/components/calendar-picker/calendar-picker.class.js +11 -6
  26. package/components/calendar-picker/calendar-picker.css +41 -19
  27. package/components/calendar-picker/calendar-picker.yaml +5 -0
  28. package/components/chart/chart.class.js +17 -14
  29. package/components/chat-thread/chat-input.a2ui.json +3 -1
  30. package/components/chat-thread/chat-input.css +12 -13
  31. package/components/chat-thread/chat-input.js +6 -2
  32. package/components/chat-thread/chat-input.yaml +3 -0
  33. package/components/check/check.css +1 -1
  34. package/components/color-input/color-input.class.js +8 -5
  35. package/components/color-picker/color-picker.class.js +6 -3
  36. package/components/combobox/combobox.a2ui.json +3 -1
  37. package/components/combobox/combobox.class.js +1 -3
  38. package/components/combobox/combobox.css +9 -12
  39. package/components/combobox/combobox.yaml +2 -0
  40. package/components/date-range-picker/date-range-picker.css +1 -0
  41. package/components/field/field.class.js +10 -7
  42. package/components/fields/fields.class.js +15 -12
  43. package/components/icon/icon.class.js +9 -5
  44. package/components/index.js +1 -0
  45. package/components/input/input.css +6 -2
  46. package/components/nav/nav.class.js +2 -1
  47. package/components/nav-group/nav-group.css +5 -6
  48. package/components/noodles/noodles.class.js +14 -10
  49. package/components/option-card/option-card.css +1 -0
  50. package/components/page/page.class.js +3 -2
  51. package/components/radio/radio.css +1 -1
  52. package/components/range/range.css +2 -0
  53. package/components/select/select.a2ui.json +2 -1
  54. package/components/select/select.class.js +12 -11
  55. package/components/select/select.css +29 -40
  56. package/components/select/select.test.js +6 -2
  57. package/components/select/select.yaml +1 -0
  58. package/components/swatch/swatch.a2ui.json +3 -1
  59. package/components/swatch/swatch.class.js +41 -32
  60. package/components/swatch/swatch.css +13 -18
  61. package/components/swatch/swatch.yaml +2 -0
  62. package/components/swiper/swiper.class.js +4 -1
  63. package/components/swiper/swiper.css +13 -4
  64. package/components/switch/switch.css +1 -1
  65. package/components/tag/tag.a2ui.json +4 -1
  66. package/components/tag/tag.class.js +3 -1
  67. package/components/tag/tag.css +13 -15
  68. package/components/tag/tag.yaml +3 -0
  69. package/components/tags-input/tags-input.css +1 -0
  70. package/components/textarea/textarea.css +2 -0
  71. package/components/time-picker/time-picker.css +2 -0
  72. package/components/timeline/timeline.a2ui.json +2 -1
  73. package/components/timeline/timeline.class.js +7 -8
  74. package/components/timeline/timeline.css +14 -26
  75. package/components/timeline/timeline.yaml +1 -0
  76. package/components/toggle-group/toggle-group.css +1 -0
  77. package/components/toolbar/toolbar.class.js +11 -6
  78. package/components/tree/tree.class.js +5 -2
  79. package/components/tree/tree.css +5 -2
  80. package/components/upload/upload.css +1 -0
  81. package/core/data-stream.js +21 -15
  82. package/core/element.js +36 -1
  83. package/core/element.test.js +67 -0
  84. package/core/icons-phosphor.js +86 -22
  85. package/core/icons.js +22 -8
  86. package/dist/theme-provider.min.js +2 -2
  87. package/dist/web-components.min.css +1 -1
  88. package/dist/web-components.min.js +109 -108
  89. package/dist/web-components.sheet.js +1 -1
  90. package/package.json +1 -5
  91. package/styles/colors/semantics/features.css +6 -1
  92. package/styles/components.css +1 -0
  93. package/traits/error-shake/error-shake.js +23 -17
  94. package/traits/fade-presence/fade-presence.js +19 -15
  95. package/traits/success-checkmark/success-checkmark.js +21 -16
@@ -51,19 +51,22 @@ export class UIFields extends UIElement {
51
51
 
52
52
  connected() {
53
53
  this.#syncInline();
54
- this.#mo = new MutationObserver((records) => {
55
- // Only re-sync on childList changes that involve direct <field-ui>
56
- // children attribute mutations on grandchildren aren't ours to
57
- // care about.
58
- for (const r of records) {
59
- if (r.type !== 'childList') continue;
60
- if (this.#hasFieldChild(r.addedNodes) || this.#hasFieldChild(r.removedNodes)) {
61
- this.#syncInline();
62
- return;
54
+ // gh#285 SSR DOM shims (linkedom) have no MutationObserver global.
55
+ if (typeof MutationObserver !== 'undefined') {
56
+ this.#mo = new MutationObserver((records) => {
57
+ // Only re-sync on childList changes that involve direct <field-ui>
58
+ // children attribute mutations on grandchildren aren't ours to
59
+ // care about.
60
+ for (const r of records) {
61
+ if (r.type !== 'childList') continue;
62
+ if (this.#hasFieldChild(r.addedNodes) || this.#hasFieldChild(r.removedNodes)) {
63
+ this.#syncInline();
64
+ return;
65
+ }
63
66
  }
64
- }
65
- });
66
- this.#mo.observe(this, { childList: true });
67
+ });
68
+ this.#mo.observe(this, { childList: true });
69
+ }
67
70
  }
68
71
 
69
72
  disconnected() {
@@ -30,13 +30,17 @@ function warnIfIconRegistryUnwired() {
30
30
  // eslint-disable-next-line no-console
31
31
  console.error(
32
32
  `[icon-ui] No icons are registered and no icon loaders were installed — every <icon-ui> renders blank.\n` +
33
- ` The zero-config import.meta.glob path does not work from inside a published dependency under Vite 8 / rolldown.\n` +
34
- ` Fix from your APP entry (where import.meta.glob is transformed):\n` +
33
+ ` Two known causes (gh#287): (1) Vite's dev-server optimizeDeps pre-bundled this package, which empties\n` +
34
+ ` the auto-discovery glob add to your Vite config:\n` +
35
+ ` optimizeDeps: { exclude: ['@adia-ai/web-components', '@adia-ai/web-modules'] }\n` +
36
+ ` (2) @phosphor-icons/core is missing or this file was re-nested by a packaging step (Yarn PnP, a custom\n` +
37
+ ` bundler). If neither applies, install loaders manually with plain imports from your APP entry:\n` +
35
38
  ` npm i @phosphor-icons/core\n` +
36
39
  ` import { installIconLoaders } from '@adia-ai/web-components/core/icons';\n` +
37
- ` installIconLoaders({ regular: import.meta.glob(\n` +
38
- ` '/node_modules/@phosphor-icons/core/assets/regular/*.svg',\n` +
39
- ` { query: '?raw', import: 'default', eager: true }) });\n` +
40
+ ` import caretRight from '@phosphor-icons/core/assets/regular/caret-right.svg?raw';\n` +
41
+ ` installIconLoaders({ regular: {\n` +
42
+ ` '/node_modules/@phosphor-icons/core/assets/regular/caret-right.svg': caretRight,\n` +
43
+ ` } });\n` +
40
44
  ` See USAGE.md § Icons.`,
41
45
  );
42
46
  }, 0);
@@ -58,6 +58,7 @@ export { UISwiper } from './swiper/swiper.js';
58
58
  export { UIUpload } from './upload/upload.js';
59
59
  export { UICard } from './card/card.js';
60
60
  export { UIAvatar, UIAvatarGroup } from './avatar/avatar.js';
61
+ export { UIAdiaMark } from './adia-mark/adia-mark.js';
61
62
  export { UIProgress } from './progress/progress.js';
62
63
  export { UIStepProgress } from './step-progress/step-progress.js';
63
64
  export { UISkeleton } from './skeleton/skeleton.js';
@@ -107,14 +107,18 @@ input-ui:not([disabled]) [slot="field"]:hover [slot="suffix"] {
107
107
  /* hover rules moved outside @scope — see Safari 17.x bug note at top. */
108
108
  :scope:not([disabled]):focus-within [slot="field"] {
109
109
  /* Canonical ring — consumes the L3 --input-focus-ring token
110
- which aliases --a-focus-ring. Border stays stable; the ring
111
- is the focus affordance (WCAG 2.2 SC 2.4.11/2.4.13). */
110
+ which aliases --a-focus-ring. The border goes transparent while
111
+ the ring paints (operator ruling 2026-07-16): the ring alone is
112
+ the focus affordance; contrast obligations move to the ring
113
+ (--a-focus-color) rather than the border (WCAG 2.2 SC 2.4.11/2.4.13). */
112
114
  box-shadow: var(--input-focus-ring);
113
115
  color: var(--input-fg-focus);
116
+ border-color: transparent;
114
117
  }
115
118
  :scope[aria-invalid="true"]:not([disabled]):focus-within [slot="field"],
116
119
  :scope[error]:not([disabled]):focus-within [slot="field"] {
117
120
  box-shadow: var(--input-focus-ring-invalid);
121
+ border-color: transparent;
118
122
  }
119
123
 
120
124
  /* Text (contenteditable span) */
@@ -61,7 +61,8 @@ export class UINav extends UIElement {
61
61
  this.addEventListener('click', this.#onClick);
62
62
 
63
63
  // ResizeObserver only meaningful for primary variant — section is static.
64
- if (this.variant !== 'section') {
64
+ // gh#285 SSR DOM shims (linkedom) have no ResizeObserver global.
65
+ if (this.variant !== 'section' && typeof ResizeObserver !== 'undefined') {
65
66
  this.#ro = new ResizeObserver(() => this.#updateTooltips());
66
67
  this.#ro.observe(this);
67
68
  }
@@ -182,12 +182,11 @@
182
182
  justify-content: center;
183
183
  padding: 0;
184
184
  }
185
- [slot="icon"] icon-ui {
186
- --a-icon-size: --a-icon-size;
187
- }
188
- [slot="caret"] icon-ui {
189
- --a-icon-size: --a-caret-size;
190
- }
185
+ /* (Removed two `--a-icon-size: --a-icon-size/--a-caret-size` lines —
186
+ both were missing var() so the value was the literal token NAME,
187
+ invalid at the icon's var() site: a silent no-op. The caret is
188
+ display:none in collapsed mode anyway; the icon keeps the
189
+ inherited --a-icon-size.) */
191
190
  :scope {
192
191
  align-self: stretch;
193
192
  }
@@ -108,19 +108,23 @@ export class UINoodles extends UIElement {
108
108
  this.#svg.setAttribute('data-noodle-svg', '');
109
109
  this.prepend(this.#svg);
110
110
 
111
- // Observers for child layout changes
112
- this.#resizeObs = new ResizeObserver(() => this.#scheduleUpdate());
113
- this.#mutationObs = new MutationObserver(() => {
114
- this.#rebuildPortIndicators();
115
- this.#scheduleUpdate();
116
- });
117
-
118
- this.#resizeObs.observe(this);
111
+ // Observers for child layout changes.
112
+ // gh#285 SSR DOM shims (linkedom) have neither observer global.
113
+ if (typeof ResizeObserver !== 'undefined') {
114
+ this.#resizeObs = new ResizeObserver(() => this.#scheduleUpdate());
115
+ this.#resizeObs.observe(this);
116
+ }
117
+ if (typeof MutationObserver !== 'undefined') {
118
+ this.#mutationObs = new MutationObserver(() => {
119
+ this.#rebuildPortIndicators();
120
+ this.#scheduleUpdate();
121
+ });
122
+ }
119
123
  this.#observeMutations();
120
124
 
121
125
  // Observe each port-bearing child
122
126
  for (const child of this.#getPortChildren()) {
123
- this.#resizeObs.observe(child);
127
+ this.#resizeObs?.observe(child);
124
128
  }
125
129
 
126
130
  this.#rebuildPortIndicators();
@@ -181,7 +185,7 @@ export class UINoodles extends UIElement {
181
185
  // Sync resize observer with current port children
182
186
  const portChildren = this.#getPortChildren();
183
187
  for (const child of portChildren) {
184
- this.#resizeObs.observe(child);
188
+ this.#resizeObs?.observe(child);
185
189
  }
186
190
 
187
191
  // Update port indicator positions
@@ -237,5 +237,6 @@ option-card-ui[checked] [slot="icon"] {
237
237
  /* ── Focus ── */
238
238
  :scope:focus-visible {
239
239
  box-shadow: var(--option-card-focus-ring);
240
+ border-color: transparent;
240
241
  }
241
242
  }
@@ -78,13 +78,14 @@ export class UIPage extends UIElement {
78
78
  header.insertAdjacentElement('beforebegin', this.#sentinel);
79
79
  }
80
80
 
81
- if (!this.#observer) {
81
+ // gh#285 — SSR DOM shims (linkedom) have no IntersectionObserver global.
82
+ if (!this.#observer && typeof IntersectionObserver !== 'undefined') {
82
83
  this.#observer = new IntersectionObserver((entries) => {
83
84
  this.toggleAttribute('data-header-stuck', !entries[0].isIntersecting);
84
85
  }, { threshold: 0 });
85
86
  }
86
87
 
87
- this.#observer.observe(this.#sentinel);
88
+ this.#observer?.observe(this.#sentinel);
88
89
  }
89
90
 
90
91
  #teardownSticky() {
@@ -100,7 +100,7 @@ radio-ui[checked] [slot="dot"]::after {
100
100
  :scope[label]::after { content: attr(label); }
101
101
 
102
102
  /* Focus */
103
- :scope:focus-visible [slot="dot"] { box-shadow: var(--radio-focus-ring); }
103
+ :scope:focus-visible [slot="dot"] { box-shadow: var(--radio-focus-ring); border-color: transparent; }
104
104
 
105
105
  /* Size handled by universal [size] attribute system. */
106
106
  }
@@ -79,10 +79,12 @@ range-ui:not([disabled]) [slot="field"]:hover [data-layer="fill"] {
79
79
  :scope:focus-visible [slot="field"] {
80
80
  /* Canonical ring via L3 token (see semantics.css FOCUS block). */
81
81
  box-shadow: var(--range-focus-ring);
82
+ border-color: transparent;
82
83
  }
83
84
  :scope[aria-invalid="true"]:focus-visible [slot="field"],
84
85
  :scope[error]:focus-visible [slot="field"] {
85
86
  box-shadow: var(--range-focus-ring-invalid);
87
+ border-color: transparent;
86
88
  }
87
89
 
88
90
  /* ── Dual-layer fill: identical layouts, overlay clipped to fill % ── */
@@ -169,7 +169,8 @@
169
169
  "category": "input",
170
170
  "composes": [
171
171
  "icon-ui",
172
- "tag-ui"
172
+ "tag-ui",
173
+ "button-ui"
173
174
  ],
174
175
  "events": {
175
176
  "change": {
@@ -381,7 +381,8 @@ export class UISelect extends UIFormElement {
381
381
  // options are already parsed/set, and skip unless there's a real
382
382
  // <option>/<optgroup> to parse — so the component's own listbox/empty-state
383
383
  // renders (and #parseOptions's own option removals) don't re-enter or loop.
384
- if (!this.#optionObserver) {
384
+ // gh#285 — SSR DOM shims (linkedom) have no MutationObserver global.
385
+ if (!this.#optionObserver && typeof MutationObserver !== 'undefined') {
385
386
  this.#optionObserver = new MutationObserver(() => {
386
387
  if (this.#options.length) return;
387
388
  const kids = this.#logicalOptionChildren();
@@ -394,7 +395,7 @@ export class UISelect extends UIFormElement {
394
395
  }
395
396
  });
396
397
  }
397
- this.#optionObserver.observe(this, { childList: true, subtree: true });
398
+ this.#optionObserver?.observe(this, { childList: true, subtree: true });
398
399
  }
399
400
 
400
401
  render() {
@@ -442,9 +443,7 @@ export class UISelect extends UIFormElement {
442
443
  ${leading}
443
444
  <span data-chips></span>
444
445
  ${displayMarkup}
445
- <button type="button" data-clear-all aria-label="Clear all" hidden>
446
- <icon-ui name="x"></icon-ui>
447
- </button>
446
+ <button-ui variant="ghost" size="xs" icon="x" aria-label="Clear all" data-clear-all hidden></button-ui>
448
447
  <icon-ui name="caret-down" slot="caret"></icon-ui>
449
448
  </span>
450
449
  ${hintMarkup}
@@ -725,10 +724,11 @@ export class UISelect extends UIFormElement {
725
724
  if (this.multiple && this.selectAll) {
726
725
  const header = document.createElement('div');
727
726
  header.setAttribute('data-select-all', '');
728
- const btn = document.createElement('button');
729
- btn.type = 'button';
727
+ const btn = document.createElement('button-ui');
728
+ btn.setAttribute('variant', 'ghost');
729
+ btn.setAttribute('size', 'xs');
730
730
  btn.dataset.selectAllBtn = '';
731
- btn.textContent = this.#allSelected() ? 'Clear' : 'Select all';
731
+ btn.setAttribute('text', this.#allSelected() ? 'Clear' : 'Select all');
732
732
  btn.addEventListener('click', this.#onSelectAllClick);
733
733
  header.appendChild(btn);
734
734
  this.#listbox.appendChild(header);
@@ -854,10 +854,11 @@ export class UISelect extends UIFormElement {
854
854
  slot.appendChild(tag);
855
855
  }
856
856
  if (overflow > 0) {
857
- const more = document.createElement('button');
858
- more.type = 'button';
857
+ const more = document.createElement('button-ui');
858
+ more.setAttribute('variant', 'ghost');
859
+ more.setAttribute('size', 'xs');
859
860
  more.dataset.more = '';
860
- more.textContent = `+${overflow} more`;
861
+ more.setAttribute('text', `+${overflow} more`);
861
862
  more.addEventListener('click', this.#onMoreClick);
862
863
  slot.appendChild(more);
863
864
  }
@@ -108,10 +108,12 @@
108
108
  /* Canonical ring via L3 token (see semantics.css FOCUS block). */
109
109
  box-shadow: var(--select-focus-ring);
110
110
  color: var(--select-fg);
111
+ border-color: transparent;
111
112
  }
112
113
  :scope[aria-invalid="true"]:focus-visible [slot="trigger"],
113
114
  :scope[error]:focus-visible [slot="trigger"] {
114
115
  box-shadow: var(--select-focus-ring-invalid);
116
+ border-color: transparent;
115
117
  }
116
118
  :scope:focus-visible::before {
117
119
  color: var(--select-fg-subtle);
@@ -167,6 +169,11 @@
167
169
  :scope [slot="trigger"] [slot="caret"] {
168
170
  color: var(--select-caret-fg);
169
171
  flex-shrink: 0;
172
+ /* Universal caret size (14px default) — without this the caret rides
173
+ the ambient icon size instead (admin-shell's global 18px bump made
174
+ sidebar workspace-select carets outsized; ruling 2026-07-16: carets
175
+ read --a-caret-size everywhere, like nav-group / tree / accordion). */
176
+ --a-icon-size: var(--a-caret-size);
170
177
  transition: rotate var(--a-duration-fast) var(--a-easing);
171
178
  }
172
179
 
@@ -218,41 +225,31 @@
218
225
  /* Chip styling tracks the active variant via tag-ui's own tokens. */
219
226
  --tag-size: var(--a-ui-tiny);
220
227
  }
228
+ /* "+N more" overflow pill — a stamped <button-ui variant="ghost" size="xs">
229
+ (gh issue 276: no native <button> in stamped DOM); pill shape + chip-row
230
+ height via button token re-points, chrome stays button-ui's own. */
221
231
  :scope[data-multi-chips] [data-chips] [data-more] {
222
- appearance: none;
223
- border: 1px solid var(--select-border);
224
- background: var(--select-bg-hover);
225
- color: var(--select-fg-muted);
226
- border-radius: var(--a-radius-full);
227
- padding: 0 var(--a-space-2);
228
- font: inherit;
229
- font-size: var(--a-ui-tiny);
230
- line-height: calc(var(--a-size) - var(--a-space-3));
231
- cursor: pointer;
232
- }
233
- :scope[data-multi-chips] [data-chips] [data-more]:hover {
234
- background: var(--a-bg-hover);
232
+ --button-radius: var(--a-radius-full);
233
+ --button-height: calc(var(--a-size) - var(--a-space-3));
234
+ --button-fg: var(--select-fg-muted);
235
+
236
+ flex-shrink: 0;
235
237
  }
236
238
 
237
- /* Clear-all `x` affordance. Visible only when [clearable] AND chips. */
239
+ /* Clear-all `x` affordance stamped <button-ui variant="ghost" size="xs"
240
+ icon="x"> (gh issue 276). Visible only when [clearable] AND chips. */
238
241
  :scope[data-multi-chips] [data-clear-all] {
239
- appearance: none;
240
- background: transparent;
241
- border: none;
242
- padding: var(--a-space-0);
243
- margin-inline-start: var(--a-space-1);
244
- color: var(--select-fg-muted);
245
- cursor: pointer;
246
- border-radius: var(--a-radius-full);
242
+ --button-radius: var(--a-radius-full);
243
+ --button-height: calc(var(--a-size) - var(--a-space-3));
244
+ --button-fg: var(--select-fg-muted);
247
245
  --a-icon-size: var(--a-ui-sm);
248
- display: inline-flex;
249
- align-items: center;
246
+
247
+ margin-inline-start: var(--a-space-1);
248
+ flex-shrink: 0;
250
249
  }
250
+ /* button-ui's own :scope display wins over the UA [hidden] rule —
251
+ keep the explicit guard. */
251
252
  :scope[data-multi-chips] [data-clear-all][hidden] { display: none; }
252
- :scope[data-multi-chips] [data-clear-all]:hover {
253
- color: var(--select-fg);
254
- background: var(--a-bg-hover);
255
- }
256
253
 
257
254
  /* In multi-select with chips present, the trigger display element is
258
255
  the rest-of-row slot — make it flex-grow but not push chips off. */
@@ -429,19 +426,11 @@ select-ui [slot="listbox"] [data-select-all] {
429
426
  border-bottom: 1px solid var(--md-sys-color-neutral-outline-variant);
430
427
  margin-bottom: var(--a-space-1);
431
428
  }
429
+ /* Select-all / Clear — stamped <button-ui variant="ghost" size="xs">
430
+ (gh issue 276); link-colored via the button fg token, chrome is button-ui's. */
432
431
  select-ui [slot="listbox"] [data-select-all-btn] {
433
- appearance: none;
434
- background: transparent;
435
- border: none;
436
- font: inherit;
437
- font-size: var(--a-ui-xs);
438
- color: var(--md-sys-color-primary);
439
- cursor: pointer;
440
- padding: var(--a-space-1) var(--a-space-2);
441
- border-radius: var(--a-radius-sm);
442
- }
443
- select-ui [slot="listbox"] [data-select-all-btn]:hover {
444
- background: var(--a-bg-hover);
432
+ --button-fg: var(--md-sys-color-primary);
433
+ --button-radius: var(--a-radius-sm);
445
434
  }
446
435
 
447
436
  /* Multi-select option rows — leading checkbox indicator + label.
@@ -272,7 +272,9 @@ describe('select-ui', () => {
272
272
  expect(chips.length).toBe(2);
273
273
  const more = s.querySelector('[slot="trigger"] [data-more]');
274
274
  expect(more).toBeTruthy();
275
- expect(more.textContent).toBe('+2 more');
275
+ // Stamped as <button-ui> (gh issue 276) — label rides the text attr.
276
+ expect(more.tagName).toBe('BUTTON-UI');
277
+ expect(more.getAttribute('text')).toBe('+2 more');
276
278
  });
277
279
 
278
280
  it('multi-select: [clearable] reveals the clear-all button when chips present', async () => {
@@ -300,7 +302,9 @@ describe('select-ui', () => {
300
302
  expect(header).toBeTruthy();
301
303
  const btn = header.querySelector('[data-select-all-btn]');
302
304
  expect(btn).toBeTruthy();
303
- expect(btn.textContent).toBe('Select all');
305
+ // Stamped as <button-ui> (gh issue 276) — label rides the text attr.
306
+ expect(btn.tagName).toBe('BUTTON-UI');
307
+ expect(btn.getAttribute('text')).toBe('Select all');
304
308
  });
305
309
 
306
310
  it('multi-select: chip remove event empties that selection from value', async () => {
@@ -25,6 +25,7 @@ description: |
25
25
  composes:
26
26
  - icon-ui # caret + option-row affixes (created in render)
27
27
  - tag-ui # multi-select chip per selected option in the trigger
28
+ - button-ui # clear-all / select-all / +N-overflow affordances (gh issue 276 — no native <button> in stamped DOM)
28
29
  props:
29
30
  name:
30
31
  description: Form control name for form data submission
@@ -100,7 +100,9 @@
100
100
  "x-adiaui": {
101
101
  "anti_patterns": [],
102
102
  "category": "display",
103
- "composes": [],
103
+ "composes": [
104
+ "button-ui"
105
+ ],
104
106
  "events": {
105
107
  "select": {
106
108
  "description": "Fired when a selectable swatch is activated (click / Enter / Space). detail carries the swatch's value, color, and label.",
@@ -63,7 +63,7 @@
63
63
  * <span data-badge></span> gamut / contrast marker (optional)
64
64
  * <span data-label></span> label text
65
65
  * <span data-detail></span> secondary line (optional)
66
- * <button data-copy></button> copy-to-clipboard (optional)
66
+ * <button-ui data-copy></button-ui> copy-to-clipboard (optional)
67
67
  *
68
68
  * Accessibility:
69
69
  * - When [selectable], host is role="button" + tabindex="0";
@@ -208,31 +208,34 @@ export class UISwatch extends UIElement {
208
208
  // the canonical post-tile region. MutationObserver closes this gap for
209
209
  // every arrival path: single-template ordering skew, separate render
210
210
  // cycle, or imperative host.appendChild().
211
- this.#chromeObserver = new MutationObserver((mutations) => {
212
- let hasChrome = false;
213
- for (const m of mutations) {
214
- for (const n of m.addedNodes) {
215
- if (n.nodeType !== 1) continue;
216
- // Direct chrome child OR a display:contents wrapper-span carrying chrome.
217
- if (n.getAttribute?.('slot') === 'chrome') { hasChrome = true; break; }
218
- if (
219
- n.tagName === 'SPAN' &&
220
- n.style?.display === 'contents' &&
221
- n.querySelector?.('[slot="chrome"]')
222
- ) { hasChrome = true; break; }
211
+ // gh#285 SSR DOM shims (linkedom) have no MutationObserver global.
212
+ if (typeof MutationObserver !== 'undefined') {
213
+ this.#chromeObserver = new MutationObserver((mutations) => {
214
+ let hasChrome = false;
215
+ for (const m of mutations) {
216
+ for (const n of m.addedNodes) {
217
+ if (n.nodeType !== 1) continue;
218
+ // Direct chrome child OR a display:contents wrapper-span carrying chrome.
219
+ if (n.getAttribute?.('slot') === 'chrome') { hasChrome = true; break; }
220
+ if (
221
+ n.tagName === 'SPAN' &&
222
+ n.style?.display === 'contents' &&
223
+ n.querySelector?.('[slot="chrome"]')
224
+ ) { hasChrome = true; break; }
225
+ }
226
+ if (hasChrome) break;
223
227
  }
224
- if (hasChrome) break;
225
- }
226
- if (!hasChrome) return;
227
- // Re-entrancy guard: #absorbChromeSlot() calls insertBefore() which itself
228
- // generates childList mutations on the host. Without this guard, every
229
- // absorb pass would re-trigger the observer indefinitely. takeRecords()
230
- // drains the queue without dispatch so the next observation cycle starts
231
- // clean.
232
- this.#absorbChromeSlot();
233
- this.#chromeObserver.takeRecords();
234
- });
235
- this.#chromeObserver.observe(this, { childList: true });
228
+ if (!hasChrome) return;
229
+ // Re-entrancy guard: #absorbChromeSlot() calls insertBefore() which itself
230
+ // generates childList mutations on the host. Without this guard, every
231
+ // absorb pass would re-trigger the observer indefinitely. takeRecords()
232
+ // drains the queue without dispatch so the next observation cycle starts
233
+ // clean.
234
+ this.#absorbChromeSlot();
235
+ this.#chromeObserver.takeRecords();
236
+ });
237
+ this.#chromeObserver.observe(this, { childList: true });
238
+ }
236
239
  }
237
240
 
238
241
  render() {
@@ -380,12 +383,15 @@ export class UISwatch extends UIElement {
380
383
  this.#detailEl.setAttribute('hidden', '');
381
384
  this.appendChild(this.#detailEl);
382
385
 
383
- this.#copyEl = document.createElement('button');
386
+ // button-ui, not a native <button> (gh issue 276) — the ⧉/✓/⚠ glyph
387
+ // rides the text attribute (button-ui renders it via attr(text)).
388
+ this.#copyEl = document.createElement('button-ui');
384
389
  this.#copyEl.setAttribute('data-copy', '');
385
- this.#copyEl.setAttribute('type', 'button');
390
+ this.#copyEl.setAttribute('variant', 'ghost');
391
+ this.#copyEl.setAttribute('size', 'xs');
386
392
  this.#copyEl.setAttribute('aria-label', 'Copy value');
387
393
  this.#copyEl.setAttribute('hidden', '');
388
- this.#copyEl.textContent = '⧉';
394
+ this.#copyEl.setAttribute('text', '⧉');
389
395
  this.appendChild(this.#copyEl);
390
396
 
391
397
  this.#stamped = true;
@@ -610,8 +616,11 @@ export class UISwatch extends UIElement {
610
616
  this.#onHostKey = (e) => {
611
617
  if (!this.selectable) return;
612
618
  if (e.key !== 'Enter' && e.key !== ' ') return;
613
- // Don't steal Space when focus is in the copy button.
614
- if (e.target === this.#copyEl) return;
619
+ // Don't steal Space when focus is in the copy button (the focused
620
+ // element is button-ui's INNER control, so identity-compare via the
621
+ // event path, not e.target).
622
+ const keyPath = e.composedPath ? e.composedPath() : [e.target];
623
+ if (keyPath.includes(this.#copyEl)) return;
615
624
  e.preventDefault();
616
625
  this.#activate();
617
626
  };
@@ -660,11 +669,11 @@ export class UISwatch extends UIElement {
660
669
  if (!this.#copyEl) return;
661
670
  if (this.#copyResetTimer != null) clearTimeout(this.#copyResetTimer);
662
671
  this.#copyEl.setAttribute('data-copy-state', ok ? 'ok' : 'fail');
663
- this.#copyEl.textContent = ok ? '✓' : '⚠';
672
+ this.#copyEl.setAttribute('text', ok ? '✓' : '⚠');
664
673
  this.#copyResetTimer = setTimeout(() => {
665
674
  if (!this.#copyEl) return;
666
675
  this.#copyEl.removeAttribute('data-copy-state');
667
- this.#copyEl.textContent = '⧉';
676
+ this.#copyEl.setAttribute('text', '⧉');
668
677
  this.#copyResetTimer = null;
669
678
  }, 1200);
670
679
  }
@@ -288,29 +288,24 @@
288
288
  color: color-mix(in oklab, var(--a-chrome-dark, #111) 70%, transparent);
289
289
  }
290
290
 
291
- /* ═══════ Copy button ═══════ */
291
+ /* ═══════ Copy button a stamped <button-ui variant="ghost" size="xs">
292
+ (gh issue 276: no native <button> in stamped DOM). Geometry/colors
293
+ ride button token re-points; focus ring is button-ui's own. ═══════ */
292
294
  :scope > [data-copy] {
293
- appearance: none;
294
- -webkit-appearance: none;
295
- background: transparent;
296
- border: 0;
297
- padding: 2px 4px;
295
+ --button-fg: var(--swatch-copy-fg);
296
+ --button-fg-ghost-hover: var(--md-sys-color-neutral-on-surface);
297
+ --button-bg-ghost-hover: var(--a-bg-muted);
298
+ --button-radius: var(--a-radius-xs);
299
+ --button-height: calc(var(--a-ui-sm) + var(--a-space-1));
300
+
298
301
  margin-inline-start: var(--a-space-1);
299
- color: var(--swatch-copy-fg);
300
302
  font-size: var(--a-ui-sm);
301
- line-height: 1;
302
- cursor: pointer;
303
- border-radius: var(--a-radius-xs);
304
- transition: color var(--a-duration-fast) var(--a-easing-out), background var(--a-duration-fast) var(--a-easing-out);
305
- }
306
- :scope > [data-copy]:hover { color: var(--md-sys-color-neutral-on-surface); background: var(--a-bg-muted); }
307
- :scope > [data-copy]:focus-visible {
308
- outline: var(--swatch-select-ring-width) solid var(--swatch-select-ring);
309
- outline-offset: 1px;
303
+ flex-shrink: 0;
310
304
  }
305
+ /* button-ui's own :scope display wins over the UA [hidden] rule. */
311
306
  :scope > [data-copy][hidden] { display: none; }
312
- :scope > [data-copy][data-copy-state="ok"] { color: var(--swatch-copy-fg-ok); }
313
- :scope > [data-copy][data-copy-state="fail"] { color: var(--swatch-copy-fg-fail); }
307
+ :scope > [data-copy][data-copy-state="ok"] { --button-fg: var(--swatch-copy-fg-ok); }
308
+ :scope > [data-copy][data-copy-state="fail"] { --button-fg: var(--swatch-copy-fg-fail); }
314
309
 
315
310
  /* ═══════ Selectable + selected states ═══════
316
311
  [selectable] makes the whole host focusable; ring on :focus-visible.
@@ -11,6 +11,8 @@ description: |
11
11
  shape; pairs with an optional label slot or attribute. Composed by chart-
12
12
  legend-ui's per-row swatch and consumed directly by the token-colors /
13
13
  spacing demo pages.
14
+ composes:
15
+ - button-ui # [copyable] copy affordance (gh issue 276 — no native <button> in stamped DOM)
14
16
  props:
15
17
  shape:
16
18
  description: Visual shape — block (filled tile), dot (small circle), square (small filled square), line (solid hairline), dashed (dashed hairline).
@@ -436,7 +436,10 @@ export class UISwiper extends UIElement {
436
436
  if (dots.children.length !== desired) {
437
437
  while (dots.firstChild) dots.removeChild(dots.firstChild);
438
438
  for (let i = 0; i < desired; i++) {
439
- const dot = document.createElement('button');
439
+ // button-ui, not a native <button> (gh issue 276 wave 4) — it
440
+ // respects the consumer-set role, so the tab semantics survive.
441
+ const dot = document.createElement('button-ui');
442
+ dot.setAttribute('variant', 'ghost');
440
443
  dot.setAttribute('role', 'tab');
441
444
  dot.setAttribute('aria-label', `Page ${i + 1}`);
442
445
  dot.addEventListener('click', () => this.goTo(this.#pageToSlideIndex(i)));