@adia-ai/web-components 0.0.25 → 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 (45) 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/index.js +0 -1
  21. package/components/list/list.js +20 -16
  22. package/components/menu/menu.css +18 -0
  23. package/components/menu/menu.js +24 -10
  24. package/components/pane/pane.css +5 -0
  25. package/components/pipeline-status/pipeline-status.css +15 -1
  26. package/components/popover/popover.css +17 -0
  27. package/components/select/select.css +18 -0
  28. package/components/swiper/swiper.css +9 -0
  29. package/components/table/table.css +5 -0
  30. package/components/table/table.js +45 -1
  31. package/components/table-toolbar/table-toolbar.css +13 -0
  32. package/components/tag/tag.css +10 -0
  33. package/components/timeline/timeline.css +10 -3
  34. package/components/toast/toast.css +93 -48
  35. package/components/toast/toast.js +101 -22
  36. package/components/toolbar/toolbar.css +13 -0
  37. package/components/tooltip/tooltip.css +8 -0
  38. package/package.json +1 -1
  39. package/patterns/app-shell/app-shell.css +0 -12
  40. package/styles/colors/semantics.css +1 -1
  41. package/styles/components.css +0 -1
  42. package/components/app-shell/app-shell.a2ui.json +0 -136
  43. package/components/app-shell/app-shell.css +0 -16
  44. package/components/app-shell/app-shell.js +0 -202
  45. package/components/app-shell/app-shell.yaml +0 -183
@@ -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"] {
@@ -188,6 +188,15 @@
188
188
  transform var(--swiper-duration) var(--swiper-easing);
189
189
  }
190
190
 
191
+ :scope > [data-swiper-dots] > button:hover {
192
+ background: var(--swiper-dot-bg-active);
193
+ }
194
+
195
+ :scope > [data-swiper-dots] > button:focus-visible {
196
+ outline: none;
197
+ box-shadow: var(--a-focus-ring);
198
+ }
199
+
191
200
  :scope > [data-swiper-dots] > button[aria-current="true"] {
192
201
  background: var(--swiper-dot-bg-active);
193
202
  }
@@ -210,6 +210,9 @@
210
210
 
211
211
  [data-body] [role="gridcell"] {
212
212
  box-sizing: border-box;
213
+ display: flex;
214
+ align-items: center;
215
+ align-self: stretch;
213
216
  padding: var(--table-py) var(--table-px);
214
217
  border-bottom: 1px solid var(--table-border);
215
218
  min-width: 0;
@@ -218,10 +221,12 @@
218
221
 
219
222
  [data-align="right"] {
220
223
  text-align: right;
224
+ justify-content: flex-end;
221
225
  }
222
226
 
223
227
  [data-align="center"] {
224
228
  text-align: center;
229
+ justify-content: center;
225
230
  }
226
231
 
227
232
  /* ═══════ Row states ═══════ */
@@ -129,10 +129,54 @@ class AdiaTable extends AdiaElement {
129
129
 
130
130
  get data() { return this.#data; }
131
131
 
132
- // ── Public API: read-only getters ──
132
+ // ── Public API: selection ──
133
133
 
134
+ /**
135
+ * Indices of currently-selected rows, ascending.
136
+ * @returns {number[]}
137
+ */
134
138
  get selected() { return [...this.#selected].sort((a, b) => a - b); }
135
139
 
140
+ /**
141
+ * Replace the selection set programmatically. Out-of-range indices are
142
+ * silently dropped. Pair with `selectable` mode; on a non-selectable
143
+ * table the indices are stored but not rendered as checked rows.
144
+ * @param {Iterable<number>} indices
145
+ */
146
+ set selected(indices) {
147
+ this.#selected.clear();
148
+ if (indices) {
149
+ for (const i of indices) {
150
+ if (Number.isInteger(i) && i >= 0 && i < this.#data.length) {
151
+ this.#selected.add(i);
152
+ }
153
+ }
154
+ }
155
+ this.#requestRender();
156
+ this.dispatchEvent(new CustomEvent('select', {
157
+ detail: { selected: this.selected },
158
+ bubbles: true,
159
+ }));
160
+ }
161
+
162
+ /**
163
+ * Empty the selection set. Equivalent to `el.selected = []` but doesn't
164
+ * require constructing an empty array; the most common selection-write
165
+ * call by far (after a "delete" or "archive" bulk action), so worth
166
+ * having a one-token verb form.
167
+ */
168
+ clearSelection() {
169
+ if (this.#selected.size === 0) return;
170
+ this.#selected.clear();
171
+ this.#requestRender();
172
+ this.dispatchEvent(new CustomEvent('select', {
173
+ detail: { selected: [] },
174
+ bubbles: true,
175
+ }));
176
+ }
177
+
178
+ // ── Public API: read-only getters ──
179
+
136
180
  get sortState() { return this.#sortState.map(s => ({ ...s })); }
137
181
 
138
182
  // ── Public API: filters ──
@@ -156,6 +156,19 @@
156
156
  display: flex;
157
157
  flex-direction: column;
158
158
  gap: var(--a-space-1);
159
+ /* Fade + lift in on first paint. */
160
+ opacity: 1;
161
+ translate: 0 0;
162
+ transition: opacity var(--a-duration-fast) var(--a-easing-out),
163
+ translate var(--a-duration-fast) var(--a-easing-out);
164
+ @starting-style {
165
+ opacity: 0;
166
+ translate: 0 -4px;
167
+ }
168
+ }
169
+
170
+ @media (prefers-reduced-motion: reduce) {
171
+ [data-toolbar-popover]:popover-open { transition: none; }
159
172
  }
160
173
 
161
174
  /* The popover head + empty hint are <text-ui variant="kicker"|caption">,
@@ -79,6 +79,16 @@ tag-ui[removable]:not([disabled]):hover {
79
79
  --tag-fg: var(--a-danger-bg);
80
80
  }
81
81
 
82
+ /* `default` is a semantic alias of the base — same tokens as the
83
+ unstyled `:scope`, declared explicitly so the yaml enum and the
84
+ CSS contract agree. Most consumers omit `variant` and inherit the
85
+ base; the explicit selector lets `<tag-ui variant="default">`
86
+ render identically without falling through to base. */
87
+ :scope[variant="default"] {
88
+ --tag-bg: var(--a-bg-muted);
89
+ --tag-fg: var(--a-fg);
90
+ }
91
+
82
92
  /* Size handled by universal [size] attribute system. */
83
93
 
84
94
  /* hover rule moved outside @scope — see Safari 17.x bug note at top. */
@@ -350,14 +350,22 @@ agent-reasoning-ui timeline-ui:not([orientation="horizontal"]),
350
350
  top: calc((1.4em - 1em) / 2);
351
351
  background: none;
352
352
  border: none;
353
- padding: 0 var(--timeline-item-toggle-px);
353
+ padding: var(--a-space-0-5) var(--timeline-item-toggle-px);
354
354
  margin: 0;
355
355
  cursor: pointer;
356
- color: inherit;
356
+ color: var(--timeline-item-label-fg-muted, var(--a-fg-muted));
357
+ border-radius: var(--a-radius-sm);
357
358
  display: inline-flex;
358
359
  align-items: center;
359
360
  justify-content: center;
360
361
  line-height: 0;
362
+ transition: background var(--timeline-item-duration) var(--timeline-item-easing),
363
+ color var(--timeline-item-duration) var(--timeline-item-easing);
364
+ }
365
+
366
+ :scope > [data-timeline-toggle]:hover {
367
+ background: var(--a-bg-subtle);
368
+ color: var(--timeline-item-label-fg, var(--a-fg));
361
369
  }
362
370
 
363
371
  /* Reserve room so the time isn't overlapped by the chevron */
@@ -368,7 +376,6 @@ agent-reasoning-ui timeline-ui:not([orientation="horizontal"]),
368
376
  :scope > [data-timeline-toggle]:focus-visible {
369
377
  outline: none;
370
378
  box-shadow: var(--a-focus-ring);
371
- border-radius: var(--a-radius-sm);
372
379
  }
373
380
  }
374
381
 
@@ -106,39 +106,60 @@ toast-ui[data-closing][position="top-left"] {
106
106
  /* Enter / exit animation rules ([data-open] / [data-closing]) moved
107
107
  outside @scope — see Safari 17.x bug note at top of file. */
108
108
 
109
- /* ── Variant: info (default) ── */
109
+ /* ── Variants ──
110
+ `--variant-muted` flips light↔dark with scheme, so the fg token
111
+ paired with it must do the same. Use `--a-{variant}-text` (the
112
+ light-dark scheme-aware token), NOT `--a-{variant}-text-strong`
113
+ (the latter is locked dark for warning + light for the others —
114
+ designed for solid `--variant-strong` fills, where the bg never
115
+ flips). Mirrors alert.css's variant wiring. */
110
116
 
111
117
  :where(:scope),
112
118
  :where(:scope[variant="info"]) {
113
- --toast-bg: var(--a-info-muted);
114
- --toast-fg: var(--a-info-text-strong);
119
+ --toast-bg: var(--a-info-muted);
120
+ --toast-fg: var(--a-info-text);
115
121
  --toast-border: var(--a-info-border-subtle);
116
122
  }
117
-
118
- /* ── Variant: success ── */
119
-
120
123
  :where(:scope[variant="success"]) {
121
- --toast-bg: var(--a-success-muted);
122
- --toast-fg: var(--a-success-text-strong);
124
+ --toast-bg: var(--a-success-muted);
125
+ --toast-fg: var(--a-success-text);
123
126
  --toast-border: var(--a-success-border-subtle);
124
127
  }
125
-
126
- /* ── Variant: warning ── */
127
-
128
128
  :where(:scope[variant="warning"]) {
129
- --toast-bg: var(--a-warning-muted);
130
- --toast-fg: var(--a-warning-text-strong);
129
+ --toast-bg: var(--a-warning-muted);
130
+ --toast-fg: var(--a-warning-text);
131
131
  --toast-border: var(--a-warning-border-subtle);
132
132
  }
133
-
134
- /* ── Variant: danger ── */
135
-
136
133
  :where(:scope[variant="danger"]) {
137
- --toast-bg: var(--a-danger-muted);
138
- --toast-fg: var(--a-danger-text-strong);
134
+ --toast-bg: var(--a-danger-muted);
135
+ --toast-fg: var(--a-danger-text);
139
136
  --toast-border: var(--a-danger-border-subtle);
140
137
  }
141
138
 
139
+ /* `primary` is the accent-filled variant — solid bg + on-accent text
140
+ for "branded" toasts (welcome banners, feature-launch nudges)
141
+ where the message should read as a brand stamp rather than tinted
142
+ status chrome. Tracks the same accent-strong source as button-ui's
143
+ primary; border picks up `--a-accent-border-subtle` since there's
144
+ no `--a-primary-border-subtle` token. */
145
+ :where(:scope[variant="primary"]) {
146
+ --toast-bg: var(--a-primary-bg);
147
+ --toast-fg: var(--a-primary-fg);
148
+ --toast-border: var(--a-accent-border-subtle);
149
+ }
150
+
151
+ /* `muted` and `neutral` are semantic aliases of the base canvas
152
+ surface — same tokens as the unstyled `:scope`, declared explicitly
153
+ so the yaml enum and the CSS contract agree. Use cases: quiet
154
+ status nudges that shouldn't carry tonal weight (e.g. "Draft
155
+ saved" without success-green). Mirrors alert-ui's pattern. */
156
+ :where(:scope[variant="muted"]),
157
+ :where(:scope[variant="neutral"]) {
158
+ --toast-bg: var(--a-bg-subtle);
159
+ --toast-fg: var(--a-fg);
160
+ --toast-border: var(--a-border-subtle);
161
+ }
162
+
142
163
  /* ── Message slot ── */
143
164
 
144
165
  [slot="message"] {
@@ -146,25 +167,17 @@ toast-ui[data-closing][position="top-left"] {
146
167
  min-width: 0;
147
168
  }
148
169
 
149
- /* ── Close button ── */
150
-
170
+ /* ── Close button ──
171
+ A real <button-ui icon="x" variant="ghost" size="sm"> stamped by
172
+ toast.js — brings the system focus ring + hover transition + a
173
+ properly-sized icon-ui glyph. The `align-self: start` keeps the
174
+ button on the first line of multi-line toast bodies, matching the
175
+ alert-ui pattern. */
151
176
  [slot="close"] {
152
- background: none;
153
- border: none;
154
- font-size: 0.875rem;
155
- color: currentColor;
156
- cursor: pointer;
157
- padding: 0;
158
- line-height: 1;
159
- opacity: 0.6;
160
177
  flex-shrink: 0;
161
- align-self: flex-start;
162
- margin-top: 0.125rem;
163
- transition: opacity var(--toast-close-duration) var(--toast-close-easing);
178
+ align-self: start;
179
+ color: currentColor;
164
180
  }
165
- [slot="close"]::after { content: '\00d7'; }
166
- [slot="close"]:hover { opacity: 1; }
167
- [slot="close"]:focus-visible { box-shadow: var(--toast-focus-ring); border-radius: var(--toast-focus-radius); }
168
181
 
169
182
  /* Inside a container: drop fixed positioning */
170
183
  [data-toast-container] > :scope {
@@ -176,37 +189,69 @@ toast-ui[data-closing][position="top-left"] {
176
189
  }
177
190
  }
178
191
 
179
- /* ── Top-layer: cannot inherit component tokens ── */
180
- /* ── Toast container stacks toasts vertically ── */
192
+ /* ── Toast lane container ──
193
+ Per-position singleton, attached to `document.body`. Promoted to the
194
+ browser's top-layer via the Popover API — `[popover="manual"]` on the
195
+ element + `showPopover()` on first post. The native popover stack
196
+ means multiple lanes (e.g. top-right + bottom-center) coexist
197
+ without z-index collisions, and the lane sits above ALL page chrome
198
+ (modals included, since both ride the top-layer in document order).
199
+
200
+ Reset the UA `[popover]` defaults (centred, white panel, border).
201
+ The lane is a transparent flex column anchored to a viewport corner;
202
+ the toast cards inside paint themselves. `pointer-events: none` on
203
+ the lane + `auto` on each toast lets clicks pass through empty
204
+ space between cards. */
181
205
  [data-toast-container] {
182
206
  position: fixed;
183
- z-index: 9999;
207
+ inset: unset;
208
+ margin: 0;
209
+ padding: 0;
210
+ border: none;
211
+ background: transparent;
212
+ overflow: visible;
213
+ width: max-content;
214
+ max-width: 100vw;
215
+ z-index: 9999; /* fallback for envs without Popover API */
184
216
  display: flex;
185
- flex-direction: column;
186
217
  gap: var(--a-space-2);
187
218
  pointer-events: none;
188
219
  }
189
-
190
- [data-toast-container="bottom-right"] {
191
- bottom: var(--a-space-4);
192
- right: var(--a-space-4);
193
- align-items: flex-end;
220
+ [data-toast-container]:popover-open {
221
+ /* Native top-layer; component-level z-index above is moot here but kept
222
+ for fallback. */
223
+ display: flex;
194
224
  }
195
225
 
226
+ /* Top lanes — newest at top, columns flow downward. */
227
+ [data-toast-container="top-right"],
228
+ [data-toast-container="top-center"],
229
+ [data-toast-container="top-left"] {
230
+ top: var(--a-space-4);
231
+ flex-direction: column;
232
+ }
233
+ /* Bottom lanes — newest at bottom, columns reverse so post() lands at
234
+ the visually-newest position. */
235
+ [data-toast-container="bottom-right"],
236
+ [data-toast-container="bottom-center"],
196
237
  [data-toast-container="bottom-left"] {
197
238
  bottom: var(--a-space-4);
198
- left: var(--a-space-4);
199
- align-items: flex-start;
239
+ flex-direction: column-reverse;
200
240
  }
201
241
 
242
+ [data-toast-container="bottom-right"],
202
243
  [data-toast-container="top-right"] {
203
- top: var(--a-space-4);
204
244
  right: var(--a-space-4);
205
245
  align-items: flex-end;
206
246
  }
207
-
247
+ [data-toast-container="bottom-left"],
208
248
  [data-toast-container="top-left"] {
209
- top: var(--a-space-4);
210
249
  left: var(--a-space-4);
211
250
  align-items: flex-start;
212
251
  }
252
+ [data-toast-container="top-center"],
253
+ [data-toast-container="bottom-center"] {
254
+ left: 50%;
255
+ transform: translateX(-50%);
256
+ align-items: center;
257
+ }