@adia-ai/web-components 0.7.6 → 0.7.8

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.7.8] — 2026-06-02
4
+
5
+ ### Fixed
6
+
7
+ - **Wrapper-trap drain — 8 components no longer mis-handle `.map()`/interpolated slotted children.** A new shared `core/logical-children.js` (`logicalChildren` / `logicalSlotted`) pierces the template engine's `display:contents` / `role="presentation"` wrapper spans, so consumer slot content supplied via interpolation (`${items.map(…)}` / conditionals) is seen by a component's slot reads — not just static literals. Migrated 14 `:scope > [slot]` direct-child reads that previously missed wrapped content: **`combobox-ui`** (prefix / suffix / empty / loading / footer) + **`input-ui`** (leading / trailing) grabbed affordances then ran `innerHTML = ''`, wiping the wrapped ones (the `agent-artifact-ui` data-loss class); **`nav-group-ui`** (header), **`option-card-ui`** (heading / description / icon), **`timeline-item-ui`** (label), **`step-progress-ui`** (caption / track), and **`date-range-picker-ui`** (presets / footer) used `if (!query) stamp()` guards that false-negatived on a wrapped slot and stamped a DUPLICATE over the consumer's content. Companion field-reads were pierced in lockstep so they don't null-ref. `tree-item-ui`'s `[slot="row"]` is left as-is (a single static / stamped container, not an interpolated collection). New `core/logical-children.test.js` + per-component regression tests; consolidates the prior hand-rolled walks (`select-ui` `#logicalOptionChildren`, `tree-ui` `#logicalSlotChildren`). Files: `core/logical-children.js` + 8 component class files.
8
+
9
+ ### Changed
10
+ - **CDN bundles rebuilt** — `dist/web-components.min.js` regenerated so the wrapper-trap drain (the new `core/logical-children.js` helper + the 14 pierced component reads) reaches `@adia-ai/web-components@0.7` CDN consumers.
11
+
12
+ ## [0.7.7] — 2026-06-02
13
+
14
+ ### Fixed
15
+
16
+ - **`[verse]` register now scales `--a-toggle-size` (check / radio / switch) down with its type + controls.** Companion to the 0.7.6 `[verse]` icon/caret fix (`91460aae2`). `--a-toggle-size` (the check / radio / switch control size) is declared at `:root` (`styles/foundation/size.css` = `--a-size-sm − --a-space-1` = 20px @md) and shifted by the global `[size]` rules (`styles/api/sizing.css` = 16/24 at sm/lg), but was **absent from `styles/verse.css`** — so in a verse context check / radio / switch chrome stayed the `:root`-computed 20px against verse's smaller (18/20/24) controls and 11/12/13px type. Added `--a-toggle-size` to all three verse sites (base `[verse]` + `[verse][size="lg"]` + `[verse][size="sm"]`) on clean `--a-space` rungs, one tier down: verse toggle is now **12 / 16 / 20px** at sm/md/lg (was a flat 20px); the regular register is unchanged (16/20/24). The explicit `[size]` tier rules are **required** — verse's `context` layer shadows the lower-layer global `[size]` `--a-toggle-size` rules, the same reason `--a-size` and the icon/caret tokens need them. `--a-space` rungs (not literal px like icon/caret) because the toggle targets land exactly on rungs, keeping the values density-aware and consistent with verse's inset/gap. Verified by `scripts/qa/register-scale-probe.mjs` (extended with a toggle axis): verse 12/16/20, regular 16/20/24, all other registers/axes unchanged. Showcased on the `check` / `radio` / `switch` demos (a "Typography registers" section, mirroring the card / button / text / field / badge / stat pages). File: `styles/verse.css`.
17
+ - **`<agent-artifact-ui>` no longer strands `.map()`-rendered action buttons in the body.** `#build()` grabbed the author's `slot="primary"` / `slot="secondary"` buttons with a `:scope > [slot]` direct-child query, then `this.innerHTML = ''`. Buttons supplied via interpolation (`${actions.map(…)}`) arrive nested in the template engine's `display:contents` / `role="presentation"` wrapper spans, so the direct-child grab missed them and the wipe left them rendered in the artifact *body* instead of the header actions cluster. `#build()` now partitions children with a wrapper-piercing logical walk (the FB-92/96/98 pattern, mirroring `select-ui` `#logicalOptionChildren`), so interpolated AND static action buttons both reach the actions cluster. +2 regression tests. Surfaced by `audit-wrapper-trap` arm 2. File: `components/agent-artifact/agent-artifact.class.js`.
18
+
19
+ ### Changed
20
+
21
+ - **`<drawer-ui>` body inset is now register / size-aware (`--drawer-inset` → `var(--a-inset)`).** `--drawer-inset` hardcoded `var(--a-space-4)` (a literal 16px rung); it now reads `var(--a-inset)`, matching its sibling `card-ui` (`--card-inset: var(--a-inset)`). **Behavior-neutral at the default tier** (`--a-inset-md` == `--a-space-4` == 16px); the change is that the drawer body / header / footer inset (header & footer padding derive from `--drawer-inset`) now scales with `[size]` (sm 14 / lg 18), `[verse]` (12), and `[prose]` (40). drawer's `[padding]` / `[bleed]` are layout *modes* (canvas-bg / no-pad), not a value scale — unchanged. Part of the 0.7.7 inset-alignment audit, which left `page` / `canvas` / `table` filter / `richtext` `pre` as intentional fixed rungs (their literals aren't 16px, so a swap would change behavior). Files: `components/drawer/drawer.css`, `components/drawer/drawer.examples.html`.
22
+ - **CDN bundle rebuilt** — `dist/web-components.min.css` regenerated so the `[verse]` `--a-toggle-size` fix + the drawer inset reach `@adia-ai/web-components@0.7` CDN consumers.
23
+
3
24
  ## [0.7.6] — 2026-06-02
4
25
 
5
26
  ### Added
@@ -139,19 +139,35 @@ export class UIAgentArtifact extends UIElement {
139
139
  // To keep the Light-DOM approach consistent, we wrap existing children
140
140
  // into a body container if one isn't already present.
141
141
 
142
- // Capture any existing non-slotted children to put into the body wrapper
143
- const existingBodyNodes = Array.from(this.childNodes).filter((n) => {
144
- if (n.nodeType === 1) {
145
- const slot = /** @type {Element} */ (n).getAttribute?.('slot') || '';
146
- return slot !== 'primary' && slot !== 'secondary';
142
+ // Partition the author's children by LOGICAL slot, piercing the template
143
+ // engine's display:contents / role="presentation" wrapper spans. A consumer
144
+ // that interpolates the action buttons — `${actions.map(a => html`<button-ui
145
+ // slot="primary">…`)}` gets them nested in wrapper spans (core/template.js
146
+ // wrap()), so the old `:scope > [slot="primary"]` direct-child grab returned
147
+ // nothing and `innerHTML = ''` stranded them in the body. This is the
148
+ // wrapper-trap class (FB-92/96/98); walk + pierce so wrapped action buttons
149
+ // reach the header actions cluster. Wrappers are flattened (display:contents
150
+ // is layout-transparent); everything non-action becomes body.
151
+ const primaryBtns = [];
152
+ const secondaryBtns = [];
153
+ const bodyNodes = [];
154
+ const isWrapper = (el) =>
155
+ el.getAttribute('role') === 'presentation' || el.style?.display === 'contents';
156
+ const partition = (nodes) => {
157
+ for (const n of nodes) {
158
+ if (n.nodeType === 1) {
159
+ const el = /** @type {Element} */ (n);
160
+ if (isWrapper(el)) { partition(el.childNodes); continue; } // pierce; drop the wrapper
161
+ const slot = el.getAttribute('slot') || '';
162
+ if (slot === 'primary') { primaryBtns.push(el); continue; }
163
+ if (slot === 'secondary') { secondaryBtns.push(el); continue; }
164
+ }
165
+ bodyNodes.push(n);
147
166
  }
148
- return true;
149
- });
150
-
151
- // Clear — we'll rebuild, preserving slotted action buttons
152
- const primaryBtns = this.querySelectorAll(':scope > [slot="primary"]');
153
- const secondaryBtns = this.querySelectorAll(':scope > [slot="secondary"]');
167
+ };
168
+ partition(Array.from(this.childNodes));
154
169
 
170
+ // Clear — we'll rebuild around the captured (now-detached) children.
155
171
  this.innerHTML = '';
156
172
 
157
173
  // Header — keyboard-focusable button-style row that toggles collapsed.
@@ -197,7 +213,7 @@ export class UIAgentArtifact extends UIElement {
197
213
  // Body
198
214
  this.#bodyEl = document.createElement('div');
199
215
  this.#bodyEl.setAttribute('data-artifact-body', '');
200
- for (const n of existingBodyNodes) this.#bodyEl.appendChild(n);
216
+ for (const n of bodyNodes) this.#bodyEl.appendChild(n);
201
217
  if (this.collapsed) this.#bodyEl.hidden = true;
202
218
 
203
219
  this.append(this.#headerEl, this.#bodyEl);
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import '../../core/element.js';
3
+ import './agent-artifact.js';
4
+
5
+ const tick = () => new Promise((r) => queueMicrotask(r));
6
+
7
+ function mount(html) {
8
+ const wrap = document.createElement('div');
9
+ wrap.innerHTML = html;
10
+ document.body.appendChild(wrap);
11
+ return wrap.firstElementChild;
12
+ }
13
+
14
+ describe('agent-artifact-ui', () => {
15
+ beforeEach(() => { document.body.innerHTML = ''; });
16
+
17
+ // Baseline — static slot="primary"/"secondary" buttons route into the header
18
+ // actions cluster, and the default-slot content lands in the body.
19
+ it('routes static action buttons into the header actions cluster (not the body)', async () => {
20
+ const a = mount(`
21
+ <agent-artifact-ui title="Build">
22
+ <button-ui slot="primary">Run</button-ui>
23
+ <button-ui slot="secondary">Cancel</button-ui>
24
+ <p>Body content</p>
25
+ </agent-artifact-ui>
26
+ `);
27
+ await tick();
28
+ const actions = a.querySelector('[data-artifact-actions]');
29
+ const body = a.querySelector('[data-artifact-body]');
30
+ expect(actions).toBeTruthy();
31
+ expect(actions.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(2);
32
+ expect(body.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(0);
33
+ expect(body.textContent).toContain('Body content');
34
+ });
35
+
36
+ // Wrapper-trap regression (audit-wrapper-trap arm 2): action buttons rendered
37
+ // via `.map()`/`repeat()` arrive nested in the template engine's
38
+ // display:contents / role="presentation" wrapper spans. The old `:scope >
39
+ // [slot="primary"]` direct-child grab missed them, so `innerHTML = ''`
40
+ // stranded them in the body. They must reach the actions cluster.
41
+ it('routes WRAPPED (interpolated) action buttons into the actions cluster, not the body', async () => {
42
+ const a = mount(`
43
+ <agent-artifact-ui title="Build">
44
+ <span style="display:contents" role="presentation">
45
+ <span style="display:contents" role="presentation"><button-ui slot="primary">Run</button-ui></span>
46
+ <span style="display:contents" role="presentation"><button-ui slot="secondary">Cancel</button-ui></span>
47
+ </span>
48
+ <p>Body content</p>
49
+ </agent-artifact-ui>
50
+ `);
51
+ await tick();
52
+ const actions = a.querySelector('[data-artifact-actions]');
53
+ const body = a.querySelector('[data-artifact-body]');
54
+ expect(actions.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(2);
55
+ // The regression: before the wrapper-pierce fix these landed in the body.
56
+ expect(body.querySelectorAll('[slot="primary"], [slot="secondary"]').length).toBe(0);
57
+ expect(body.textContent).toContain('Body content');
58
+ // No empty wrapper spans left dangling — they're flattened, not re-appended.
59
+ expect(a.querySelectorAll('[role="presentation"]').length).toBe(0);
60
+ });
61
+ });
@@ -40,6 +40,7 @@
40
40
  import { UIFormElement } from '../../core/form.js';
41
41
  import { anchorPopover } from '../../core/anchor.js';
42
42
  import { untracked } from '../../core/signals.js';
43
+ import { logicalSlotted } from '../../core/logical-children.js';
43
44
 
44
45
  function escapeHTML(s) {
45
46
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -213,11 +214,14 @@ export class UICombobox extends UIFormElement {
213
214
  const inputId = `${this.#instanceId}-input`;
214
215
 
215
216
  // Capture consumer-supplied slotted children before innerHTML wipes them.
216
- const prefixNodes = Array.from(this.querySelectorAll(':scope > [slot="prefix"]'));
217
- const suffixNodes = Array.from(this.querySelectorAll(':scope > [slot="suffix"]'));
218
- const emptyNodes = Array.from(this.querySelectorAll(':scope > [slot="empty"]'));
219
- const loadingNodes = Array.from(this.querySelectorAll(':scope > [slot="loading"]'));
220
- const footerNodes = Array.from(this.querySelectorAll(':scope > [slot="footer"]'));
217
+ // logicalSlotted pierces the template engine's display:contents wrapper spans,
218
+ // so `.map()`/`repeat()`-interpolated affordances aren't missed then wiped
219
+ // (the wrapper trap audit-wrapper-trap arm 2).
220
+ const prefixNodes = logicalSlotted(this, 'prefix');
221
+ const suffixNodes = logicalSlotted(this, 'suffix');
222
+ const emptyNodes = logicalSlotted(this, 'empty');
223
+ const loadingNodes = logicalSlotted(this, 'loading');
224
+ const footerNodes = logicalSlotted(this, 'footer');
221
225
 
222
226
  this.innerHTML = `
223
227
  <span data-label id="${labelId}"${this.label ? '' : ' style="display:none"'}>${escapeHTML(this.label || '')}</span>
@@ -28,6 +28,23 @@ describe('combobox-ui', () => {
28
28
  expect(el.options[0].label).toBe('Alpha');
29
29
  });
30
30
 
31
+ // Wrapper-trap regression (audit-wrapper-trap arm 2): a `.map()`-interpolated
32
+ // affordance (here [slot="footer"]) arrives nested in a display:contents
33
+ // wrapper span. The old `:scope > [slot]` grab missed it → innerHTML='' wiped
34
+ // it. logicalSlotted pierces the wrapper so the footer survives the rebuild.
35
+ it('preserves a WRAPPED (interpolated) [slot="footer"] across the rebuild (wrapper trap)', async () => {
36
+ const el = mount(`
37
+ <combobox-ui>
38
+ <option value="a">Alpha</option>
39
+ <span style="display:contents" role="presentation"><div slot="footer" data-test="ft">Footer</div></span>
40
+ </combobox-ui>
41
+ `);
42
+ await tick(); await tick();
43
+ const footer = el.querySelector('[slot="footer"]');
44
+ expect(footer).not.toBeNull();
45
+ expect(footer.dataset.test).toBe('ft');
46
+ });
47
+
31
48
  it('honours <option selected> as initial value', async () => {
32
49
  const el = mount(`
33
50
  <combobox-ui>
@@ -40,6 +40,7 @@
40
40
  import { UIFormElement } from '../../core/form.js';
41
41
  import { anchorPopover } from '../../core/anchor.js';
42
42
  import { untracked } from '../../core/signals.js';
43
+ import { logicalSlotted } from '../../core/logical-children.js';
43
44
 
44
45
  const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
45
46
  const MONTHS_LONG = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
@@ -392,7 +393,7 @@ export class UIDateRangePicker extends UIFormElement {
392
393
  this.#popoverRef.appendChild(calArea);
393
394
  }
394
395
  // Preset rail (unless author supplied [slot="presets"] OR no-presets is set).
395
- const authorPresets = this.querySelector(':scope > [slot="presets"]');
396
+ const authorPresets = logicalSlotted(this, 'presets')[0];
396
397
  if (authorPresets && authorPresets.parentElement !== this.#popoverRef) {
397
398
  this.#popoverRef.appendChild(authorPresets);
398
399
  this.#presetRailRef = authorPresets;
@@ -425,7 +426,7 @@ export class UIDateRangePicker extends UIFormElement {
425
426
  this.#calFromRef = calArea.querySelector(':scope > [data-cal-from]');
426
427
  this.#calToRef = calArea.querySelector(':scope > [data-cal-to]');
427
428
  // Footer slot — author-supplied; lift into popover after calendar area.
428
- const authorFooter = this.querySelector(':scope > [slot="footer"]');
429
+ const authorFooter = logicalSlotted(this, 'footer')[0];
429
430
  if (authorFooter && authorFooter.parentElement !== this.#popoverRef) {
430
431
  this.#popoverRef.appendChild(authorFooter);
431
432
  }
@@ -477,7 +478,7 @@ export class UIDateRangePicker extends UIFormElement {
477
478
 
478
479
  // Render the preset rail (unless overridden by [slot="presets"] or
479
480
  // hidden via [no-presets]).
480
- if (!this.querySelector(':scope > [slot="presets"]')) {
481
+ if (!logicalSlotted(this, 'presets').length) {
481
482
  if (this.noPresets) {
482
483
  this.#presetRailRef?.remove();
483
484
  this.#presetRailRef = null;
@@ -19,7 +19,7 @@
19
19
  /* ── Geometry ── */
20
20
  --drawer-width: 24rem;
21
21
  --drawer-height: 24rem;
22
- --drawer-inset: var(--a-space-4);
22
+ --drawer-inset: var(--a-inset);
23
23
  --drawer-header-pad: var(--drawer-inset);
24
24
  --drawer-footer-pad: var(--drawer-inset);
25
25
  --drawer-header-gap: var(--a-space-2);
@@ -52,6 +52,7 @@
52
52
 
53
53
  import { UIFormElement } from '../../core/form.js';
54
54
  import { isIconName, whenIconRegistryReady } from '../../core/icons.js';
55
+ import { logicalSlotted } from '../../core/logical-children.js';
55
56
 
56
57
  const renderAffix = (v) => isIconName(v)
57
58
  ? `<icon-ui name="${v}"></icon-ui>`
@@ -133,11 +134,13 @@ export class UIInput extends UIFormElement {
133
134
  // §199 (v0.5.7): consumer-supplied leading/trailing buttons live as
134
135
  // direct children of <input-ui> at author time. Capture references
135
136
  // BEFORE innerHTML wipes them, so we can move them into [slot="field"]
136
- // after the shell is built. Querying `:scope > [slot="leading|trailing"]`
137
- // (direct-child) avoids matching anything we already moved on a prior
138
- // re-render.
139
- const leadingNodes = Array.from(this.querySelectorAll(':scope > [slot="leading"]'));
140
- const trailingNodes = Array.from(this.querySelectorAll(':scope > [slot="trailing"]'));
137
+ // after the shell is built. logicalSlotted pierces the template engine's
138
+ // display:contents wrapper spans (so `.map()`-interpolated affordances
139
+ // aren't missed then wiped — the wrapper trap), and still won't re-match
140
+ // nodes already moved into the field (those nest in a real element, not a
141
+ // transparent wrapper).
142
+ const leadingNodes = logicalSlotted(this, 'leading');
143
+ const trailingNodes = logicalSlotted(this, 'trailing');
141
144
 
142
145
  if (!this.querySelector('[slot="text"]')) {
143
146
  const labelId = this.label ? `input-label-${++UIInput.#labelSeq}` : '';
@@ -68,6 +68,22 @@ describe('input-ui — §199 leading/trailing affordance slots', () => {
68
68
  expect(el.querySelector(':scope > [slot="leading"]')).toBeNull();
69
69
  });
70
70
 
71
+ // Wrapper-trap regression (audit-wrapper-trap arm 2): a `.map()`-interpolated
72
+ // affordance arrives nested in a display:contents/role=presentation wrapper
73
+ // span. The old `:scope > [slot]` direct-child grab missed it, so innerHTML=''
74
+ // wiped it. logicalSlotted pierces the wrapper → it reaches the field.
75
+ it('moves a WRAPPED (interpolated) [slot="trailing"] into the field (wrapper trap)', () => {
76
+ const el = mount(`
77
+ <input-ui value="Theme 1" suffix="Light">
78
+ <span style="display:contents" role="presentation"><button slot="trailing" data-test="open" aria-label="Open">↗</button></span>
79
+ </input-ui>
80
+ `);
81
+ const field = el.querySelector(':scope > [slot="field"]');
82
+ const trailing = field.querySelector(':scope > [slot="trailing"]');
83
+ expect(trailing).not.toBeNull();
84
+ expect(trailing.dataset.test).toBe('open');
85
+ });
86
+
71
87
  it('positions trailing after suffix and before controls (number mode)', () => {
72
88
  const el = mount(`
73
89
  <input-ui type="number" value="42" suffix="kg">
@@ -27,6 +27,7 @@
27
27
 
28
28
  import { UIElement } from '../../core/element.js';
29
29
  import { anchorPopover } from '../../core/anchor.js';
30
+ import { logicalSlotted } from '../../core/logical-children.js';
30
31
 
31
32
  export class UINavGroup extends UIElement {
32
33
  static properties = {
@@ -59,7 +60,7 @@ export class UINavGroup extends UIElement {
59
60
  connected() {
60
61
  this.setAttribute('role', 'group');
61
62
 
62
- if (!this.querySelector(':scope > [slot="header"]')) {
63
+ if (!logicalSlotted(this, 'header').length) {
63
64
  this.#headerEl = document.createElement('div');
64
65
  this.#headerEl.setAttribute('slot', 'header');
65
66
  this.#headerEl.setAttribute('tabindex', '0');
@@ -71,7 +72,7 @@ export class UINavGroup extends UIElement {
71
72
  `;
72
73
  this.prepend(this.#headerEl);
73
74
  } else {
74
- this.#headerEl = this.querySelector(':scope > [slot="header"]');
75
+ this.#headerEl = logicalSlotted(this, 'header')[0];
75
76
  }
76
77
 
77
78
  this.#headerEl?.addEventListener('keydown', this.#onHeaderKey);
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import '../../core/element.js';
3
+ import './nav-group.js';
4
+
5
+ const tick = () => new Promise((r) => queueMicrotask(r));
6
+ function mount(html) {
7
+ const w = document.createElement('div');
8
+ w.innerHTML = html;
9
+ document.body.appendChild(w);
10
+ return w.firstElementChild;
11
+ }
12
+
13
+ describe('nav-group-ui — header stamp-if-absent (wrapper trap)', () => {
14
+ beforeEach(() => { document.body.innerHTML = ''; });
15
+
16
+ it('stamps a header when the consumer provides none', async () => {
17
+ const el = mount('<nav-group-ui text="Reports"></nav-group-ui>');
18
+ await tick();
19
+ expect(el.querySelectorAll('[slot="header"]').length).toBe(1);
20
+ });
21
+
22
+ // Regression (audit-wrapper-trap arm 2): a `.map()`/conditional-interpolated
23
+ // [slot="header"] arrives nested in a display:contents wrapper. The old
24
+ // `:scope > [slot="header"]` absence check missed it and stamped a DUPLICATE.
25
+ it('does NOT stamp a duplicate when a WRAPPED consumer header is present', async () => {
26
+ const el = mount(`<nav-group-ui text="Reports"><span style="display:contents" role="presentation"><div slot="header" data-test="h">Custom</div></span></nav-group-ui>`);
27
+ await tick();
28
+ const headers = el.querySelectorAll('[slot="header"]');
29
+ expect(headers.length).toBe(1);
30
+ expect(headers[0].dataset.test).toBe('h');
31
+ });
32
+ });
@@ -45,6 +45,7 @@
45
45
  */
46
46
 
47
47
  import { UIFormElement } from '../../core/form.js';
48
+ import { logicalSlotted } from '../../core/logical-children.js';
48
49
 
49
50
  export class UIOptionCard extends UIFormElement {
50
51
  static properties = {
@@ -97,21 +98,21 @@ export class UIOptionCard extends UIFormElement {
97
98
  /** Stamp heading / description / icon from attributes when the
98
99
  * consumer hasn't already provided slotted content. */
99
100
  #ensureLayout() {
100
- if (this.heading && !this.querySelector(':scope > [slot="heading"]')) {
101
+ if (this.heading && !logicalSlotted(this, 'heading').length) {
101
102
  const el = document.createElement('span');
102
103
  el.setAttribute('slot', 'heading');
103
104
  el.dataset.fromAttr = 'true';
104
105
  el.textContent = this.heading;
105
106
  this.appendChild(el);
106
107
  }
107
- if (this.description && !this.querySelector(':scope > [slot="description"]')) {
108
+ if (this.description && !logicalSlotted(this, 'description').length) {
108
109
  const el = document.createElement('span');
109
110
  el.setAttribute('slot', 'description');
110
111
  el.dataset.fromAttr = 'true';
111
112
  el.textContent = this.description;
112
113
  this.appendChild(el);
113
114
  }
114
- if (this.icon && !this.querySelector(':scope > [slot="icon"]')) {
115
+ if (this.icon && !logicalSlotted(this, 'icon').length) {
115
116
  const el = document.createElement('icon-ui');
116
117
  el.setAttribute('slot', 'icon');
117
118
  el.dataset.fromAttr = 'true';
@@ -0,0 +1,32 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import '../../core/element.js';
3
+ import './option-card.js';
4
+
5
+ const tick = () => new Promise((r) => queueMicrotask(r));
6
+ function mount(html) {
7
+ const w = document.createElement('div');
8
+ w.innerHTML = html;
9
+ document.body.appendChild(w);
10
+ return w.firstElementChild;
11
+ }
12
+
13
+ describe('option-card-ui — heading/description stamp-if-absent (wrapper trap)', () => {
14
+ beforeEach(() => { document.body.innerHTML = ''; });
15
+
16
+ it('stamps heading + description from attrs when no slots are provided', async () => {
17
+ const el = mount('<option-card-ui heading="Pro" description="Best value"></option-card-ui>');
18
+ await tick();
19
+ expect(el.querySelectorAll('[slot="heading"]').length).toBe(1);
20
+ expect(el.querySelectorAll('[slot="description"]').length).toBe(1);
21
+ });
22
+
23
+ // Regression (audit-wrapper-trap arm 2): a WRAPPED consumer [slot="heading"]
24
+ // override must suppress the attr-stamp, not collide with it.
25
+ it('does NOT stamp a duplicate heading when a WRAPPED consumer heading is present', async () => {
26
+ const el = mount(`<option-card-ui heading="Pro"><span style="display:contents" role="presentation"><h3 slot="heading" data-test="h">Custom</h3></span></option-card-ui>`);
27
+ await tick();
28
+ const headings = el.querySelectorAll('[slot="heading"]');
29
+ expect(headings.length).toBe(1);
30
+ expect(headings[0].dataset.test).toBe('h');
31
+ });
32
+ });
@@ -30,6 +30,7 @@
30
30
  */
31
31
 
32
32
  import { UIElement } from '../../core/element.js';
33
+ import { logicalSlotted } from '../../core/logical-children.js';
33
34
 
34
35
  export class UIStepProgress extends UIElement {
35
36
  static properties = {
@@ -48,7 +49,7 @@ export class UIStepProgress extends UIElement {
48
49
  this.setAttribute('aria-valuemin', '0');
49
50
 
50
51
  // Auto-mint internal structure if the consumer didn't provide custom slots.
51
- if (!this.querySelector(':scope > [slot="caption"]') && !this.querySelector(':scope > [slot="track"]')) {
52
+ if (!logicalSlotted(this, 'caption').length && !logicalSlotted(this, 'track').length) {
52
53
  this.#captionEl = document.createElement('span');
53
54
  this.#captionEl.setAttribute('slot', 'caption');
54
55
  this.appendChild(this.#captionEl);
@@ -57,8 +58,8 @@ export class UIStepProgress extends UIElement {
57
58
  this.#trackEl.setAttribute('slot', 'track');
58
59
  this.appendChild(this.#trackEl);
59
60
  } else {
60
- this.#captionEl = this.querySelector(':scope > [slot="caption"]');
61
- this.#trackEl = this.querySelector(':scope > [slot="track"]');
61
+ this.#captionEl = logicalSlotted(this, 'caption')[0];
62
+ this.#trackEl = logicalSlotted(this, 'track')[0];
62
63
  }
63
64
  }
64
65
 
@@ -115,4 +115,15 @@ describe('step-progress-ui', () => {
115
115
  const el = mount('<step-progress-ui value="3" total="10"></step-progress-ui>');
116
116
  expect(() => el.remove()).not.toThrow();
117
117
  });
118
+
119
+ // Regression (audit-wrapper-trap arm 2): WRAPPED consumer [slot="caption"] +
120
+ // [slot="track"] (e.g. via `.map()`/conditional interpolation) must be seen by
121
+ // the absence check so it doesn't MINT duplicates over the consumer's slots.
122
+ it('does NOT mint duplicates when WRAPPED consumer caption + track are present', async () => {
123
+ const el = mount(`<step-progress-ui value="2" total="5"><span style="display:contents" role="presentation"><span slot="caption" data-test="c">Custom</span><div slot="track" data-test="t"></div></span></step-progress-ui>`);
124
+ await tick();
125
+ expect(el.querySelectorAll('[slot="caption"]').length).toBe(1);
126
+ expect(el.querySelectorAll('[slot="track"]').length).toBe(1);
127
+ expect(el.querySelector('[slot="caption"]').dataset.test).toBe('c');
128
+ });
118
129
  });
@@ -56,6 +56,7 @@
56
56
  */
57
57
 
58
58
  import { UIElement } from '../../core/element.js';
59
+ import { logicalSlotted } from '../../core/logical-children.js';
59
60
 
60
61
  // ── Container ──────────────────────────────────────────────────
61
62
 
@@ -104,7 +105,7 @@ export class UITimelineItem extends UIElement {
104
105
  }
105
106
 
106
107
  connected() {
107
- if (!this.querySelector(':scope > [slot="label"]')) {
108
+ if (!logicalSlotted(this, 'label').length) {
108
109
  // Author provided just text/time — stamp the default slot structure
109
110
  this.innerHTML = `
110
111
  <span slot="label"></span>
@@ -0,0 +1,31 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import '../../core/element.js';
3
+ import './timeline.js';
4
+
5
+ const tick = () => new Promise((r) => queueMicrotask(r));
6
+ function mount(html) {
7
+ const w = document.createElement('div');
8
+ w.innerHTML = html;
9
+ document.body.appendChild(w);
10
+ return w.firstElementChild;
11
+ }
12
+
13
+ describe('timeline-item-ui — label stamp-if-absent (wrapper trap)', () => {
14
+ beforeEach(() => { document.body.innerHTML = ''; });
15
+
16
+ it('stamps the label structure when the consumer provides none', async () => {
17
+ const el = mount('<timeline-item-ui text="Shipped"></timeline-item-ui>');
18
+ await tick();
19
+ expect(el.querySelectorAll('[slot="label"]').length).toBe(1);
20
+ });
21
+
22
+ // Regression (audit-wrapper-trap arm 2): a WRAPPED consumer [slot="label"]
23
+ // must suppress the default-structure stamp, not produce a duplicate label.
24
+ it('does NOT stamp a duplicate when a WRAPPED consumer label is present', async () => {
25
+ const el = mount(`<timeline-item-ui text="Shipped"><span style="display:contents" role="presentation"><span slot="label" data-test="l">Custom</span></span></timeline-item-ui>`);
26
+ await tick();
27
+ const labels = el.querySelectorAll('[slot="label"]');
28
+ expect(labels.length).toBe(1);
29
+ expect(labels[0].dataset.test).toBe('l');
30
+ });
31
+ });
@@ -297,6 +297,13 @@ export class UITreeItem extends UIElement {
297
297
  // required parent, and the row's required `group` parent is the host.
298
298
  this.setAttribute('role', 'group');
299
299
 
300
+ // [slot="row"] is a SINGLE per-item container — supplied statically or
301
+ // component-stamped, never a `.map()` collection. A full wrapper-pierce would
302
+ // have to migrate all 9 focus / keyboard-nav row reads in lockstep
303
+ // (`item.querySelector(':scope > [slot="row"]')`), or nav breaks for a wrapped
304
+ // row — high regression risk for a rare interpolated-single-row case (the
305
+ // common path, actions / caret, is already FB-96-handled below).
306
+ // wrapper-trap-ok: row is static / stamped, not an interpolated collection.
300
307
  if (!this.querySelector(':scope > [slot="row"]')) {
301
308
  this.#stamp();
302
309
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * core/logical-children.js — wrapper-piercing light-DOM child access.
3
+ *
4
+ * The template engine wraps EVERY interpolated value — a single result OR a
5
+ * `.map()` / `repeat()` array — in a `display:contents` / `role="presentation"`
6
+ * span (core/template.js `wrap()` + the Array branch). So a `:scope > [slot="X"]`
7
+ * direct-child query (or a `for (… of this.children)` loop) MISSES interpolated
8
+ * slotted children: they arrive as grand-children of a wrapper span, not direct
9
+ * children of the host. This is the recurring "wrapper trap" (FB-92 menu #show,
10
+ * FB-96 tree #stamp, FB-98 select #parseOptions, agent-artifact actions).
11
+ * `scripts/dev/audit-wrapper-trap.mjs` flags the un-pierced reads.
12
+ *
13
+ * `logicalChildren(host)` returns the host's LOGICAL direct children — its
14
+ * direct element children, but recursing THROUGH (and dropping) the transparent
15
+ * wrapper spans — so interpolated and static children read identically.
16
+ * `logicalSlotted(host, name)` filters those to `slot="<name>"`.
17
+ *
18
+ * Consolidates the per-component hand-rolled walks (select `#logicalOptionChildren`,
19
+ * tree `#logicalSlotChildren`) into one tested helper.
20
+ */
21
+
22
+ /** A transparent template-engine wrapper span (never authored by a consumer). */
23
+ const isWrapper = (el) =>
24
+ el.getAttribute?.('role') === 'presentation' || el.style?.display === 'contents';
25
+
26
+ /**
27
+ * The host's logical direct element children, piercing display:contents /
28
+ * role="presentation" wrapper spans (depth-first, wrappers flattened away).
29
+ * @param {Element} host
30
+ * @returns {Element[]}
31
+ */
32
+ export function logicalChildren(host) {
33
+ const out = [];
34
+ const walk = (nodes) => {
35
+ for (const n of nodes) {
36
+ if (n.nodeType !== 1) continue; // elements only (slotted children are elements)
37
+ if (isWrapper(n)) { walk(n.children); continue; } // pierce + drop the wrapper
38
+ out.push(n);
39
+ }
40
+ };
41
+ if (host) walk(host.children);
42
+ return out;
43
+ }
44
+
45
+ /**
46
+ * Logical direct children carrying `slot="<slotName>"`, wrapper-pierced.
47
+ * Use `.length` for a stamp-if-absent guard, `[0]` for the single case, or the
48
+ * array for a collection grab — all safe against interpolated content.
49
+ * @param {Element} host
50
+ * @param {string} slotName
51
+ * @returns {Element[]}
52
+ */
53
+ export function logicalSlotted(host, slotName) {
54
+ return logicalChildren(host).filter((el) => el.getAttribute('slot') === slotName);
55
+ }