@adia-ai/web-components 0.7.7 → 0.7.9

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,22 @@
1
1
  # Changelog — @adia-ai/web-components
2
2
 
3
+ ## [0.7.9] — 2026-06-03
4
+
5
+ ### Changed
6
+
7
+ - **`[verse]` control heights bumped one tier — `--a-size` 18/20/24 → 20/24/28px** (sm/md/lg @ density=1). Form-element / control heights in the compact `[verse]` register grow: `--a-size-sm` `1.125→1.250rem`, `--a-size-md` `1.25→1.500rem`, `--a-size-lg` `1.5→1.750rem` (still density-scaled rem). **Behavior change** — inputs / buttons / selects in a `[verse]` context render taller; the `[verse][size]` tier rules inherit the new values (they re-point `--a-size` to the sm/lg token). Verified by `scripts/qa/register-scale-probe.mjs` (verse 20/24/28; regular 24/30/36 + prose 28/36/44 unchanged). **Chrome follows:** `--a-toggle-size` 12/16/20 → 16/20/24, `--a-icon-size` 12/14/16 → 14/16/18, `--a-caret-size` 10/12/14 → 12/14/16 (each +1 tier) so check / radio / switch boxes + select carets + icons stay proportional to the larger controls. File: `styles/verse.css`.
8
+ - **Semantic color tier lift + status-compat removal (the `warning-yellow-cusp-lift`).** Per-family `-strong` tiers moved one step richer (`-50`/`-70` → `-60`) and the `-bg` aliases now read the base family token (`var(--a-accent)` etc.) instead of `-strong`, across accent / brand / info / success / warning (`colors/semantics/core.css` + `colors/parameters.css` + `colors/primitives-warning.css` — the warning yellow's cusp re-tuned for legibility). The legacy `colors/semantics/aliases.css` **STATUS COMPAT block** that overrode `--a-danger` / `--a-success` to `-strong` was **removed** — status families now resolve to their base tier deliberately (base tokens are defined upstream in `core.css`, so non-breaking); the `--a-scrim-*` role aliases in that file are **retained** (modal / drawer / tour / loading-overlay backdrops depend on them). **Behavior change** — danger / success / accent / brand / info surfaces shift tone. Gate-clean: no raw colors, `verify:contrast` + `check:foundation-layers` pass.
9
+ - **CDN bundles rebuilt** — `dist/host.min.css` + `dist/web-components.min.css` regenerated so the color cusp-lift + `[verse]` tier bump reach `@adia-ai/web-components@0.7` CDN consumers.
10
+
11
+ ## [0.7.8] — 2026-06-02
12
+
13
+ ### Fixed
14
+
15
+ - **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.
16
+
17
+ ### Changed
18
+ - **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.
19
+
3
20
  ## [0.7.7] — 2026-06-02
4
21
 
5
22
  ### Fixed
@@ -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;
@@ -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
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { logicalChildren, logicalSlotted } from './logical-children.js';
3
+
4
+ function host(html) {
5
+ const el = document.createElement('div');
6
+ el.innerHTML = html;
7
+ return el;
8
+ }
9
+
10
+ describe('logical-children', () => {
11
+ beforeEach(() => { document.body.innerHTML = ''; });
12
+
13
+ it('returns static direct children as-is', () => {
14
+ const h = host(`<button slot="primary">A</button><p>body</p><button slot="secondary">B</button>`);
15
+ expect(logicalChildren(h).map((e) => e.tagName)).toEqual(['BUTTON', 'P', 'BUTTON']);
16
+ expect(logicalSlotted(h, 'primary').length).toBe(1);
17
+ expect(logicalSlotted(h, 'secondary')[0].textContent).toBe('B');
18
+ expect(logicalSlotted(h, 'missing').length).toBe(0);
19
+ });
20
+
21
+ it('pierces a single display:contents/role=presentation wrapper', () => {
22
+ const h = host(`
23
+ <span style="display:contents" role="presentation"><button slot="primary">A</button></span>
24
+ <p>body</p>
25
+ `);
26
+ // Without piercing, h.querySelector(':scope > [slot="primary"]') would be null:
27
+ expect(h.querySelector(':scope > [slot="primary"]')).toBeNull();
28
+ // With the helper, it's found:
29
+ expect(logicalSlotted(h, 'primary').length).toBe(1);
30
+ expect(logicalChildren(h).map((e) => e.tagName)).toEqual(['BUTTON', 'P']);
31
+ });
32
+
33
+ it('pierces NESTED wrappers (the .map() shape)', () => {
34
+ const h = host(`
35
+ <span style="display:contents" role="presentation">
36
+ <span style="display:contents" role="presentation"><button slot="act">A</button></span>
37
+ <span style="display:contents" role="presentation"><button slot="act">B</button></span>
38
+ </span>
39
+ `);
40
+ expect(logicalSlotted(h, 'act').map((e) => e.textContent)).toEqual(['A', 'B']);
41
+ });
42
+
43
+ it('does NOT pierce non-wrapper elements (logical = direct, wrappers aside)', () => {
44
+ const h = host(`<div><button slot="primary">nested-in-real-div</button></div>`);
45
+ // The button is inside a real <div>, not a transparent wrapper → not a logical child.
46
+ expect(logicalSlotted(h, 'primary').length).toBe(0);
47
+ expect(logicalChildren(h).map((e) => e.tagName)).toEqual(['DIV']);
48
+ });
49
+
50
+ it('tolerates a null host', () => {
51
+ expect(logicalChildren(null)).toEqual([]);
52
+ expect(logicalSlotted(null, 'x')).toEqual([]);
53
+ });
54
+ });