@adia-ai/web-components 0.0.26 → 0.0.27

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 (38) 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-reasoning/agent-reasoning.css +11 -0
  6. package/components/agent-reasoning/agent-reasoning.js +16 -0
  7. package/components/agent-trace/agent-trace.css +19 -0
  8. package/components/alert/alert.a2ui.json +10 -4
  9. package/components/alert/alert.css +13 -0
  10. package/components/alert/alert.js +1 -1
  11. package/components/alert/alert.yaml +21 -4
  12. package/components/badge/badge.a2ui.json +0 -2
  13. package/components/badge/badge.css +20 -0
  14. package/components/badge/badge.js +1 -1
  15. package/components/badge/badge.yaml +0 -2
  16. package/components/calendar-picker/calendar-picker.css +17 -0
  17. package/components/code/code.css +41 -0
  18. package/components/code/code.js +44 -3
  19. package/components/empty-state/empty-state.js +32 -21
  20. package/components/list/list.js +20 -16
  21. package/components/menu/menu.css +18 -0
  22. package/components/menu/menu.js +24 -10
  23. package/components/pane/pane.css +5 -0
  24. package/components/pipeline-status/pipeline-status.css +15 -1
  25. package/components/popover/popover.css +17 -0
  26. package/components/select/select.css +18 -0
  27. package/components/swiper/swiper.css +9 -0
  28. package/components/table/table.css +5 -0
  29. package/components/table/table.js +45 -1
  30. package/components/table-toolbar/table-toolbar.css +13 -0
  31. package/components/tag/tag.css +10 -0
  32. package/components/timeline/timeline.css +10 -3
  33. package/components/toast/toast.css +93 -48
  34. package/components/toast/toast.js +101 -22
  35. package/components/toolbar/toolbar.css +13 -0
  36. package/components/tooltip/tooltip.css +8 -0
  37. package/package.json +1 -1
  38. package/styles/colors/semantics.css +1 -1
@@ -32,7 +32,7 @@
32
32
  "default": ""
33
33
  },
34
34
  "kind": {
35
- "description": "Uppercase badge label.",
35
+ "description": "Badge label; value is normalized to uppercase before rendering.",
36
36
  "type": "string",
37
37
  "default": ""
38
38
  },
@@ -13,6 +13,7 @@
13
13
  --agent-artifact-fg-muted: var(--a-fg-muted);
14
14
  --agent-artifact-border: var(--a-border-subtle);
15
15
  --agent-artifact-header-bg: var(--a-bg);
16
+ --agent-artifact-header-bg-hover: var(--a-bg-hover);
16
17
 
17
18
  /* ── Typography ── */
18
19
  --agent-artifact-title-size: var(--a-ui-md);
@@ -46,6 +47,16 @@
46
47
  cursor: pointer;
47
48
  user-select: none;
48
49
  min-width: 0;
50
+ transition: background var(--agent-artifact-duration);
51
+ }
52
+
53
+ [data-artifact-header]:hover {
54
+ background: var(--agent-artifact-header-bg-hover);
55
+ }
56
+
57
+ [data-artifact-header]:focus-visible {
58
+ outline: none;
59
+ box-shadow: var(--a-focus-ring) inset;
49
60
  }
50
61
 
51
62
  [data-artifact-icon] {
@@ -17,7 +17,8 @@
17
17
  *
18
18
  * Attributes:
19
19
  * title — header title (string)
20
- * kind — small uppercase badge, e.g. "A2UI", "JSON", "TICKET"
20
+ * kind — small badge, e.g. "A2UI", "JSON", "TICKET" (value is
21
+ * normalized to uppercase before rendering)
21
22
  * icon — icon-ui name for the header
22
23
  * collapsed — start collapsed (body hidden)
23
24
  * tone — neutral (default) | accent | warning | danger
@@ -59,6 +60,7 @@ class AdiaAgentArtifact extends AdiaElement {
59
60
 
60
61
  disconnected() {
61
62
  this.#headerEl?.removeEventListener('click', this.#onHeaderClick);
63
+ this.#headerEl?.removeEventListener('keydown', this.#onHeaderKey);
62
64
  this.#headerEl = this.#iconEl = this.#titleEl = null;
63
65
  this.#kindEl = this.#chevronEl = this.#bodyEl = null;
64
66
  }
@@ -73,6 +75,17 @@ class AdiaAgentArtifact extends AdiaElement {
73
75
  this.render();
74
76
  };
75
77
 
78
+ // Keyboard activation — Space/Enter toggle (matches role="button" semantics).
79
+ #onHeaderKey = (e) => {
80
+ if (e.key === ' ' || e.key === 'Enter') {
81
+ // Don't fire when the focus is on a slotted action button — let those
82
+ // handle their own activation.
83
+ if (e.target.closest('[slot="primary"], [slot="secondary"]')) return;
84
+ e.preventDefault();
85
+ this.#onHeaderClick(e);
86
+ }
87
+ };
88
+
76
89
  render() {
77
90
  if (!this.#headerEl) return;
78
91
 
@@ -95,6 +108,9 @@ class AdiaAgentArtifact extends AdiaElement {
95
108
  if (this.#bodyEl) {
96
109
  this.#bodyEl.hidden = this.collapsed;
97
110
  }
111
+ if (this.#headerEl) {
112
+ this.#headerEl.setAttribute('aria-expanded', String(!this.collapsed));
113
+ }
98
114
  }
99
115
 
100
116
  #build() {
@@ -120,10 +136,15 @@ class AdiaAgentArtifact extends AdiaElement {
120
136
 
121
137
  this.innerHTML = '';
122
138
 
123
- // Header
139
+ // Header — keyboard-focusable button-style row that toggles collapsed.
140
+ // role/tabindex make Space/Enter activate it the same as click.
124
141
  this.#headerEl = document.createElement('div');
125
142
  this.#headerEl.setAttribute('data-artifact-header', '');
143
+ this.#headerEl.setAttribute('role', 'button');
144
+ this.#headerEl.setAttribute('tabindex', '0');
145
+ this.#headerEl.setAttribute('aria-expanded', String(!this.collapsed));
126
146
  this.#headerEl.addEventListener('click', this.#onHeaderClick);
147
+ this.#headerEl.addEventListener('keydown', this.#onHeaderKey);
127
148
 
128
149
  this.#iconEl = document.createElement('icon-ui');
129
150
  this.#iconEl.setAttribute('data-artifact-icon', '');
@@ -9,7 +9,7 @@ version: 1
9
9
  description: Inline container for structured agent artifacts (A2UI, JSON, tickets).
10
10
  props:
11
11
  kind:
12
- description: Uppercase badge label.
12
+ description: Badge label; value is normalized to uppercase before rendering.
13
13
  type: string
14
14
  default: ""
15
15
  collapsed:
@@ -55,6 +55,17 @@
55
55
  user-select: none;
56
56
  padding-bottom: var(--a-space-1);
57
57
  color: var(--agent-reasoning-fg-muted);
58
+ border-radius: var(--a-radius-sm);
59
+ transition: color var(--agent-reasoning-duration) var(--agent-reasoning-easing);
60
+ }
61
+
62
+ [data-reasoning-summary]:hover {
63
+ color: var(--agent-reasoning-fg);
64
+ }
65
+
66
+ [data-reasoning-summary]:focus-visible {
67
+ outline: none;
68
+ box-shadow: var(--a-focus-ring);
58
69
  }
59
70
 
60
71
  [data-reasoning-summary] > [data-reasoning-check],
@@ -109,6 +109,7 @@ class AdiaAgentReasoning extends AdiaElement {
109
109
  this.#finishTimer = null;
110
110
  }
111
111
  this.#summaryEl?.removeEventListener('click', this.#onSummaryClick);
112
+ this.#summaryEl?.removeEventListener('keydown', this.#onSummaryKey);
112
113
  this.#bodyEl?.removeEventListener('timeline-toggle', this.#onStepToggle);
113
114
  this.#summaryEl = null;
114
115
  this.#bodyEl = null;
@@ -265,12 +266,26 @@ class AdiaAgentReasoning extends AdiaElement {
265
266
  this.#render();
266
267
  };
267
268
 
269
+ // Keyboard activation — Space/Enter toggle (matches role="button" semantics).
270
+ #onSummaryKey = (e) => {
271
+ if (e.key === ' ' || e.key === 'Enter') {
272
+ e.preventDefault();
273
+ this.#onSummaryClick();
274
+ }
275
+ };
276
+
268
277
  #buildShell() {
269
278
  this.innerHTML = '';
270
279
 
280
+ // Summary is a click-to-toggle row; mark up + key-activate so it
281
+ // behaves like a button under keyboard nav too.
271
282
  this.#summaryEl = document.createElement('div');
272
283
  this.#summaryEl.setAttribute('data-reasoning-summary', '');
284
+ this.#summaryEl.setAttribute('role', 'button');
285
+ this.#summaryEl.setAttribute('tabindex', '0');
286
+ this.#summaryEl.setAttribute('aria-expanded', String(!this.collapsed));
273
287
  this.#summaryEl.addEventListener('click', this.#onSummaryClick);
288
+ this.#summaryEl.addEventListener('keydown', this.#onSummaryKey);
274
289
  this.appendChild(this.#summaryEl);
275
290
 
276
291
  this.#bodyEl = document.createElement('div');
@@ -341,6 +356,7 @@ class AdiaAgentReasoning extends AdiaElement {
341
356
  `;
342
357
 
343
358
  this.#bodyEl.hidden = this.collapsed;
359
+ this.#summaryEl.setAttribute('aria-expanded', String(!this.collapsed));
344
360
  if (this.collapsed) return;
345
361
 
346
362
  // Body rendering: we group contiguous steps into timeline-ui blocks and
@@ -43,9 +43,23 @@
43
43
  gap: var(--agent-trace-inline-gap);
44
44
  cursor: pointer;
45
45
  white-space: nowrap;
46
+ border-radius: var(--a-radius-sm);
47
+ padding: var(--a-space-0-5) var(--a-space-1);
48
+ margin: calc(var(--a-space-0-5) * -1) calc(var(--a-space-1) * -1);
49
+ transition: background var(--agent-trace-chevron-dur);
50
+ }
51
+
52
+ [data-trace-root] > summary:hover {
53
+ background: var(--a-bg-subtle);
54
+ }
55
+
56
+ [data-trace-root] > summary:focus-visible {
57
+ outline: none;
58
+ box-shadow: var(--a-focus-ring);
46
59
  }
47
60
 
48
61
  [data-trace-root] > summary::-webkit-details-marker { display: none; }
62
+ [data-trace-root] > summary::marker { content: ''; }
49
63
 
50
64
  [data-trace-status] {
51
65
  flex: 1;
@@ -163,6 +177,11 @@
163
177
  background: var(--a-bg-subtle);
164
178
  }
165
179
 
180
+ details[data-trace-row] > summary:focus-visible {
181
+ outline: none;
182
+ box-shadow: var(--a-focus-ring);
183
+ }
184
+
166
185
  [data-trace-label] {
167
186
  color: var(--agent-trace-fg-subtle);
168
187
  }
@@ -93,11 +93,17 @@
93
93
  "button"
94
94
  ],
95
95
  "slots": {
96
- "default": {
97
- "description": "Rich content for the alert body"
96
+ "action": {
97
+ "description": "Optional trailing action button (e.g. \"Refresh\", \"Status page\"). Right-aligned in the flex layout. See system-banners pattern for examples."
98
98
  },
99
- "icon": {
100
- "description": "Custom icon element"
99
+ "close": {
100
+ "description": "Close button. Stamped automatically when `closable` is set; the stamped element is `<button-ui slot=\"close\" icon=\"x\" variant=\"ghost\" size=\"sm\">`. Override by passing a custom `slot=\"close\"` button."
101
+ },
102
+ "content": {
103
+ "description": "Alert body. For single-line text use the `text=` attribute (alert.js stamps a `<span slot=\"content\">` automatically). For rich content with title + description, wrap in `<col-ui slot=\"content\">` — the alert root is `display: flex` row, so multiple bare children would lay out side-by-side instead of stacking."
104
+ },
105
+ "leading": {
106
+ "description": "Leading icon. Stamped automatically from the `icon=` attribute; consumers can override by passing a custom `<icon-ui slot=\"leading\">`."
101
107
  }
102
108
  },
103
109
  "states": [
@@ -65,6 +65,19 @@
65
65
  --alert-icon-fg: var(--a-danger-strong);
66
66
  }
67
67
 
68
+ /* `muted` and `neutral` are semantic aliases of the base — same tokens
69
+ as the default variant, declared explicitly so the yaml enum and the
70
+ CSS contract agree. Use cases: passive callouts (beta banners,
71
+ tertiary notes) where the alert should read as quiet chrome rather
72
+ than carrying tonal weight. */
73
+ :scope[variant="muted"],
74
+ :scope[variant="neutral"] {
75
+ --alert-bg: var(--a-bg-muted);
76
+ --alert-fg: var(--a-fg);
77
+ --alert-border: var(--a-border-subtle);
78
+ --alert-icon-fg: var(--a-fg-muted);
79
+ }
80
+
68
81
  /* ── Slots ── */
69
82
  :scope [slot="leading"] {
70
83
  flex-shrink: 0;
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Inline alert banner with optional icon and close button.
6
6
  *
7
- * Variants: default, info, success, warning, danger
7
+ * Variants: default, info, success, warning, danger, muted, neutral
8
8
  * Slots: leading (icon), content (text), close (dismiss button)
9
9
  *
10
10
  * Events:
@@ -36,10 +36,27 @@ events:
36
36
  close:
37
37
  description: Fired when the close button is clicked
38
38
  slots:
39
- default:
40
- description: Rich content for the alert body
41
- icon:
42
- description: Custom icon element
39
+ content:
40
+ description: >-
41
+ Alert body. For single-line text use the `text=` attribute (alert.js
42
+ stamps a `<span slot="content">` automatically). For rich content
43
+ with title + description, wrap in `<col-ui slot="content">` — the
44
+ alert root is `display: flex` row, so multiple bare children would
45
+ lay out side-by-side instead of stacking.
46
+ leading:
47
+ description: >-
48
+ Leading icon. Stamped automatically from the `icon=` attribute;
49
+ consumers can override by passing a custom `<icon-ui slot="leading">`.
50
+ close:
51
+ description: >-
52
+ Close button. Stamped automatically when `closable` is set; the
53
+ stamped element is `<button-ui slot="close" icon="x" variant="ghost"
54
+ size="sm">`. Override by passing a custom `slot="close"` button.
55
+ action:
56
+ description: >-
57
+ Optional trailing action button (e.g. "Refresh", "Status page").
58
+ Right-aligned in the flex layout. See system-banners pattern for
59
+ examples.
43
60
  states:
44
61
  - name: idle
45
62
  description: Default, ready for interaction.
@@ -61,8 +61,6 @@
61
61
  "warning",
62
62
  "danger",
63
63
  "primary",
64
- "soft",
65
- "outline",
66
64
  "muted",
67
65
  "neutral"
68
66
  ],
@@ -74,5 +74,25 @@
74
74
  --badge-fg: var(--a-danger-text);
75
75
  }
76
76
 
77
+ /* `primary` is the accent-filled variant — solid bg + on-accent text,
78
+ used for "PRO"-style emphasis chips that should read as a brand
79
+ stamp rather than tinted-on-canvas. Uses the L3 primary-* surface
80
+ matrix so it tracks the same accent-strong source as button-ui's
81
+ primary variant. */
82
+ :scope[variant="primary"] {
83
+ --badge-bg: var(--a-primary-bg);
84
+ --badge-fg: var(--a-primary-fg);
85
+ }
86
+
87
+ /* `muted` and `neutral` are semantic aliases of the base — same tokens
88
+ as the default variant, declared explicitly so the yaml enum and the
89
+ CSS contract agree. Use cases: passive metadata chips (counts, IDs,
90
+ "Not set" states) where the badge should read as quiet chrome. */
91
+ :scope[variant="muted"],
92
+ :scope[variant="neutral"] {
93
+ --badge-bg: var(--a-bg-muted);
94
+ --badge-fg: var(--a-fg);
95
+ }
96
+
77
97
  /* Size handled by universal [size] attribute system. */
78
98
  }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Inline badge / tag. Pill-shaped, text rendered via CSS attr().
8
8
  *
9
- * Variants: default, accent, info, success, warning, danger
9
+ * Variants: default, accent, info, success, warning, danger, primary, muted, neutral
10
10
  * Sizes: sm, md (default)
11
11
  * Icon: optional leading icon (any registered icon name; "dot" for legend
12
12
  * markers). Inherits the variant's foreground color so legend chips
@@ -52,8 +52,6 @@ props:
52
52
  - warning
53
53
  - danger
54
54
  - primary
55
- - soft
56
- - outline
57
55
  - muted
58
56
  - neutral
59
57
  events: {}
@@ -180,6 +180,23 @@ calendar-picker-ui [slot="popover"] {
180
180
  font-family: inherit;
181
181
  font-size: var(--calendar-picker-font-size);
182
182
  color: var(--calendar-picker-popover-fg);
183
+ /* Fade + lift in on first paint via @starting-style. Same pattern
184
+ as menu/select/popover surfaces — see journal 2026-04-29 §18. */
185
+ opacity: 1;
186
+ translate: 0 0;
187
+ transition: opacity var(--a-duration-fast) var(--a-easing-out),
188
+ translate var(--a-duration-fast) var(--a-easing-out);
189
+ }
190
+
191
+ calendar-picker-ui [slot="popover"]:popover-open {
192
+ @starting-style {
193
+ opacity: 0;
194
+ translate: 0 -4px;
195
+ }
196
+ }
197
+
198
+ @media (prefers-reduced-motion: reduce) {
199
+ calendar-picker-ui [slot="popover"] { transition: none; }
183
200
  }
184
201
 
185
202
  /* Header row */
@@ -156,6 +156,47 @@
156
156
  color: inherit;
157
157
  }
158
158
 
159
+ /* ── Diff line states (static path) ──
160
+ Active when the block uses `language="diff"` (auto-parse +/- prefix)
161
+ or carries `data-line-states="..."` (explicit per-line). Each line is
162
+ a `[data-line-state]` row whose bg picks up the state token. The
163
+ CodeMirror path doesn't use these markers; line decorations there go
164
+ through CM extensions instead. */
165
+ > pre > code[data-line-state-mode] {
166
+ display: block;
167
+ }
168
+ > pre > code[data-line-state-mode] > [data-line-state] {
169
+ display: grid;
170
+ grid-template-columns: 1fr;
171
+ /* Bleed the row tint to the pre's padding edges so it reads as a
172
+ full-width line, not a gap-margined pill. */
173
+ margin-inline: calc(-1 * var(--code-px));
174
+ padding-inline: var(--code-px);
175
+ }
176
+ > pre > code[data-line-numbers] > [data-line-state] {
177
+ grid-template-columns: auto 1fr;
178
+ column-gap: var(--a-space-3);
179
+ }
180
+ > pre > code[data-line-state-mode] [data-line-num] {
181
+ color: var(--a-fg-subtle);
182
+ text-align: end;
183
+ user-select: none;
184
+ min-width: 1.5ch;
185
+ }
186
+ > pre > code[data-line-state-mode] [data-line-body] {
187
+ white-space: pre;
188
+ }
189
+ /* Empty lines still need height so the diff column counts line up. */
190
+ > pre > code[data-line-state-mode] [data-line-body]:empty::before {
191
+ content: " ";
192
+ }
193
+ > pre > code [data-line-state="added"] {
194
+ background: var(--a-success-muted);
195
+ }
196
+ > pre > code [data-line-state="removed"] {
197
+ background: var(--a-danger-muted);
198
+ }
199
+
159
200
  /* Footer — optional chrome band below the code block
160
201
  (line counts, byte size, language family, etc.) */
161
202
  > footer {
@@ -90,9 +90,12 @@ class AdiaCode extends AdiaElement {
90
90
  // Mount CodeMirror when the language is supported OR the element is
91
91
  // editable (editable plain-text is still useful). Inline instances
92
92
  // stay on the static <code> path. Mount failures leave the static
93
- // fallback in place — it's already visible.
93
+ // fallback in place — it's already visible. Diff line-state mode
94
+ // (auto via `language="diff"` or explicit `data-line-states`) also
95
+ // stays on the static path — bg tinting lives on the wrapper spans.
94
96
  const lang = canonicalLanguage(this.language);
95
- const shouldMount = !this.inline && (SUPPORTED_LANGUAGES.has(lang) || this.editable);
97
+ const useLineState = this.hasAttribute('data-line-states') || lang === 'diff';
98
+ const shouldMount = !this.inline && !useLineState && (SUPPORTED_LANGUAGES.has(lang) || this.editable);
96
99
  if (shouldMount) {
97
100
  this.#mountEditor();
98
101
  }
@@ -145,7 +148,45 @@ class AdiaCode extends AdiaElement {
145
148
  // Pre > Code — direct semantic elements, no slot attr
146
149
  const pre = document.createElement('pre');
147
150
  const code = document.createElement('code');
148
- code.textContent = raw;
151
+
152
+ // Diff line-state mode — wrap each line so it can carry a state-driven
153
+ // bg tint and (when [line-numbers]) a leading line-number gutter.
154
+ // Triggered by `language="diff"` (auto-parses +/- prefix) or by an
155
+ // explicit `data-line-states` CSV that maps positionally onto lines.
156
+ const lang = canonicalLanguage(this.language);
157
+ const explicit = this.getAttribute('data-line-states');
158
+ const useLineState = explicit != null || lang === 'diff';
159
+ if (useLineState) {
160
+ code.setAttribute('data-line-state-mode', '');
161
+ if (this.lineNumbers) code.setAttribute('data-line-numbers', '');
162
+ const states = explicit?.split(',').map((s) => s.trim()) ?? null;
163
+ const lines = raw.split('\n');
164
+ lines.forEach((line, i) => {
165
+ let state = 'unchanged';
166
+ if (states && states[i]) {
167
+ state = states[i];
168
+ } else if (lang === 'diff') {
169
+ const head = line.charAt(0);
170
+ if (head === '+') state = 'added';
171
+ else if (head === '-') state = 'removed';
172
+ }
173
+ const row = document.createElement('span');
174
+ row.setAttribute('data-line-state', state);
175
+ if (this.lineNumbers) {
176
+ const num = document.createElement('span');
177
+ num.setAttribute('data-line-num', '');
178
+ num.textContent = String(i + 1);
179
+ row.appendChild(num);
180
+ }
181
+ const body = document.createElement('span');
182
+ body.setAttribute('data-line-body', '');
183
+ body.textContent = line;
184
+ row.appendChild(body);
185
+ code.appendChild(row);
186
+ });
187
+ } else {
188
+ code.textContent = raw;
189
+ }
149
190
  pre.appendChild(code);
150
191
 
151
192
  this.appendChild(pre);
@@ -24,10 +24,15 @@ class AdiaEmptyState extends AdiaElement {
24
24
  #headingEl = null;
25
25
  #descEl = null;
26
26
 
27
+ // Mark slot elements we create so render() never overrides consumer-provided ones.
28
+ // See ADR-0010 (slot content is source of truth).
29
+ #stampMark(el) { el.dataset.emptyStateStamped = '1'; return el; }
30
+ #wasStamped(el) { return el?.dataset?.emptyStateStamped === '1'; }
31
+
27
32
  connected() {
28
33
  this.#iconEl = this.querySelector(':scope > [slot="icon"]');
29
34
  if (!this.#iconEl) {
30
- this.#iconEl = document.createElement('icon-ui');
35
+ this.#iconEl = this.#stampMark(document.createElement('icon-ui'));
31
36
  this.#iconEl.setAttribute('slot', 'icon');
32
37
  this.#iconEl.setAttribute('size', 'lg');
33
38
  this.insertBefore(this.#iconEl, this.firstChild);
@@ -35,14 +40,14 @@ class AdiaEmptyState extends AdiaElement {
35
40
 
36
41
  this.#headingEl = this.querySelector(':scope > [slot="heading"]');
37
42
  if (!this.#headingEl) {
38
- this.#headingEl = document.createElement('span');
43
+ this.#headingEl = this.#stampMark(document.createElement('span'));
39
44
  this.#headingEl.setAttribute('slot', 'heading');
40
45
  this.insertBefore(this.#headingEl, this.querySelector('[slot="action"]'));
41
46
  }
42
47
 
43
48
  this.#descEl = this.querySelector(':scope > [slot="description"]');
44
49
  if (!this.#descEl) {
45
- this.#descEl = document.createElement('span');
50
+ this.#descEl = this.#stampMark(document.createElement('span'));
46
51
  this.#descEl.setAttribute('slot', 'description');
47
52
  this.insertBefore(this.#descEl, this.querySelector('[slot="action"]'));
48
53
  }
@@ -51,28 +56,34 @@ class AdiaEmptyState extends AdiaElement {
51
56
  render() {
52
57
  if (!this.#iconEl) return;
53
58
 
54
- // Icon
55
- if (this.icon) {
56
- this.#iconEl.setAttribute('name', this.icon);
57
- this.#iconEl.hidden = false;
58
- } else {
59
- this.#iconEl.hidden = true;
59
+ // Icon — only mutate stamped elements; visibility follows the source of truth.
60
+ if (this.#wasStamped(this.#iconEl)) {
61
+ if (this.icon) {
62
+ this.#iconEl.setAttribute('name', this.icon);
63
+ this.#iconEl.hidden = false;
64
+ } else {
65
+ this.#iconEl.hidden = true;
66
+ }
60
67
  }
61
68
 
62
- // Heading
63
- if (this.heading) {
64
- this.#headingEl.textContent = this.heading;
65
- this.#headingEl.hidden = false;
66
- } else {
67
- this.#headingEl.hidden = true;
69
+ // Heading — same policy.
70
+ if (this.#wasStamped(this.#headingEl)) {
71
+ if (this.heading) {
72
+ this.#headingEl.textContent = this.heading;
73
+ this.#headingEl.hidden = false;
74
+ } else {
75
+ this.#headingEl.hidden = true;
76
+ }
68
77
  }
69
78
 
70
- // Description
71
- if (this.description) {
72
- this.#descEl.textContent = this.description;
73
- this.#descEl.hidden = false;
74
- } else {
75
- this.#descEl.hidden = true;
79
+ // Description — same policy.
80
+ if (this.#wasStamped(this.#descEl)) {
81
+ if (this.description) {
82
+ this.#descEl.textContent = this.description;
83
+ this.#descEl.hidden = false;
84
+ } else {
85
+ this.#descEl.hidden = true;
86
+ }
76
87
  }
77
88
  }
78
89
 
@@ -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
  }