@adia-ai/web-components 0.0.26 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/components/agent-artifact/agent-artifact.a2ui.json +1 -1
  2. package/components/agent-artifact/agent-artifact.css +11 -0
  3. package/components/agent-artifact/agent-artifact.js +23 -2
  4. package/components/agent-artifact/agent-artifact.yaml +1 -1
  5. package/components/agent-questions/agent-questions.css +20 -1
  6. package/components/agent-reasoning/agent-reasoning.css +11 -0
  7. package/components/agent-reasoning/agent-reasoning.js +16 -0
  8. package/components/agent-trace/agent-trace.css +36 -12
  9. package/components/alert/alert.a2ui.json +10 -4
  10. package/components/alert/alert.css +13 -0
  11. package/components/alert/alert.js +1 -1
  12. package/components/alert/alert.yaml +21 -4
  13. package/components/badge/badge.a2ui.json +0 -2
  14. package/components/badge/badge.css +20 -0
  15. package/components/badge/badge.js +10 -2
  16. package/components/badge/badge.yaml +0 -2
  17. package/components/breadcrumb/breadcrumb.a2ui.json +16 -1
  18. package/components/breadcrumb/breadcrumb.css +27 -0
  19. package/components/breadcrumb/breadcrumb.js +95 -17
  20. package/components/breadcrumb/breadcrumb.yaml +15 -1
  21. package/components/calendar-picker/calendar-picker.css +17 -0
  22. package/components/chart/chart.css +20 -13
  23. package/components/chart/chart.js +49 -17
  24. package/components/chart-legend/chart-legend.css +30 -54
  25. package/components/chart-legend/chart-legend.js +48 -30
  26. package/components/code/code.css +41 -0
  27. package/components/code/code.js +44 -3
  28. package/components/command/command.js +52 -1
  29. package/components/empty-state/empty-state.js +32 -21
  30. package/components/feed/feed-item.yaml +50 -0
  31. package/components/feed/feed.a2ui.json +59 -0
  32. package/components/feed/feed.css +141 -0
  33. package/components/feed/feed.js +276 -0
  34. package/components/feed/feed.yaml +33 -0
  35. package/components/index.js +2 -0
  36. package/components/list/list.js +20 -16
  37. package/components/menu/menu.css +18 -0
  38. package/components/menu/menu.js +24 -10
  39. package/components/pane/pane.css +5 -0
  40. package/components/pipeline-status/pipeline-status.css +15 -1
  41. package/components/popover/popover.css +17 -0
  42. package/components/select/select.css +18 -0
  43. package/components/swatch/swatch.a2ui.json +116 -0
  44. package/components/swatch/swatch.css +141 -0
  45. package/components/swatch/swatch.js +121 -0
  46. package/components/swatch/swatch.yaml +101 -0
  47. package/components/swiper/swiper.css +9 -0
  48. package/components/table/table.css +5 -0
  49. package/components/table/table.js +45 -1
  50. package/components/table-toolbar/table-toolbar.css +13 -0
  51. package/components/tag/tag.css +10 -0
  52. package/components/timeline/timeline.css +15 -4
  53. package/components/toast/toast.css +93 -48
  54. package/components/toast/toast.js +101 -22
  55. package/components/toolbar/toolbar.css +13 -0
  56. package/components/tooltip/tooltip.css +11 -3
  57. package/core/provider.js +1 -0
  58. package/package.json +1 -1
  59. package/styles/colors/semantics.css +1 -1
  60. package/styles/components.css +1 -0
@@ -0,0 +1,276 @@
1
+ /**
2
+ * <feed-ui> + <feed-item-ui> — Shared top-layer feed channel.
3
+ *
4
+ * Per docs/specs/feed-channel.md (SPEC-FEED-CHANNEL-001).
5
+ *
6
+ * Phase 1 (skeleton) ships here:
7
+ * - Per-position singletons (`<feed-ui position="bottom-right">`)
8
+ * resolving OD-FEED-1; mounted lazily into document.body via
9
+ * Popover API for top-layer placement.
10
+ * - Auto-fade `<feed-item-ui>` policy. Sticky-dismissible and
11
+ * action-required policies are stubs that respect the contract
12
+ * (no auto-dismiss when `duration` is null/0; close button
13
+ * surfaces when `dismissible` is set) but the action-required
14
+ * focus-trap is deferred.
15
+ * - Static API: AdiaFeed.post() / .get() / .clear() / .purge()
16
+ * — `purge()` directly addresses the audit's L-B4 container-leak
17
+ * finding by giving consumers a tear-down hook.
18
+ * - Global event channel: window.dispatchEvent(new CustomEvent(
19
+ * 'feed', { detail: { text, position, ... } })) — same shape as
20
+ * AdiaFeed.post() so any code can post without importing the
21
+ * module directly. Idempotent listener install (HMR-safe).
22
+ *
23
+ * Toast migration (spec § 2.5) is deferred — toast-ui keeps its own
24
+ * container + API for now; the migration happens once the feed-ui
25
+ * surface has soaked.
26
+ */
27
+
28
+ import { AdiaElement, html } from '../../core/element.js';
29
+
30
+ /* ── Container — one per position, mounted into document.body ── */
31
+
32
+ class AdiaFeedContainer extends AdiaElement {
33
+ static properties = {
34
+ position: { type: String, default: 'bottom-right', reflect: true },
35
+ max: { type: Number, default: 5, reflect: true },
36
+ };
37
+ static template = () => html``;
38
+
39
+ connected() {
40
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'region');
41
+ if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', 'Feed');
42
+ }
43
+ }
44
+
45
+ customElements.define('feed-ui', AdiaFeedContainer);
46
+
47
+ /* ── Item — one per posted message ── */
48
+
49
+ class AdiaFeedItem extends AdiaElement {
50
+ #timer = null;
51
+ #removing = false;
52
+ #closeTimer = null;
53
+ #openRaf = null;
54
+
55
+ static properties = {
56
+ text: { type: String, default: '', reflect: true },
57
+ heading: { type: String, default: '', reflect: true },
58
+ icon: { type: String, default: '', reflect: true },
59
+ variant: { type: String, default: 'default', reflect: true },
60
+ duration: { type: Number, default: 4000, reflect: true },
61
+ dismissible: { type: Boolean, default: false, reflect: true },
62
+ };
63
+
64
+ static parts = {
65
+ body: '<div slot="body"></div>',
66
+ };
67
+ static template = () => html``;
68
+
69
+ #onPress = (e) => {
70
+ if (e.target.closest('[data-feed-close]')) this.dismiss();
71
+ };
72
+
73
+ connected() {
74
+ this.addEventListener('press', this.#onPress);
75
+ }
76
+
77
+ #getDuration() {
78
+ const raw = getComputedStyle(this).getPropertyValue('--feed-item-duration').trim();
79
+ return parseFloat(raw) || 200;
80
+ }
81
+
82
+ render() {
83
+ const body = this.ensure('body');
84
+ body.textContent = '';
85
+ if (this.heading) {
86
+ const h = document.createElement('strong');
87
+ h.textContent = this.heading;
88
+ h.style.display = 'block';
89
+ body.appendChild(h);
90
+ }
91
+ if (this.text) {
92
+ const t = document.createElement('span');
93
+ t.textContent = this.text;
94
+ body.appendChild(t);
95
+ }
96
+
97
+ /* Inferred policy roles per spec §2.4. Auto-fade gets `status`;
98
+ sticky danger/warning gets `alert`; action-required gets
99
+ `alertdialog` (a future phase wires the focus trap). */
100
+ const isSticky = !this.duration || this.duration <= 0;
101
+ const isLoud = this.variant === 'danger' || this.variant === 'warning';
102
+ let role = 'status';
103
+ if (isSticky && isLoud) role = 'alert';
104
+ this.setAttribute('role', role);
105
+ this.setAttribute('aria-live', role === 'alert' ? 'assertive' : 'polite');
106
+
107
+ /* Render dismiss button for sticky items (spec §2.2 — default true
108
+ for sticky, false for auto-fade). */
109
+ const wantsClose = this.dismissible || isSticky;
110
+ let close = this.querySelector(':scope > [data-feed-close]');
111
+ if (wantsClose && !close) {
112
+ close = document.createElement('button-ui');
113
+ close.setAttribute('data-feed-close', '');
114
+ close.setAttribute('icon', 'x');
115
+ close.setAttribute('variant', 'ghost');
116
+ close.setAttribute('size', 'sm');
117
+ close.setAttribute('aria-label', 'Dismiss');
118
+ this.appendChild(close);
119
+ } else if (!wantsClose && close) {
120
+ close.remove();
121
+ }
122
+
123
+ if (!this.hasAttribute('data-open') && !this.#removing) {
124
+ this.#openRaf = requestAnimationFrame(() => {
125
+ this.#openRaf = null;
126
+ this.setAttribute('data-open', '');
127
+ });
128
+ }
129
+ this.#scheduleAutoDismiss();
130
+ }
131
+
132
+ #scheduleAutoDismiss() {
133
+ if (this.#timer) clearTimeout(this.#timer);
134
+ if (this.duration && this.duration > 0) {
135
+ this.#timer = setTimeout(() => this.dismiss(), this.duration);
136
+ }
137
+ }
138
+
139
+ dismiss() {
140
+ if (this.#removing) return;
141
+ this.#removing = true;
142
+ if (this.#timer) { clearTimeout(this.#timer); this.#timer = null; }
143
+ this.removeAttribute('data-open');
144
+ this.setAttribute('data-closing', '');
145
+ this.#closeTimer = setTimeout(() => {
146
+ this.#closeTimer = null;
147
+ const container = this.parentElement;
148
+ this.dispatchEvent(new Event('close', { bubbles: true }));
149
+ this.remove();
150
+ if (container?.matches?.('feed-ui')) AdiaFeed.releaseContainerIfEmpty(container);
151
+ }, this.#getDuration());
152
+ }
153
+
154
+ update(patch) {
155
+ if (!patch || typeof patch !== 'object') return;
156
+ for (const k of Object.keys(patch)) {
157
+ if (k in AdiaFeedItem.properties) this[k] = patch[k];
158
+ }
159
+ this.#scheduleAutoDismiss();
160
+ }
161
+
162
+ disconnected() {
163
+ this.removeEventListener('press', this.#onPress);
164
+ if (this.#timer) { clearTimeout(this.#timer); this.#timer = null; }
165
+ if (this.#closeTimer) { clearTimeout(this.#closeTimer); this.#closeTimer = null; }
166
+ if (this.#openRaf != null) { cancelAnimationFrame(this.#openRaf); this.#openRaf = null; }
167
+ }
168
+ }
169
+
170
+ customElements.define('feed-item-ui', AdiaFeedItem);
171
+
172
+ /* ── Static API — AdiaFeed ── */
173
+
174
+ class AdiaFeed {
175
+ static #containers = new Map();
176
+
177
+ /**
178
+ * Get (or lazily create) the per-position lane. Each lane is a
179
+ * manual Popover-API container — `[popover="manual"]` puts it in
180
+ * the browser's top-layer with no z-index wars and the native
181
+ * popover stack lets multiple lanes coexist (e.g. one feed in
182
+ * top-right + another in bottom-center) without collision.
183
+ */
184
+ static get(position = 'bottom-right') {
185
+ let el = AdiaFeed.#containers.get(position);
186
+ if (el && el.isConnected) return el;
187
+ el = document.createElement('feed-ui');
188
+ el.setAttribute('position', position);
189
+ if ('popover' in HTMLElement.prototype) el.setAttribute('popover', 'manual');
190
+ document.body.appendChild(el);
191
+ try { el.showPopover?.(); } catch { /* graceful fallback */ }
192
+ AdiaFeed.#containers.set(position, el);
193
+ return el;
194
+ }
195
+
196
+ /**
197
+ * Post a feed item. Returns a `FeedHandle` (`{id, dismiss, update}`).
198
+ *
199
+ * @param {Object} opts
200
+ * @param {string} [opts.text]
201
+ * @param {string} [opts.heading]
202
+ * @param {string} [opts.icon]
203
+ * @param {string} [opts.variant='default'] default | info | success | warning | danger
204
+ * @param {number|null} [opts.duration=4000] ms; null/0 = sticky (requires close click)
205
+ * @param {string} [opts.position='bottom-right']
206
+ * @param {boolean} [opts.dismissible] override default (true for sticky, false for auto)
207
+ * @param {string} [opts.id]
208
+ */
209
+ static post(opts = {}) {
210
+ const {
211
+ text = '',
212
+ heading = '',
213
+ icon = '',
214
+ variant = 'default',
215
+ duration = 4000,
216
+ position = 'bottom-right',
217
+ dismissible,
218
+ id,
219
+ } = opts;
220
+ const v = variant === 'error' ? 'danger' : variant; // documented alias
221
+ const container = AdiaFeed.get(position);
222
+ const item = document.createElement('feed-item-ui');
223
+ if (id) item.setAttribute('data-id', id);
224
+ item.text = text;
225
+ item.heading = heading;
226
+ item.icon = icon;
227
+ item.variant = v;
228
+ item.duration = duration;
229
+ if (dismissible != null) item.dismissible = !!dismissible;
230
+ container.appendChild(item);
231
+ return {
232
+ id: id ?? null,
233
+ dismiss: () => item.dismiss(),
234
+ update: (patch) => item.update(patch),
235
+ };
236
+ }
237
+
238
+ /** Clear all items in a single lane. */
239
+ static clear(position = 'bottom-right') {
240
+ const el = AdiaFeed.#containers.get(position);
241
+ if (!el) return;
242
+ for (const item of [...el.children]) {
243
+ if (item.tagName === 'FEED-ITEM-UI') item.dismiss?.();
244
+ }
245
+ }
246
+
247
+ /** Tear down ALL containers. Test cleanup; addresses audit L-B4. */
248
+ static purge() {
249
+ for (const el of AdiaFeed.#containers.values()) {
250
+ try { el.hidePopover?.(); } catch { /* noop */ }
251
+ el.remove();
252
+ }
253
+ AdiaFeed.#containers.clear();
254
+ }
255
+
256
+ /** Internal: drop a lane when its last item exits. */
257
+ static releaseContainerIfEmpty(container) {
258
+ if (!container || container.children.length > 0) return;
259
+ try { container.hidePopover?.(); } catch { /* noop */ }
260
+ container.remove();
261
+ for (const [pos, el] of AdiaFeed.#containers) {
262
+ if (el === container) AdiaFeed.#containers.delete(pos);
263
+ }
264
+ }
265
+ }
266
+
267
+ /* Global 'feed' CustomEvent listener — same shape as AdiaFeed.post().
268
+ Idempotent (HMR-safe via window flag). */
269
+ if (typeof window !== 'undefined' && !window.__adiaFeedListenerInstalled) {
270
+ window.__adiaFeedListenerInstalled = true;
271
+ window.addEventListener('feed', (e) => {
272
+ if (e?.detail && typeof e.detail === 'object') AdiaFeed.post(e.detail);
273
+ });
274
+ }
275
+
276
+ export { AdiaFeedContainer, AdiaFeedItem, AdiaFeed };
@@ -0,0 +1,33 @@
1
+ # Generated alongside feed.js — kept in sync by hand for now (feed
2
+ # is a Phase-1 skeleton; once it stabilizes, run the regen pipeline).
3
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
4
+ name: AdiaFeedContainer
5
+ tag: feed-ui
6
+ component: Feed
7
+ category: container
8
+ version: 1
9
+ description: >-
10
+ Shared top-layer feed channel. Per docs/specs/feed-channel.md
11
+ (SPEC-FEED-CHANNEL-001). Per-position singletons mounted lazily into
12
+ document.body via Popover API; consumers post via the static API
13
+ (`AdiaFeed.post()`) or the global 'feed' CustomEvent.
14
+ props:
15
+ position:
16
+ description: Lane the feed renders into
17
+ type: string
18
+ default: bottom-right
19
+ enum:
20
+ - top-left
21
+ - top-center
22
+ - top-right
23
+ - bottom-left
24
+ - bottom-center
25
+ - bottom-right
26
+ - inline
27
+ max:
28
+ description: Cap on simultaneously visible items per lane
29
+ type: number
30
+ default: 5
31
+ events: {}
32
+ slots: {}
33
+ states: {}
@@ -30,6 +30,7 @@ export { AdiaChat } from './chat/chat.js';
30
30
  export { AdiaDrawer } from './drawer/drawer.js';
31
31
  export { AdiaModal } from './modal/modal.js';
32
32
  export { AdiaToast } from './toast/toast.js';
33
+ export { AdiaFeedContainer, AdiaFeedItem, AdiaFeed } from './feed/feed.js';
33
34
  export { AdiaTabs } from './tabs/tabs.js';
34
35
  export { AdiaTab } from './tabs/tab.js';
35
36
  export { AdiaTooltip } from './tooltip/tooltip.js';
@@ -51,6 +52,7 @@ export { AdiaSkeleton } from './skeleton/skeleton.js';
51
52
  export { AdiaAlert } from './alert/alert.js';
52
53
  export { AdiaKbd } from './kbd/kbd.js';
53
54
  export { AdiaTag } from './tag/tag.js';
55
+ export { AdiaSwatch } from './swatch/swatch.js';
54
56
  export { AdiaCol } from './col/col.js';
55
57
  export { AdiaField } from './field/field.js';
56
58
  export { AdiaRow } from './row/row.js';
@@ -161,15 +161,19 @@ class AdiaListItem extends AdiaElement {
161
161
  return null;
162
162
  }
163
163
 
164
+ // Mark slot elements we create so render() never deletes consumer-provided ones.
165
+ #stampMark(el) { el.dataset.listStamped = '1'; return el; }
166
+ #wasStamped(el) { return el?.dataset?.listStamped === '1'; }
167
+
164
168
  #stamp() {
165
169
  if (this.#ownChild('[slot="content"]')) return;
166
170
 
167
171
  if (this.icon) {
168
172
  let iconEl = this.#ownChild('[slot="icon"]') || this.#ownChild('icon-ui');
169
173
  if (!iconEl) {
170
- iconEl = document.createElement('icon-ui');
174
+ iconEl = this.#stampMark(document.createElement('icon-ui'));
171
175
  iconEl.setAttribute('slot', 'icon');
172
- this.appendChild(iconEl);
176
+ this.prepend(iconEl);
173
177
  }
174
178
  iconEl.setAttribute('name', this.icon);
175
179
  }
@@ -177,56 +181,56 @@ class AdiaListItem extends AdiaElement {
177
181
  if (this.text) {
178
182
  let span = this.#ownChild('[slot="text"]');
179
183
  if (!span) {
180
- span = document.createElement('span');
184
+ span = this.#stampMark(document.createElement('span'));
181
185
  span.setAttribute('slot', 'text');
182
186
  this.appendChild(span);
183
187
  }
184
- span.textContent = this.text;
188
+ if (this.#wasStamped(span)) span.textContent = this.text;
185
189
  }
186
190
 
187
191
  if (this.description) {
188
192
  let desc = this.#ownChild('[slot="description"]');
189
193
  if (!desc) {
190
- desc = document.createElement('span');
194
+ desc = this.#stampMark(document.createElement('span'));
191
195
  desc.setAttribute('slot', 'description');
192
196
  this.appendChild(desc);
193
197
  }
194
- desc.textContent = this.description;
198
+ if (this.#wasStamped(desc)) desc.textContent = this.description;
195
199
  }
196
200
  }
197
201
 
198
202
  render() {
199
- // Sync icon
203
+ // Sync icon — only touch elements we stamped.
200
204
  const iconEl = this.#ownChild('[slot="icon"]');
201
205
  if (this.icon) {
202
206
  if (iconEl) {
203
- iconEl.setAttribute('name', this.icon);
207
+ if (this.#wasStamped(iconEl)) iconEl.setAttribute('name', this.icon);
204
208
  } else {
205
- const el = document.createElement('icon-ui');
209
+ const el = this.#stampMark(document.createElement('icon-ui'));
206
210
  el.setAttribute('slot', 'icon');
207
211
  el.setAttribute('name', this.icon);
208
212
  this.prepend(el);
209
213
  }
210
- } else if (iconEl) {
214
+ } else if (this.#wasStamped(iconEl)) {
211
215
  iconEl.remove();
212
216
  }
213
217
 
214
- // Sync text
218
+ // Sync text — only touch elements we stamped.
215
219
  const textEl = this.#ownChild('[slot="text"]');
216
- if (textEl) textEl.textContent = this.text;
220
+ if (this.#wasStamped(textEl)) textEl.textContent = this.text;
217
221
 
218
- // Sync description
222
+ // Sync description — only touch elements we stamped.
219
223
  const descEl = this.#ownChild('[slot="description"]');
220
224
  if (this.description) {
221
225
  if (descEl) {
222
- descEl.textContent = this.description;
226
+ if (this.#wasStamped(descEl)) descEl.textContent = this.description;
223
227
  } else {
224
- const el = document.createElement('span');
228
+ const el = this.#stampMark(document.createElement('span'));
225
229
  el.setAttribute('slot', 'description');
226
230
  el.textContent = this.description;
227
231
  this.appendChild(el);
228
232
  }
229
- } else if (descEl) {
233
+ } else if (this.#wasStamped(descEl)) {
230
234
  descEl.remove();
231
235
  }
232
236
  }
@@ -38,6 +38,24 @@ menu-ui [data-menu-popover] {
38
38
  font-family: inherit;
39
39
  font-size: var(--a-ui-size);
40
40
  color: var(--a-fg);
41
+ /* Fade + lift in on first paint via @starting-style. Plain `transition`
42
+ applies during exit too (display: none can't transition, but opacity
43
+ can — and the close path is JS-controlled so no exit anim is needed). */
44
+ opacity: 1;
45
+ translate: 0 0;
46
+ transition: opacity var(--a-duration-fast) var(--a-easing-out),
47
+ translate var(--a-duration-fast) var(--a-easing-out);
48
+ }
49
+
50
+ menu-ui [data-menu-popover]:popover-open {
51
+ @starting-style {
52
+ opacity: 0;
53
+ translate: 0 -4px;
54
+ }
55
+ }
56
+
57
+ @media (prefers-reduced-motion: reduce) {
58
+ menu-ui [data-menu-popover] { transition: none; }
41
59
  }
42
60
 
43
61
  /* Safari 17.x bug: `:scope:hover` inside `@scope` doesn't match the scope
@@ -251,18 +251,30 @@ class AdiaMenuItem extends AdiaElement {
251
251
  this.#syncAria();
252
252
  }
253
253
 
254
+ // Mark slot elements we create so render() never deletes consumer-provided ones.
255
+ // See ADR-0010 (slot content is source of truth).
256
+ #stampMark(el) { el.dataset.menuItemStamped = '1'; return el; }
257
+ #wasStamped(el) { return el?.dataset?.menuItemStamped === '1'; }
258
+
259
+ #ownChild(selector) {
260
+ for (const ch of this.children) {
261
+ if (ch.matches(selector)) return ch;
262
+ }
263
+ return null;
264
+ }
265
+
254
266
  #stamp() {
255
- if (this.querySelector('[slot="text"]')) return;
267
+ if (this.#ownChild('[slot="text"]')) return;
256
268
 
257
- if (this.icon) {
258
- const iconEl = document.createElement('icon-ui');
269
+ if (this.icon && !this.#ownChild('[slot="icon"]')) {
270
+ const iconEl = this.#stampMark(document.createElement('icon-ui'));
259
271
  iconEl.setAttribute('slot', 'icon');
260
272
  iconEl.setAttribute('name', this.icon);
261
273
  this.appendChild(iconEl);
262
274
  }
263
275
 
264
276
  if (this.text) {
265
- const span = document.createElement('span');
277
+ const span = this.#stampMark(document.createElement('span'));
266
278
  span.setAttribute('slot', 'text');
267
279
  span.textContent = this.text;
268
280
  this.appendChild(span);
@@ -275,22 +287,24 @@ class AdiaMenuItem extends AdiaElement {
275
287
  }
276
288
 
277
289
  render() {
278
- const iconEl = this.querySelector('[slot="icon"]');
290
+ // Sync icon — only touch elements we stamped.
291
+ const iconEl = this.#ownChild('[slot="icon"]');
279
292
  if (this.icon) {
280
293
  if (iconEl) {
281
- iconEl.setAttribute('name', this.icon);
294
+ if (this.#wasStamped(iconEl)) iconEl.setAttribute('name', this.icon);
282
295
  } else {
283
- const el = document.createElement('icon-ui');
296
+ const el = this.#stampMark(document.createElement('icon-ui'));
284
297
  el.setAttribute('slot', 'icon');
285
298
  el.setAttribute('name', this.icon);
286
299
  this.prepend(el);
287
300
  }
288
- } else if (iconEl) {
301
+ } else if (this.#wasStamped(iconEl)) {
289
302
  iconEl.remove();
290
303
  }
291
304
 
292
- const textEl = this.querySelector('[slot="text"]');
293
- if (textEl) textEl.textContent = this.text;
305
+ // Sync text — only touch elements we stamped.
306
+ const textEl = this.#ownChild('[slot="text"]');
307
+ if (this.#wasStamped(textEl)) textEl.textContent = this.text;
294
308
 
295
309
  this.#syncAria();
296
310
  }
@@ -91,6 +91,11 @@
91
91
  background: var(--pane-header-bg-hover);
92
92
  }
93
93
 
94
+ > header:focus-visible {
95
+ outline: none;
96
+ box-shadow: var(--a-focus-ring) inset;
97
+ }
98
+
94
99
  /* Collapse indicator — stamped by JS as icon-ui */
95
100
  > header > [slot="chevron"] {
96
101
  --a-icon-size: var(--a-caret-size);
@@ -130,7 +130,21 @@
130
130
  font-size: var(--pipeline-status-history-size);
131
131
  color: var(--pipeline-status-history-fg);
132
132
  user-select: none;
133
- padding: var(--pipeline-status-history-pad-y) 0;
133
+ padding: var(--pipeline-status-history-pad-y) var(--a-space-1);
134
+ margin-inline: calc(var(--a-space-1) * -1);
135
+ border-radius: var(--a-radius-sm);
136
+ transition: background var(--pipeline-status-duration) var(--pipeline-status-easing),
137
+ color var(--pipeline-status-duration) var(--pipeline-status-easing);
138
+ }
139
+
140
+ [data-pipeline-history] summary:hover {
141
+ background: var(--a-bg-subtle);
142
+ color: var(--pipeline-status-label-fg);
143
+ }
144
+
145
+ [data-pipeline-history] summary:focus-visible {
146
+ outline: none;
147
+ box-shadow: var(--a-focus-ring);
134
148
  }
135
149
 
136
150
  [data-pipeline-history] summary::marker {
@@ -47,6 +47,23 @@
47
47
  color: var(--popover-fg);
48
48
  max-height: calc(100vh - 3rem);
49
49
  overflow-y: auto;
50
+ /* Fade + lift in on first paint. @starting-style is the initial frame
51
+ browsers paint before the popover transitions to its open state. */
52
+ opacity: 1;
53
+ translate: 0 0;
54
+ transition: opacity var(--a-duration-fast) var(--a-easing-out),
55
+ translate var(--a-duration-fast) var(--a-easing-out);
56
+ }
57
+
58
+ [slot="content"]:popover-open {
59
+ @starting-style {
60
+ opacity: 0;
61
+ translate: 0 -4px;
62
+ }
63
+ }
64
+
65
+ @media (prefers-reduced-motion: reduce) {
66
+ [slot="content"] { transition: none; }
50
67
  }
51
68
 
52
69
  /* Collapse default margins on the first/last block child so the
@@ -199,6 +199,24 @@ select-ui [slot="listbox"] {
199
199
 
200
200
  /* Positioned by JS (#positionListbox) — fixed to viewport */
201
201
  width: max-content;
202
+
203
+ /* Fade + lift in on first paint (popover top-layer cannot inherit
204
+ component tokens, so reference --a-* directly). */
205
+ opacity: 1;
206
+ translate: 0 0;
207
+ transition: opacity var(--a-duration-fast) var(--a-easing-out),
208
+ translate var(--a-duration-fast) var(--a-easing-out);
209
+ }
210
+
211
+ select-ui [slot="listbox"]:popover-open {
212
+ @starting-style {
213
+ opacity: 0;
214
+ translate: 0 -4px;
215
+ }
216
+ }
217
+
218
+ @media (prefers-reduced-motion: reduce) {
219
+ select-ui [slot="listbox"] { transition: none; }
202
220
  }
203
221
 
204
222
  select-ui [role="option"] {