@adia-ai/web-components 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/components/accordion/accordion.css +5 -0
  3. package/components/agent-artifact/agent-artifact.css +1 -0
  4. package/components/agent-reasoning/agent-reasoning.css +9 -5
  5. package/components/button/button.class.js +4 -1
  6. package/components/button/button.css +1 -1
  7. package/components/calendar-picker/calendar-picker.css +2 -0
  8. package/components/chat-thread/chat-input.a2ui.json +3 -1
  9. package/components/chat-thread/chat-input.css +12 -13
  10. package/components/chat-thread/chat-input.js +6 -2
  11. package/components/chat-thread/chat-input.yaml +3 -0
  12. package/components/check/check.css +1 -1
  13. package/components/combobox/combobox.a2ui.json +3 -1
  14. package/components/combobox/combobox.class.js +1 -3
  15. package/components/combobox/combobox.css +9 -12
  16. package/components/combobox/combobox.yaml +2 -0
  17. package/components/date-range-picker/date-range-picker.css +1 -0
  18. package/components/input/input.css +6 -2
  19. package/components/nav-group/nav-group.css +5 -6
  20. package/components/option-card/option-card.css +1 -0
  21. package/components/radio/radio.css +1 -1
  22. package/components/range/range.css +2 -0
  23. package/components/select/select.a2ui.json +2 -1
  24. package/components/select/select.class.js +9 -9
  25. package/components/select/select.css +29 -40
  26. package/components/select/select.test.js +6 -2
  27. package/components/select/select.yaml +1 -0
  28. package/components/swatch/swatch.a2ui.json +3 -1
  29. package/components/swatch/swatch.class.js +14 -8
  30. package/components/swatch/swatch.css +13 -18
  31. package/components/swatch/swatch.yaml +2 -0
  32. package/components/switch/switch.css +1 -1
  33. package/components/tag/tag.a2ui.json +4 -1
  34. package/components/tag/tag.class.js +3 -1
  35. package/components/tag/tag.css +13 -15
  36. package/components/tag/tag.yaml +3 -0
  37. package/components/tags-input/tags-input.css +1 -0
  38. package/components/textarea/textarea.css +2 -0
  39. package/components/time-picker/time-picker.css +2 -0
  40. package/components/timeline/timeline.a2ui.json +2 -1
  41. package/components/timeline/timeline.class.js +7 -8
  42. package/components/timeline/timeline.css +14 -26
  43. package/components/timeline/timeline.yaml +1 -0
  44. package/components/toggle-group/toggle-group.css +1 -0
  45. package/components/tree/tree.css +5 -2
  46. package/components/upload/upload.css +1 -0
  47. package/dist/theme-provider.min.js +1 -1
  48. package/dist/web-components.min.css +1 -1
  49. package/dist/web-components.min.js +7 -11
  50. package/dist/web-components.sheet.js +1 -1
  51. package/package.json +1 -1
  52. package/styles/colors/semantics/features.css +6 -1
package/CHANGELOG.md CHANGED
@@ -1,4 +1,24 @@
1
1
  # Changelog — @adia-ai/web-components
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Fixed
6
+ - **Focus rings pair with transparent borders** (operator ruling 2026-07-16): a control showing its `--a-focus-ring` box-shadow sets `border-color: transparent` in the same rule — border + gap + ring double-drew the edge (visible on the chat composer). Applied to the 21 ring-bearing controls with visible borders (input, textarea, select, combobox, check, radio, switch, range, tag[tone=outline], tags-input's missing invalid-state half, time/calendar/date-range pickers, upload dropzone, option-card, accordion[contained], agent-artifact header, button, toggle-group, chat-input); the pairing law is stated in `styles/colors/semantics/features.css` and the component-token contract.
7
+ - **Sidebar carets read the universal `--a-caret-size` (14px)**: tree carets were an 8px outlier (`--a-space-2`), accordion carets rode the 1rem icon default, select's trigger caret rode the ambient icon size (admin-shell's 18px bump), timeline's outcomes toggle likewise, and nav-group's collapsed block carried two `var()`-less declarations whose value was the literal token name (silently invalid — removed).
8
+ - **`agent-reasoning-ui` connector spine ~2px right of the dot centers**: the spine read `--timeline-marker-w` from outside timeline-ui's scope (unreachable — always the 1.25rem fallback) while the composed `size="sm"` dots center in a 1rem column; now keyed to the component's own `--agent-reasoning-marker-w`. Probe: 0px delta.
9
+ - **`theme-provider.min.js` inlined a stale foundation sheet**: the bundle embeds the GENERATED `dist/web-components.sheet.js`, and building JS before CSS baked the previous iteration in. `npm run build:bundles` now encodes the css→js order.
10
+
11
+ ### Changed
12
+ - **No native `<button>` in stamped component DOM (gh#276)**: `select-ui` (clear-all / select-all / +N overflow), `tag-ui` (dismiss), `swatch-ui` (copy), `timeline-item-ui` (outcomes toggle), `chat-input-ui` (attachment remove), and `combobox-ui` (clear x) stamp `<button-ui variant="ghost" size="xs">` with token re-points — shape, semantic intent, theme tokens, and focus-ring discipline come from the primitive. Data-hooks, aria-labels, and event wiring unchanged. Parked with reasons (in `scripts/dev/audit-stamped-native-buttons.mjs`, the new CI gate): table, agent-questions option cards, swiper dots, calendar day grids.
13
+ - **`button-ui` respects a consumer-authored `tabindex`** instead of clobbering it with `0` on connect — enables mouse-only affordances (combobox's clear keeps `tabindex="-1"`) and roving-tabindex composition.
14
+
15
+ ### Maintenance
16
+ - **`dist/` bundles rebuilt** (focus-ring/caret/button-ui payloads across `web-components.min.{css,js}`, `web-components.sheet.js`, `theme-provider.min.js`).
17
+
18
+ ## [0.8.3] — 2026-07-16
19
+
20
+ ### Maintenance
21
+ - **Lockstep version bump only.** No source changes in this package; bumped to maintain the 11-package version coherence enforced by `scripts/release/check-lockstep.mjs`. Substantive v0.8.3 work shipped in the two Claude Code plugins — the adia-tokens knowledge pack + command refresh (adia-ui-factory) and the release-tooling invariant-3 reconciliation (adia-ui-forge); see `packages/plugins/adia-ui-factory/CHANGELOG.md#083--2026-07-16`.
2
22
  ## [0.8.2] — 2026-07-16
3
23
 
4
24
  ### Fixed
@@ -113,6 +113,7 @@
113
113
  :scope[variant="contained"]:has(> :focus-visible),
114
114
  :where(accordion-ui[variant="contained"]) > :scope:not([variant="flat"]):has(> :focus-visible) {
115
115
  box-shadow: var(--accordion-item-focus-ring);
116
+ border-color: transparent;
116
117
  }
117
118
 
118
119
  /* ── Header ── */
@@ -172,6 +173,10 @@
172
173
  /* ── Caret icon ── */
173
174
  :scope [slot="caret"] {
174
175
  flex-shrink: 0;
176
+ /* Universal caret size (14px default) — one caret size across
177
+ nav-group / tree / accordion (ruling 2026-07-16); unset, the icon
178
+ rendered at the 1rem icon default and read oversized in sidebars. */
179
+ --a-icon-size: var(--a-caret-size);
175
180
  color: var(--accordion-item-caret-fg);
176
181
  transition:
177
182
  transform var(--accordion-item-duration) var(--accordion-item-easing),
@@ -57,6 +57,7 @@
57
57
  [data-artifact-header]:focus-visible {
58
58
  outline: none;
59
59
  box-shadow: var(--a-focus-ring) inset;
60
+ border-color: transparent;
60
61
  }
61
62
 
62
63
  [data-artifact-icon] {
@@ -132,14 +132,18 @@
132
132
  the last, bridging plan / thought / iteration interstitials so the run
133
133
  reads as one timeline rather than fragmented per-block stubs. Endpoints are
134
134
  JS-measured into --reasoning-spine-top / -h (#layoutSpine) — interstitial
135
- heights are dynamic, so pure CSS can't place them. The x matches the step
136
- dot center (timeline-item uses var(--timeline-marker-w, 1.25rem) for its
137
- dot math). Sits behind the dots (z-index 0 < the dots' z-index 1). The
138
- per-item timeline connectors are suppressed below so they don't double-draw. */
135
+ heights are dynamic, so pure CSS can't place them. The x is half the
136
+ component's OWN marker column (--agent-reasoning-marker-w, 1rem matched
137
+ by the composed <timeline-ui size="sm">, whose dots center in that same
138
+ column). NOT --timeline-marker-w: that token lives inside timeline-ui's
139
+ scope, a DESCENDANT — unreachable here, so reading it silently took the
140
+ 1.25rem fallback and drew the spine ~2px right of the dot centers.
141
+ Sits behind the dots (z-index 0 < the dots' z-index 1). The per-item
142
+ timeline connectors are suppressed below so they don't double-draw. */
139
143
  [data-reasoning-body][data-spine]::before {
140
144
  content: '';
141
145
  position: absolute;
142
- left: calc(var(--timeline-marker-w, 1.25rem) / 2 - var(--agent-reasoning-line-w, 1.5px) / 2);
146
+ left: calc(var(--agent-reasoning-marker-w, 1rem) / 2 - var(--agent-reasoning-line-w, 1.5px) / 2);
143
147
  top: var(--reasoning-spine-top, 0);
144
148
  height: var(--reasoning-spine-h, 0);
145
149
  width: var(--agent-reasoning-line-w, 1.5px);
@@ -51,7 +51,10 @@ export class UIButton extends UIElement {
51
51
 
52
52
  connected() {
53
53
  this.setAttribute('role', 'button');
54
- this.setAttribute('tabindex', '0');
54
+ // Respect a consumer-authored tabindex (e.g. a mouse-only tabindex="-1"
55
+ // affordance like combobox's clear x, or a roving-tabindex composite) —
56
+ // only default the focusable state when the author said nothing.
57
+ if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
55
58
  this.addEventListener('click', this.#onClick);
56
59
  this.addEventListener('keydown', this.#onKey);
57
60
  }
@@ -198,7 +198,7 @@ button-ui[variant="ghost"][color="warning"]:not([disabled]):hover { --button-f
198
198
  }
199
199
 
200
200
  /* :scope:active moved outside @scope — see Safari 17.x bug note at top. */
201
- :scope:focus-visible { outline: none; box-shadow: var(--button-focus-ring); }
201
+ :scope:focus-visible { outline: none; box-shadow: var(--button-focus-ring); border-color: transparent; }
202
202
 
203
203
  /* ── Variants (rest state) ──
204
204
  Variant/state blocks re-point the component tokens (--button-bg etc.);
@@ -156,10 +156,12 @@
156
156
  :scope:focus-visible [slot="trigger"] {
157
157
  /* Canonical ring via L3 token (see semantics.css FOCUS block). */
158
158
  box-shadow: var(--calendar-picker-trigger-focus-ring);
159
+ border-color: transparent;
159
160
  }
160
161
  :scope[aria-invalid="true"]:focus-visible [slot="trigger"],
161
162
  :scope[error]:focus-visible [slot="trigger"] {
162
163
  box-shadow: var(--calendar-picker-trigger-focus-ring-invalid);
164
+ border-color: transparent;
163
165
  }
164
166
 
165
167
  [slot="display"] {
@@ -59,7 +59,9 @@
59
59
  }
60
60
  ],
61
61
  "category": "agent",
62
- "composes": [],
62
+ "composes": [
63
+ "button-ui"
64
+ ],
63
65
  "events": {
64
66
  "submit": {
65
67
  "description": "Fires when the user presses Enter (without Shift) in the textarea OR clicks the built-in send button. Unconditional — there is no [submit-on-enter] opt-in or opt-out attribute; Enter→submit is delegated from the inner <textarea-ui> which always emits a `submit` event on Enter without Shift. The composer suppresses submission while `[loading]` is set.",
@@ -122,10 +122,12 @@ chat-input-ui textarea-ui:not([disabled]) [slot="text"]:hover {
122
122
  ─────────────────────────────────────────────────────────────── */
123
123
  :scope:focus-within {
124
124
  box-shadow: var(--chat-input-focus-ring);
125
+ border-color: transparent;
125
126
  }
126
127
  :scope[aria-invalid="true"]:focus-within,
127
128
  :scope[error]:focus-within {
128
129
  box-shadow: var(--chat-input-focus-ring-invalid);
130
+ border-color: transparent;
129
131
  }
130
132
 
131
133
  /* ── Nested-control color isolation ─────────────────────────────────
@@ -239,23 +241,20 @@ chat-input-ui textarea-ui:not([disabled]) [slot="text"]:hover {
239
241
  border: 1px solid var(--chat-input-image-border);
240
242
  }
241
243
 
244
+ /* Attachment-remove — a stamped <button-ui variant="ghost" size="xs"
245
+ icon="x"> (gh issue 276: no native <button> in stamped DOM); the
246
+ floating filled-circle look rides button token re-points. */
242
247
  [slot="preview"] [data-preview-remove] {
243
248
  position: absolute;
244
249
  top: calc(-1 * var(--chat-input-remove-offset));
245
250
  right: calc(-1 * var(--chat-input-remove-offset));
246
- width: var(--chat-input-remove-size);
247
- height: var(--chat-input-remove-size);
248
- border-radius: 50%;
249
- border: none;
250
- background: var(--chat-input-remove-bg);
251
- color: var(--chat-input-remove-fg);
252
- font-size: var(--chat-input-remove-font);
253
- line-height: 1;
254
- cursor: pointer;
255
- display: flex;
256
- align-items: center;
257
- justify-content: center;
258
- padding: 0;
251
+ --button-height: var(--chat-input-remove-size);
252
+ --button-radius: var(--a-radius-full);
253
+ --button-bg: var(--chat-input-remove-bg);
254
+ --button-fg: var(--chat-input-remove-fg);
255
+ --button-bg-ghost-hover: var(--chat-input-remove-bg);
256
+ --button-fg-ghost-hover: var(--chat-input-remove-fg);
257
+ --a-icon-size: var(--chat-input-remove-font);
259
258
  }
260
259
 
261
260
  /* Disabled / streaming — host-level chrome. `color` cascades to the
@@ -228,8 +228,12 @@ class UIChatInput extends UIElement {
228
228
  const img = document.createElement('img');
229
229
  img.src = a.dataUrl;
230
230
  img.alt = a.name || 'attachment';
231
- const btn = document.createElement('button');
232
- btn.textContent = '×';
231
+ // button-ui, not a native <button> (gh issue 276).
232
+ const btn = document.createElement('button-ui');
233
+ btn.setAttribute('variant', 'ghost');
234
+ btn.setAttribute('size', 'xs');
235
+ btn.setAttribute('icon', 'x');
236
+ btn.setAttribute('aria-label', `Remove ${a.name || 'attachment'}`);
233
237
  btn.setAttribute('data-preview-remove', '');
234
238
  btn.addEventListener('click', () => this.#removeAttachment(i));
235
239
  wrap.appendChild(img);
@@ -61,6 +61,9 @@ description: |
61
61
  autocomplete, trailing/leading controls), wrap inside
62
62
  `<chat-composer>` — see ChatComposer for the module-tier shape.
63
63
 
64
+ composes:
65
+ - button-ui # send button + attachment-remove (gh issue 276 — no native <button> in stamped DOM)
66
+
64
67
  props:
65
68
  disabled:
66
69
  description: |
@@ -123,7 +123,7 @@ check-ui[indeterminate] {
123
123
  :scope[label]::after { content: attr(label); }
124
124
 
125
125
  /* Focus */
126
- :scope:focus-visible [slot="box"] { box-shadow: var(--check-focus-ring); }
126
+ :scope:focus-visible [slot="box"] { box-shadow: var(--check-focus-ring); border-color: transparent; }
127
127
 
128
128
  /* Size handled by universal [size] attribute system. */
129
129
  }
@@ -150,7 +150,9 @@
150
150
  ],
151
151
  "category": "input",
152
152
  "composes": [
153
- "icon-ui"
153
+ "icon-ui",
154
+ "button-ui",
155
+ "spinner-ui"
154
156
  ],
155
157
  "events": {
156
158
  "change": {
@@ -241,9 +241,7 @@ export class UICombobox extends UIFormElement {
241
241
  aria-controls="${listboxId}"
242
242
  aria-labelledby="${labelId}"
243
243
  data-placeholder="${escapeHTML(this.placeholder || '')}"></span>
244
- <button type="button" data-clear aria-label="Clear" tabindex="-1" style="display:none">
245
- <icon-ui name="x-circle"></icon-ui>
246
- </button>
244
+ <button-ui variant="ghost" size="xs" data-clear aria-label="Clear" tabindex="-1" style="display:none"><icon-ui slot="icon" name="x-circle"></icon-ui></button-ui>
247
245
  <span data-suffix>
248
246
  <icon-ui name="caret-down"></icon-ui>
249
247
  </span>
@@ -102,10 +102,12 @@
102
102
 
103
103
  :scope:focus-within [data-field] {
104
104
  box-shadow: var(--combobox-focus-ring);
105
+ border-color: transparent;
105
106
  }
106
107
  :scope[aria-invalid="true"]:focus-within [data-field],
107
108
  :scope[error]:focus-within [data-field] {
108
109
  box-shadow: var(--combobox-focus-ring-invalid);
110
+ border-color: transparent;
109
111
  }
110
112
 
111
113
  [data-prefix],
@@ -159,20 +161,15 @@
159
161
  align-items: center;
160
162
  }
161
163
 
164
+ /* Clear x — a stamped <button-ui variant="ghost" size="xs"> kept
165
+ mouse-only via tabindex="-1" (gh issue 276: no native <button> in
166
+ stamped DOM); colors/geometry ride button token re-points. */
162
167
  [data-clear] {
163
- display: inline-flex;
164
- align-items: center;
165
- justify-content: center;
166
- padding: 0;
167
- margin: 0;
168
- border: 0;
169
- background: transparent;
170
- color: var(--combobox-fg-muted);
171
- cursor: pointer;
168
+ --button-fg: var(--combobox-fg-muted);
169
+ --button-fg-ghost-hover: var(--combobox-fg);
170
+ --button-height: calc(var(--combobox-font-size) + var(--a-space-1));
172
171
  --a-icon-size: var(--combobox-font-size);
173
- }
174
- [data-clear]:hover {
175
- color: var(--combobox-fg);
172
+ flex-shrink: 0;
176
173
  }
177
174
  }
178
175
 
@@ -20,6 +20,8 @@ description: |
20
20
  # do NOT auto-import them. Consumer (or demo shell) must explicitly import.
21
21
  composes:
22
22
  - icon-ui # caret-down / x / magnifying-glass affixes (created in render)
23
+ - button-ui # clear-x affordance, mouse-only via tabindex=-1 (gh issue 276 — no native <button> in stamped DOM)
24
+ - spinner-ui # loading row in the stamped listbox
23
25
  props:
24
26
  name:
25
27
  description: Form control name for form data submission
@@ -130,6 +130,7 @@
130
130
  :scope:focus-visible { outline: none; }
131
131
  :scope:focus-visible [slot="trigger"] {
132
132
  box-shadow: var(--date-range-picker-trigger-focus-ring);
133
+ border-color: transparent;
133
134
  }
134
135
 
135
136
  /* States */
@@ -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) */
@@ -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
  }
@@ -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
  }
@@ -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": {
@@ -442,9 +442,7 @@ export class UISelect extends UIFormElement {
442
442
  ${leading}
443
443
  <span data-chips></span>
444
444
  ${displayMarkup}
445
- <button type="button" data-clear-all aria-label="Clear all" hidden>
446
- <icon-ui name="x"></icon-ui>
447
- </button>
445
+ <button-ui variant="ghost" size="xs" icon="x" aria-label="Clear all" data-clear-all hidden></button-ui>
448
446
  <icon-ui name="caret-down" slot="caret"></icon-ui>
449
447
  </span>
450
448
  ${hintMarkup}
@@ -725,10 +723,11 @@ export class UISelect extends UIFormElement {
725
723
  if (this.multiple && this.selectAll) {
726
724
  const header = document.createElement('div');
727
725
  header.setAttribute('data-select-all', '');
728
- const btn = document.createElement('button');
729
- btn.type = 'button';
726
+ const btn = document.createElement('button-ui');
727
+ btn.setAttribute('variant', 'ghost');
728
+ btn.setAttribute('size', 'xs');
730
729
  btn.dataset.selectAllBtn = '';
731
- btn.textContent = this.#allSelected() ? 'Clear' : 'Select all';
730
+ btn.setAttribute('text', this.#allSelected() ? 'Clear' : 'Select all');
732
731
  btn.addEventListener('click', this.#onSelectAllClick);
733
732
  header.appendChild(btn);
734
733
  this.#listbox.appendChild(header);
@@ -854,10 +853,11 @@ export class UISelect extends UIFormElement {
854
853
  slot.appendChild(tag);
855
854
  }
856
855
  if (overflow > 0) {
857
- const more = document.createElement('button');
858
- more.type = 'button';
856
+ const more = document.createElement('button-ui');
857
+ more.setAttribute('variant', 'ghost');
858
+ more.setAttribute('size', 'xs');
859
859
  more.dataset.more = '';
860
- more.textContent = `+${overflow} more`;
860
+ more.setAttribute('text', `+${overflow} more`);
861
861
  more.addEventListener('click', this.#onMoreClick);
862
862
  slot.appendChild(more);
863
863
  }
@@ -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";
@@ -380,12 +380,15 @@ export class UISwatch extends UIElement {
380
380
  this.#detailEl.setAttribute('hidden', '');
381
381
  this.appendChild(this.#detailEl);
382
382
 
383
- this.#copyEl = document.createElement('button');
383
+ // button-ui, not a native <button> (gh issue 276) — the ⧉/✓/⚠ glyph
384
+ // rides the text attribute (button-ui renders it via attr(text)).
385
+ this.#copyEl = document.createElement('button-ui');
384
386
  this.#copyEl.setAttribute('data-copy', '');
385
- this.#copyEl.setAttribute('type', 'button');
387
+ this.#copyEl.setAttribute('variant', 'ghost');
388
+ this.#copyEl.setAttribute('size', 'xs');
386
389
  this.#copyEl.setAttribute('aria-label', 'Copy value');
387
390
  this.#copyEl.setAttribute('hidden', '');
388
- this.#copyEl.textContent = '⧉';
391
+ this.#copyEl.setAttribute('text', '⧉');
389
392
  this.appendChild(this.#copyEl);
390
393
 
391
394
  this.#stamped = true;
@@ -610,8 +613,11 @@ export class UISwatch extends UIElement {
610
613
  this.#onHostKey = (e) => {
611
614
  if (!this.selectable) return;
612
615
  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;
616
+ // Don't steal Space when focus is in the copy button (the focused
617
+ // element is button-ui's INNER control, so identity-compare via the
618
+ // event path, not e.target).
619
+ const keyPath = e.composedPath ? e.composedPath() : [e.target];
620
+ if (keyPath.includes(this.#copyEl)) return;
615
621
  e.preventDefault();
616
622
  this.#activate();
617
623
  };
@@ -660,11 +666,11 @@ export class UISwatch extends UIElement {
660
666
  if (!this.#copyEl) return;
661
667
  if (this.#copyResetTimer != null) clearTimeout(this.#copyResetTimer);
662
668
  this.#copyEl.setAttribute('data-copy-state', ok ? 'ok' : 'fail');
663
- this.#copyEl.textContent = ok ? '✓' : '⚠';
669
+ this.#copyEl.setAttribute('text', ok ? '✓' : '⚠');
664
670
  this.#copyResetTimer = setTimeout(() => {
665
671
  if (!this.#copyEl) return;
666
672
  this.#copyEl.removeAttribute('data-copy-state');
667
- this.#copyEl.textContent = '⧉';
673
+ this.#copyEl.setAttribute('text', '⧉');
668
674
  this.#copyResetTimer = null;
669
675
  }, 1200);
670
676
  }