@adia-ai/web-components 0.5.17 → 0.5.18

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
@@ -11,6 +11,11 @@ runtime ships in the sibling `@adia-ai/a2ui-runtime` package
11
11
 
12
12
  _No pending changes._
13
13
 
14
+ ## [0.5.18] - 2026-05-16
15
+
16
+ ### Fixed
17
+ - §341 (FB-43; P1) — `<swatch-ui>` chrome destroyed when interpolated via parent `html\`\`` template `${chromeFrag}`. `template.js`'s `scan()` replaces `<!--p:N-->` comments with empty text nodes inside the swatch element when the parent template is cloned; `wrap()` later mutates those text nodes in-place via `replaceWith()`. Pre-§341: the swatch's `#stamp()` ran `innerHTML = ''` between the parent's `mount()` and `update()` (synchronous custom-element connectedCallback timing), wiping the text-node placeholders. Parent's `replaceWith()` then ran on detached nodes — silent no-op — leaving wrapper-spans detached and chrome stamped into a subtree that never reached the live DOM. Symptom: chrome interpolated as `${chromeFrag}` was completely invisible (queryable from neither swatch nor host). Blocked Tokens Studio's C1.3 main-palette dogfood migration (~80 swatches inside `repeat()`). **Fix:** preserve empty text nodes + comment nodes (template-engine placeholders) AND display:contents wrapper-spans through `innerHTML = ''`. Detach them before the wipe, re-attach at the canonical chrome position (between tile + badge) after the canonical structure is stamped. Wrapper-spans preserved in-place keep the template-engine's `part.n` reference stable across re-renders → chrome content updates land in the same DOM location automatically. `#absorbChromeSlot()`'s late-arriving wrapper handler also updated: MOVE the wrapper (not hoist chrome out of it) to canonical position — same identity-preservation rationale. **§331 contract refined:** previously chrome was hoisted to be a direct sibling of the swatch's internal elements (`parentElement === swatch`); §341 keeps chrome inside the wrapper-span which is a direct sibling. Visually identical (display:contents collapses the wrapper's box) but the DOM identity is now wrapper-bearing. 3 NEW vitest cases (`FB-43 / §341`) + 3 §331 (FB-39) tests updated for new contract. 15/15 swatch tests passing.
18
+
14
19
  ## [0.5.17] - 2026-05-16
15
20
 
16
21
  ### Fixed
@@ -234,14 +234,45 @@ export class UISwatch extends UIElement {
234
234
  const chromeSlot = Array.from(this.children).filter(
235
235
  n => n.getAttribute && n.getAttribute('slot') === 'chrome'
236
236
  );
237
- // Also collect chrome elements nested inside wrapper-spans (template-render path).
238
- for (const wrapper of Array.from(this.children)) {
239
- if (wrapper.getAttribute?.('slot') === 'chrome') continue;
240
- const nested = wrapper.querySelectorAll?.('[slot="chrome"]');
241
- if (nested) for (const n of nested) chromeSlot.push(n);
242
- }
243
237
  for (const n of chromeSlot) n.remove();
244
238
 
239
+ // §341 (v0.5.x, FB-43): preserve display:contents wrapper-spans that
240
+ // already contain chrome at #stamp() time (rare — typically when a
241
+ // setup script pre-built the subtree before connect). AdiaUI's `html\`\``
242
+ // template engine wraps each `${...}` interpolation in a `<span
243
+ // style="display: contents">` (per `core/template.js:212` `wrap()`);
244
+ // re-renders update wrapper contents via `part.n`. Detach + re-attach
245
+ // at canonical position so they survive innerHTML='' below.
246
+ const chromeWrappers = Array.from(this.children).filter(n =>
247
+ n.tagName === 'SPAN' &&
248
+ n.style?.display === 'contents' &&
249
+ n.querySelector?.('[slot="chrome"]')
250
+ );
251
+ for (const w of chromeWrappers) w.remove();
252
+
253
+ // §341 (FB-43): preserve template-engine text-node + comment
254
+ // placeholders. AdiaUI's `scan()` (template.js:113) replaces
255
+ // `<!--p:N-->` comments with empty text nodes; `wrap()` later
256
+ // creates `<span style="display: contents">` and replaces the text
257
+ // node in-place via `replaceWith(part.n, wrapper)`. For that
258
+ // replaceWith to work, the text node MUST still be attached when
259
+ // parent's update() runs (it runs AFTER our connectedCallback
260
+ // synchronously, so AFTER #stamp()). Pre-§341, innerHTML='' below
261
+ // wiped the text node → replaceWith no-op → wrapper detached →
262
+ // chrome stamped into a detached subtree (never reaches DOM).
263
+ //
264
+ // Fix: extract empty text + comment nodes before the wipe, re-attach
265
+ // at the canonical chrome region (between tile and badge). Parent's
266
+ // update() then replaces them with live wrapper-spans in place;
267
+ // subsequent re-renders update the wrappers in-place too.
268
+ const placeholders = [];
269
+ for (const n of Array.from(this.childNodes)) {
270
+ if ((n.nodeType === 3 && !n.data) || n.nodeType === 8) {
271
+ placeholders.push(n);
272
+ }
273
+ }
274
+ for (const p of placeholders) p.remove();
275
+
245
276
  // Capture pre-existing default-slot content so consumer-authored
246
277
  // children (e.g. <swatch-ui>Forecast</swatch-ui>) survive stamping.
247
278
  // `[slot="chrome"]` children are already removed above, so won't appear here.
@@ -279,6 +310,19 @@ export class UISwatch extends UIElement {
279
310
  // `position: absolute; top: 2px; right: 2px` and anchor against the host's
280
311
  // padding-box, which matches the tile's geometry in the block layout.
281
312
  for (const n of chromeSlot) this.appendChild(n);
313
+ // §341 (FB-43): re-attach chrome-bearing wrapper-spans at canonical
314
+ // position. Wrapper preserves the template-engine's `part.n`
315
+ // reference so parent re-renders can update chrome content in-place
316
+ // without detaching the wrapper.
317
+ for (const w of chromeWrappers) this.appendChild(w);
318
+ // §341 (FB-43): re-attach template-engine placeholders at canonical
319
+ // chrome region. Parent's pending update() will replace each
320
+ // placeholder with a live wrapper-span (via `wrap(part)`'s
321
+ // `replaceWith`), landing chrome content visually between tile +
322
+ // badge. Without this re-attach, the placeholders would have been
323
+ // wiped by `innerHTML = ''` and the parent's `replaceWith` would
324
+ // no-op against a detached node.
325
+ for (const p of placeholders) this.appendChild(p);
282
326
 
283
327
  // Badge container — holds one or more <span data-badge-variant="..."> children.
284
328
  // Multi-badge support added in v0.4.9 §92 (FEEDBACK-04 follow-up).
@@ -350,9 +394,18 @@ export class UISwatch extends UIElement {
350
394
  this.insertBefore(child, this.#badgeEl);
351
395
  }
352
396
  }
353
- // §331 (v0.5.16, FB-39): also handle wrapper-spanned chrome children
354
- // that arrive AFTER #stamp() (parent template's late update() pass).
355
- // Hoist them OUT of any non-internal wrapper + into the canonical slot.
397
+ // §331 (v0.5.16, FB-39): handle wrapper-spanned chrome children that
398
+ // arrive AFTER #stamp() (parent template's late update() pass).
399
+ //
400
+ // §341 (FB-43) refinement: move the WRAPPER to canonical position
401
+ // rather than hoisting chrome OUT of it. The wrapper is the parent
402
+ // template engine's `part.n` reference; if we extract chrome, the
403
+ // next parent re-render lands new chrome inside the now-empty
404
+ // wrapper at the wrapper's original (non-canonical) position. By
405
+ // moving the wrapper itself to canonical position, future parent
406
+ // updates land at the right visual location automatically.
407
+ // Wrapper is `display: contents` → no layout box → chrome anchors
408
+ // against the swatch's padding-box correctly.
356
409
  for (const child of Array.from(this.children)) {
357
410
  if (
358
411
  child === this.#tileEl ||
@@ -361,11 +414,12 @@ export class UISwatch extends UIElement {
361
414
  child === this.#detailEl ||
362
415
  child === this.#copyEl
363
416
  ) continue;
364
- const nested = child.querySelectorAll?.('[slot="chrome"]');
365
- if (!nested || !nested.length) continue;
366
- for (const n of nested) {
367
- this.insertBefore(n, this.#badgeEl);
368
- }
417
+ // Only move display:contents wrappers that actually carry chrome.
418
+ const isWrapper = child.tagName === 'SPAN' && child.style?.display === 'contents';
419
+ if (!isWrapper) continue;
420
+ const hasNested = child.querySelector?.('[slot="chrome"]');
421
+ if (!hasNested) continue;
422
+ this.insertBefore(child, this.#badgeEl);
369
423
  }
370
424
  }
371
425
 
@@ -175,7 +175,10 @@ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 /
175
175
  // <span style="display: contents"> (per core/template.js:212 `wrap()`).
176
176
  // When consumer writes `html`<swatch-ui>${chromeFrag}</swatch-ui>``,
177
177
  // the chromeFrag's content lands NESTED inside a wrapper span — not
178
- // as direct children of <swatch-ui>. Pre-§331 the chrome was wiped.
178
+ // as direct children of <swatch-ui>. Pre-§331 the chrome was wiped;
179
+ // §341 (FB-43) refined to keep chrome INSIDE the wrapper + move the
180
+ // wrapper to canonical position (preserves template-engine `part.n`
181
+ // identity so parent re-renders update in-place).
179
182
  const swatch = document.createElement('swatch-ui');
180
183
  swatch.setAttribute('shape', 'block');
181
184
  swatch.setAttribute('label-position', 'overlay');
@@ -196,18 +199,22 @@ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 /
196
199
 
197
200
  const found = swatch.querySelector('#wrapped-chrome');
198
201
  expect(found).not.toBeNull();
199
- // Chrome child should be a direct host sibling positioned between tile + badge.
202
+ // §341: chrome stays inside the display:contents wrapper; wrapper is
203
+ // at canonical position (between tile + badge). Visually equivalent
204
+ // to direct-child placement because display:contents collapses the box.
205
+ expect(found.parentElement).toBe(wrapper);
206
+ expect(wrapper.parentElement).toBe(swatch);
200
207
  const tile = swatch.querySelector('[data-tile]');
201
208
  const badge = swatch.querySelector('[data-badge]');
202
- expect(found.parentElement).toBe(swatch);
203
- expect(found.previousElementSibling).toBe(tile);
204
- expect(found.nextElementSibling).toBe(badge);
209
+ expect(wrapper.previousElementSibling).toBe(tile);
210
+ expect(wrapper.nextElementSibling).toBe(badge);
205
211
  });
206
212
 
207
213
  it('handles MULTIPLE chrome children nested in separate wrapper spans', async () => {
208
214
  // Each interpolated fragment in html`` gets its own wrapper span,
209
215
  // so multi-chrome usage (`${badge}${dot1}${dot2}`) produces multiple
210
- // wrapper spans, each containing one chrome child.
216
+ // wrapper spans, each containing one chrome child. §341: wrappers
217
+ // stay intact, chrome remains inside them.
211
218
  const swatch = document.createElement('swatch-ui');
212
219
  swatch.setAttribute('shape', 'block');
213
220
  swatch.setAttribute('label-position', 'overlay');
@@ -230,7 +237,10 @@ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 /
230
237
  for (const id of ids) {
231
238
  const found = swatch.querySelector(`#${id}`);
232
239
  expect(found).not.toBeNull();
233
- expect(found.parentElement).toBe(swatch);
240
+ // §341: chrome stays inside its wrapper; wrapper is a direct child of swatch.
241
+ expect(found.parentElement.tagName).toBe('SPAN');
242
+ expect(found.parentElement.style.display).toBe('contents');
243
+ expect(found.parentElement.parentElement).toBe(swatch);
234
244
  }
235
245
  });
236
246
 
@@ -260,11 +270,15 @@ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 /
260
270
 
261
271
  const found = swatch.querySelector('#late-wrapped');
262
272
  expect(found).not.toBeNull();
263
- expect(found.parentElement).toBe(swatch);
273
+ // §341: chrome stays inside wrapper; wrapper is now at canonical
274
+ // position (between tile and badge) after #absorbChromeSlot()'s
275
+ // re-positioning of the wrapper.
276
+ expect(found.parentElement).toBe(wrapper);
277
+ expect(wrapper.parentElement).toBe(swatch);
264
278
  const tile = swatch.querySelector('[data-tile]');
265
279
  const badge = swatch.querySelector('[data-badge]');
266
- expect(found.previousElementSibling).toBe(tile);
267
- expect(found.nextElementSibling).toBe(badge);
280
+ expect(wrapper.previousElementSibling).toBe(tile);
281
+ expect(wrapper.nextElementSibling).toBe(badge);
268
282
  });
269
283
  });
270
284
 
@@ -329,3 +343,108 @@ describe('<swatch-ui> [auto-contrast] does not override [label-position="overlay
329
343
  expect(block).toMatch(/position\s*:\s*absolute/);
330
344
  });
331
345
  });
346
+
347
+ describe('<swatch-ui> chrome survives parent template re-render (FB-43 / §341)', () => {
348
+ // FB-43 (P1): claim is `template.js` mount() replaceChildren() destroys
349
+ // chrome children on every re-render. The real mechanism is more subtle:
350
+ // §331's #stamp() does `innerHTML = ''` which wipes the wrapper-span
351
+ // created by the parent template engine's wrap() (after collecting
352
+ // chrome into chromeSlot). The parent template's `part.n` still points
353
+ // to the now-detached wrapper-span. On parent re-render, applyValue()
354
+ // stamps the new chrome value INTO the detached wrapper-span — landing
355
+ // in memory but not in the DOM. Chrome is effectively destroyed.
356
+ //
357
+ // This empirical test reproduces the exact scenario.
358
+ let host;
359
+ let html, stamp;
360
+
361
+ beforeAll(async () => {
362
+ ({ html, stamp } = await import('../../core/template.js'));
363
+ });
364
+
365
+ beforeEach(() => {
366
+ host = document.createElement('div');
367
+ document.body.appendChild(host);
368
+ });
369
+
370
+ // §341 contract: chrome is queryable from swatch + lives inside a
371
+ // display:contents wrapper-span (the template-engine's stable part.n).
372
+ // The wrapper sits at canonical chrome position (between tile + badge).
373
+ // Visually equivalent to direct-child placement because display:contents
374
+ // collapses the wrapper's box.
375
+ function assertChromeCanonical(swatch, chrome) {
376
+ expect(chrome).not.toBeNull();
377
+ const wrapper = chrome.parentElement;
378
+ expect(wrapper).not.toBeNull();
379
+ // The wrapper is the chrome's direct parent + a display:contents span
380
+ // that's a direct child of the swatch (the template-engine's
381
+ // wrap()-created stable reference).
382
+ expect(wrapper.tagName).toBe('SPAN');
383
+ expect(wrapper.style.display).toBe('contents');
384
+ expect(wrapper.parentElement).toBe(swatch);
385
+ // Wrapper sits between tile and badge (canonical chrome region).
386
+ const tile = swatch.querySelector('[data-tile]');
387
+ const badge = swatch.querySelector('[data-badge]');
388
+ expect(tile).not.toBeNull();
389
+ expect(badge).not.toBeNull();
390
+ // Walk from tile forward: should hit wrapper before badge.
391
+ let cursor = tile.nextElementSibling;
392
+ let foundWrapper = false;
393
+ while (cursor && cursor !== badge) {
394
+ if (cursor === wrapper) { foundWrapper = true; break; }
395
+ cursor = cursor.nextElementSibling;
396
+ }
397
+ expect(foundWrapper).toBe(true);
398
+ }
399
+
400
+ it('FB-43: chrome interpolated via html`` template survives first render', async () => {
401
+ const chromeFrag = html`<span slot="chrome" id="fb43-chrome">P3</span>`;
402
+ const tpl = html`<swatch-ui label="500" color="#3b82f6">${chromeFrag}</swatch-ui>`;
403
+ stamp(tpl, host);
404
+ await new Promise((r) => setTimeout(r, 80));
405
+
406
+ const found = host.querySelector('#fb43-chrome');
407
+ expect(found).not.toBeNull();
408
+ const swatch = host.querySelector('swatch-ui');
409
+ assertChromeCanonical(swatch, found);
410
+ });
411
+
412
+ it('FB-43: chrome interpolated via html`` template survives second parent re-stamp (same strings)', async () => {
413
+ // FB-43's core claim: re-render destroys chrome. Same template strings
414
+ // → stamp() cache should hit → update() runs. Chrome content updates
415
+ // in-place inside the preserved wrapper-span.
416
+ function renderOnce(chromeText) {
417
+ const chromeFrag = html`<span slot="chrome" id="fb43-chrome">${chromeText}</span>`;
418
+ return html`<swatch-ui label="500" color="#3b82f6">${chromeFrag}</swatch-ui>`;
419
+ }
420
+ stamp(renderOnce('P3'), host);
421
+ await new Promise((r) => setTimeout(r, 80));
422
+ expect(host.querySelector('#fb43-chrome')).not.toBeNull();
423
+
424
+ // Re-stamp with same template strings (different value).
425
+ stamp(renderOnce('OOG'), host);
426
+ await new Promise((r) => setTimeout(r, 80));
427
+
428
+ const swatch = host.querySelector('swatch-ui');
429
+ const chrome = swatch.querySelector('[slot="chrome"]');
430
+ expect(chrome).not.toBeNull();
431
+ expect(chrome.textContent).toBe('OOG');
432
+ assertChromeCanonical(swatch, chrome);
433
+ });
434
+
435
+ it('FB-43: chrome survives N consecutive re-renders (regression stress test)', async () => {
436
+ function renderOnce(chromeText) {
437
+ const chromeFrag = html`<span slot="chrome" id="fb43-chrome">${chromeText}</span>`;
438
+ return html`<swatch-ui label="500" color="#3b82f6">${chromeFrag}</swatch-ui>`;
439
+ }
440
+ for (let i = 0; i < 5; i++) {
441
+ stamp(renderOnce(`label-${i}`), host);
442
+ await new Promise((r) => setTimeout(r, 60));
443
+ }
444
+ const swatch = host.querySelector('swatch-ui');
445
+ const chrome = swatch.querySelector('[slot="chrome"]');
446
+ expect(chrome).not.toBeNull();
447
+ expect(chrome.textContent).toBe('label-4');
448
+ assertChromeCanonical(swatch, chrome);
449
+ });
450
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.5.17",
3
+ "version": "0.5.18",
4
4
  "description": "AdiaUI web components — vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-runtime.",
5
5
  "type": "module",
6
6
  "types": "./index.d.ts",