@adia-ai/web-components 0.5.16 → 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 +14 -0
- package/components/list/class.js +17 -2
- package/components/list/list.test.js +106 -0
- package/components/swatch/class.js +68 -14
- package/components/swatch/swatch.css +13 -3
- package/components/swatch/swatch.test.js +191 -10
- package/core/template.test.js +84 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,20 @@ 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
|
+
|
|
19
|
+
## [0.5.17] - 2026-05-16
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- §339 (FB-41; P2) — `<swatch-ui>` `[auto-contrast]` overrode `[label-position="overlay"]` position. Two `@scope (swatch-ui)` rules at equal specificity (0,3,1) collided on `> [data-label] { position }`; source order favored auto-contrast's `position: relative` over overlay's `position: absolute` → label dropped below tile (host doubled to 2× block-h). Blocks the canonical `label-position="overlay" + auto-contrast` pattern §253 enabled. Fix: drop `position: relative` from `[auto-contrast]` data-label + data-detail rules — z-index works on positioned elements (overlay supplies position: absolute), and non-overlay defaults are flex flow where z-index is irrelevant. 3 NEW vitest cases lock the CSS source structurally.
|
|
23
|
+
- §340 (FB-42; P2) — `<list-item-ui>` `render()` text-branch asymmetry. Icon + description branches had create-if-missing paths; text branch only updated existing stamped spans. Template engine `.text=${expr}` (canonical authoring pattern per USAGE.md) arrives AFTER `connectedCallback` `#stamp()` — `this.text` was `''` at stamp time so no span created, `render()` fired but couldn't recover → primary text silently invisible. Fix: mirror description branch's create-if-missing + remove-when-cleared pattern. NEW `list.test.js` with 5 vitest cases (create-on-prop-set / update / remove / parity-with-description / consumer-stamp non-interference).
|
|
24
|
+
|
|
25
|
+
### Tests
|
|
26
|
+
- §338 (FB-40; misdiagnosed) — 4 NEW vitest cases in `core/template.test.js` lock the existing FEEDBACK-06 comment-aware `inTag()` fix (line 55-78). FB-40 reported apostrophes-in-HTML-comments crashes claiming the parser mis-classifies subsequent `${…}` placeholders; the cited mechanism is incorrect — `inTag()` has skipped past `<!--…-->` wholesale since FEEDBACK-06 (pre-v0.5.x). The 4 new tests empirically verify the fix covers apostrophe-comment + double-quote-comment + multi-apostrophe + apostrophe+em-dash cases. RESPONSE-40 requests stack-trace evidence for the actual crash mechanism since the cited apostrophe diagnosis is empirically wrong against current source.
|
|
27
|
+
|
|
14
28
|
## [0.5.16] - 2026-05-16
|
|
15
29
|
|
|
16
30
|
### v0.5.16 §331 — `<swatch-ui>` chrome-slot wrapper-span deep-walk (FB-39; P1)
|
package/components/list/class.js
CHANGED
|
@@ -227,9 +227,24 @@ export class UIListItem extends UIElement {
|
|
|
227
227
|
iconEl.remove();
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
// Sync text —
|
|
230
|
+
// Sync text — mirror the icon + description branches: create-if-missing
|
|
231
|
+
// path so post-connect property bindings (template engine `.text=${expr}`)
|
|
232
|
+
// can stamp the span when `#stamp()` ran with `this.text === ''`. §340
|
|
233
|
+
// (FB-42) — without this, primary text is silently invisible when set
|
|
234
|
+
// via property binding (the canonical authoring pattern per USAGE.md).
|
|
231
235
|
const textEl = this.#ownChild('[slot="text"]');
|
|
232
|
-
if (this
|
|
236
|
+
if (this.text) {
|
|
237
|
+
if (textEl) {
|
|
238
|
+
if (this.#wasStamped(textEl)) textEl.textContent = this.text;
|
|
239
|
+
} else {
|
|
240
|
+
const el = this.#stampMark(document.createElement('span'));
|
|
241
|
+
el.setAttribute('slot', 'text');
|
|
242
|
+
el.textContent = this.text;
|
|
243
|
+
this.appendChild(el);
|
|
244
|
+
}
|
|
245
|
+
} else if (this.#wasStamped(textEl)) {
|
|
246
|
+
textEl.remove();
|
|
247
|
+
}
|
|
233
248
|
|
|
234
249
|
// Sync description — only touch elements we stamped.
|
|
235
250
|
const descEl = this.#ownChild('[slot="description"]');
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <list-item-ui> behavioral tests.
|
|
3
|
+
*
|
|
4
|
+
* Initial scope: FB-42 / §340 regression coverage — `render()`'s text
|
|
5
|
+
* branch can now create the slot element when `this.text` is set
|
|
6
|
+
* post-connect (e.g. via template-engine property binding), matching
|
|
7
|
+
* the icon + description branches' create-if-missing pattern.
|
|
8
|
+
*
|
|
9
|
+
* Test setup note: happy-dom connects custom elements synchronously
|
|
10
|
+
* during innerHTML parsing. To exercise the post-connect property-set
|
|
11
|
+
* timing (template-engine path), tests use `document.createElement` +
|
|
12
|
+
* `host.appendChild()` to connect with empty `text`, then set the
|
|
13
|
+
* property AFTER connect to trigger render()'s recovery path.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
|
17
|
+
|
|
18
|
+
beforeAll(async () => {
|
|
19
|
+
await import('./list.js');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('<list-item-ui> render() text branch — post-connect property binding (FB-42 / §340)', () => {
|
|
23
|
+
let host;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
host = document.createElement('div');
|
|
27
|
+
document.body.appendChild(host);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('creates [slot="text"] span when .text is set AFTER connectedCallback (template-engine timing)', async () => {
|
|
31
|
+
const li = document.createElement('list-item-ui');
|
|
32
|
+
host.appendChild(li);
|
|
33
|
+
// connectedCallback fires here with this.text === '' — span NOT stamped
|
|
34
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
35
|
+
expect(li.querySelector('[slot="text"]')).toBeNull();
|
|
36
|
+
|
|
37
|
+
// Template engine sets the property post-connect
|
|
38
|
+
li.text = 'Primary text via property binding';
|
|
39
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
40
|
+
|
|
41
|
+
const textSpan = li.querySelector('[slot="text"]');
|
|
42
|
+
expect(textSpan).not.toBeNull();
|
|
43
|
+
expect(textSpan.textContent).toBe('Primary text via property binding');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('updates existing [slot="text"] span when .text changes', async () => {
|
|
47
|
+
const li = document.createElement('list-item-ui');
|
|
48
|
+
li.setAttribute('text', 'initial');
|
|
49
|
+
host.appendChild(li);
|
|
50
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
51
|
+
|
|
52
|
+
const textSpan = li.querySelector('[slot="text"]');
|
|
53
|
+
expect(textSpan).not.toBeNull();
|
|
54
|
+
expect(textSpan.textContent).toBe('initial');
|
|
55
|
+
|
|
56
|
+
li.text = 'updated';
|
|
57
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
58
|
+
expect(li.querySelector('[slot="text"]').textContent).toBe('updated');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('removes stamped [slot="text"] span when .text is cleared', async () => {
|
|
62
|
+
const li = document.createElement('list-item-ui');
|
|
63
|
+
li.setAttribute('text', 'will be removed');
|
|
64
|
+
host.appendChild(li);
|
|
65
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
66
|
+
expect(li.querySelector('[slot="text"]')).not.toBeNull();
|
|
67
|
+
|
|
68
|
+
li.text = '';
|
|
69
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
70
|
+
expect(li.querySelector('[slot="text"]')).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('text branch parity with description branch — both create-if-missing post-connect', async () => {
|
|
74
|
+
const li = document.createElement('list-item-ui');
|
|
75
|
+
host.appendChild(li);
|
|
76
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
77
|
+
expect(li.querySelector('[slot="text"]')).toBeNull();
|
|
78
|
+
expect(li.querySelector('[slot="description"]')).toBeNull();
|
|
79
|
+
|
|
80
|
+
li.text = 'primary';
|
|
81
|
+
li.description = 'secondary';
|
|
82
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
83
|
+
|
|
84
|
+
expect(li.querySelector('[slot="text"]')?.textContent).toBe('primary');
|
|
85
|
+
expect(li.querySelector('[slot="description"]')?.textContent).toBe('secondary');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does not stamp [slot="text"] for unauthored consumer-provided text content', async () => {
|
|
89
|
+
// If the consumer provides their own [slot="text"] child (e.g. for
|
|
90
|
+
// richer markup), the render() branch must NOT touch it.
|
|
91
|
+
const li = document.createElement('list-item-ui');
|
|
92
|
+
const userSpan = document.createElement('span');
|
|
93
|
+
userSpan.setAttribute('slot', 'text');
|
|
94
|
+
userSpan.innerHTML = '<strong>bold</strong> user';
|
|
95
|
+
li.appendChild(userSpan);
|
|
96
|
+
host.appendChild(li);
|
|
97
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
98
|
+
|
|
99
|
+
li.text = 'attempt to set';
|
|
100
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
101
|
+
|
|
102
|
+
const span = li.querySelector('[slot="text"]');
|
|
103
|
+
expect(span).toBe(userSpan); // identity preserved
|
|
104
|
+
expect(span.innerHTML).toBe('<strong>bold</strong> user'); // markup preserved
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -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):
|
|
354
|
-
//
|
|
355
|
-
//
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
|
@@ -255,9 +255,20 @@
|
|
|
255
255
|
stamps [data-on-light] or [data-on-dark] on the label. The label then
|
|
256
256
|
swaps fg color so it remains legible against the tile background.
|
|
257
257
|
Only fires for shape="block" (label sits ON the tile); for other
|
|
258
|
-
shapes the label is beside the tile and uses the normal chrome fg.
|
|
258
|
+
shapes the label is beside the tile and uses the normal chrome fg.
|
|
259
|
+
|
|
260
|
+
§339 (FB-41): the auto-contrast rules previously set `position:
|
|
261
|
+
relative` which collided at equal specificity (0,3,1) with the
|
|
262
|
+
overlay rule's `position: absolute` (line 164). Source order made
|
|
263
|
+
the later auto-contrast rule win, so `[label-position="overlay"]
|
|
264
|
+
[auto-contrast]` resolved the label to `position: relative`,
|
|
265
|
+
dropping it OUT of overlay and below the tile (host height doubled).
|
|
266
|
+
Fix: drop `position: relative` from auto-contrast. `z-index: 1`
|
|
267
|
+
only does work on a positioned element; the overlay rule already
|
|
268
|
+
supplies `position: absolute` for the overlay case. For the
|
|
269
|
+
non-overlay default (label below tile), the label is in normal
|
|
270
|
+
flow and doesn't overlap anything, so z-index is irrelevant. */
|
|
259
271
|
:scope[shape="block"][auto-contrast] > [data-label] {
|
|
260
|
-
position: relative;
|
|
261
272
|
z-index: 1;
|
|
262
273
|
}
|
|
263
274
|
:scope[shape="block"][auto-contrast] > [data-label][data-on-dark] {
|
|
@@ -268,7 +279,6 @@
|
|
|
268
279
|
}
|
|
269
280
|
/* Detail line follows the same auto-contrast choice. */
|
|
270
281
|
:scope[shape="block"][auto-contrast] > [data-detail] {
|
|
271
|
-
position: relative;
|
|
272
282
|
z-index: 1;
|
|
273
283
|
}
|
|
274
284
|
:scope[shape="block"][auto-contrast] > [data-label][data-on-dark] ~ [data-detail] {
|
|
@@ -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
|
-
//
|
|
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(
|
|
203
|
-
expect(
|
|
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
|
-
|
|
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,10 +270,181 @@ 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
|
-
|
|
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(
|
|
267
|
-
expect(
|
|
280
|
+
expect(wrapper.previousElementSibling).toBe(tile);
|
|
281
|
+
expect(wrapper.nextElementSibling).toBe(badge);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('<swatch-ui> [auto-contrast] does not override [label-position="overlay"] position (FB-41 / §339)', () => {
|
|
286
|
+
// Structural CSS-source-string assertion: happy-dom does not resolve
|
|
287
|
+
// @scope CSS specificity reliably, so we verify the fix by reading
|
|
288
|
+
// swatch.css text and asserting the auto-contrast rules don't carry
|
|
289
|
+
// `position: relative` (which collided at equal specificity with the
|
|
290
|
+
// overlay rule's `position: absolute` via source order).
|
|
291
|
+
//
|
|
292
|
+
// Pre-§339: `:scope[shape="block"][auto-contrast] > [data-label]`
|
|
293
|
+
// declared `position: relative; z-index: 1`. Combined with overlay
|
|
294
|
+
// attribute, the later rule won → label dropped out of overlay layout.
|
|
295
|
+
//
|
|
296
|
+
// Post-§339: only `z-index: 1` remains (z-index is no-op on static
|
|
297
|
+
// elements but harmless; for the overlay case it stacks above the
|
|
298
|
+
// tile correctly via the overlay rule's `position: absolute`).
|
|
299
|
+
it('auto-contrast label rule does NOT declare position: relative (regression for FB-41)', async () => {
|
|
300
|
+
const fs = await import('node:fs');
|
|
301
|
+
const path = await import('node:path');
|
|
302
|
+
const url = await import('node:url');
|
|
303
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
304
|
+
const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
|
|
305
|
+
const match = css.match(
|
|
306
|
+
/:scope\[shape="block"\]\[auto-contrast\]\s*>\s*\[data-label\]\s*\{([^}]*)\}/
|
|
307
|
+
);
|
|
308
|
+
expect(match).not.toBeNull();
|
|
309
|
+
const block = match[1];
|
|
310
|
+
expect(block).not.toMatch(/position\s*:\s*relative/);
|
|
311
|
+
expect(block).toMatch(/z-index\s*:\s*1/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('auto-contrast detail rule does NOT declare position: relative (regression for FB-41)', async () => {
|
|
315
|
+
const fs = await import('node:fs');
|
|
316
|
+
const path = await import('node:path');
|
|
317
|
+
const url = await import('node:url');
|
|
318
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
319
|
+
const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
|
|
320
|
+
const match = css.match(
|
|
321
|
+
/:scope\[shape="block"\]\[auto-contrast\]\s*>\s*\[data-detail\]\s*\{([^}]*)\}/
|
|
322
|
+
);
|
|
323
|
+
expect(match).not.toBeNull();
|
|
324
|
+
const block = match[1];
|
|
325
|
+
expect(block).not.toMatch(/position\s*:\s*relative/);
|
|
326
|
+
expect(block).toMatch(/z-index\s*:\s*1/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('overlay rule still declares position: absolute (regression for FB-41)', async () => {
|
|
330
|
+
// Defensive: also assert the overlay rule's `position: absolute` is
|
|
331
|
+
// still in place — if a future refactor drops it, the §253 contract
|
|
332
|
+
// breaks regardless of the auto-contrast collision.
|
|
333
|
+
const fs = await import('node:fs');
|
|
334
|
+
const path = await import('node:path');
|
|
335
|
+
const url = await import('node:url');
|
|
336
|
+
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
|
337
|
+
const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
|
|
338
|
+
const match = css.match(
|
|
339
|
+
/:scope\[shape="block"\]\[label-position="overlay"\]\s*>\s*\[data-label\]\s*\{([^}]*)\}/
|
|
340
|
+
);
|
|
341
|
+
expect(match).not.toBeNull();
|
|
342
|
+
const block = match[1];
|
|
343
|
+
expect(block).toMatch(/position\s*:\s*absolute/);
|
|
344
|
+
});
|
|
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);
|
|
268
449
|
});
|
|
269
450
|
});
|
package/core/template.test.js
CHANGED
|
@@ -95,3 +95,87 @@ describe('html template — §250 (v0.5.11) ?attr=${bool} silent-failure trap',
|
|
|
95
95
|
expect(warnSpy).not.toHaveBeenCalled();
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
describe('html template — FB-40 (v0.5.16) apostrophe in HTML comments does NOT mis-classify subsequent placeholders', () => {
|
|
100
|
+
// FEEDBACK-06 §1 P0 (long resolved) made `inTag()` comment-aware:
|
|
101
|
+
// when scan() hits `<!--` outside a string context, it jumps past
|
|
102
|
+
// `-->` wholesale. FB-40 (filed 2026-05-16 post-v0.5.16 cut) reported
|
|
103
|
+
// crashes attributed to apostrophes in comments. This suite verifies
|
|
104
|
+
// empirically that the comment-aware logic still holds — the FB-40
|
|
105
|
+
// diagnosis is mistaken about the mechanism (the existing fix covers
|
|
106
|
+
// the apostrophe-in-comment case).
|
|
107
|
+
|
|
108
|
+
let container;
|
|
109
|
+
let warnSpy;
|
|
110
|
+
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
container = document.createElement('div');
|
|
113
|
+
document.body.appendChild(container);
|
|
114
|
+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
container.remove();
|
|
119
|
+
warnSpy.mockRestore();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('@event=${handler} placeholder AFTER apostrophe-bearing comment is classified as attribute-position', () => {
|
|
123
|
+
let received = null;
|
|
124
|
+
const handler = (e) => { received = e; };
|
|
125
|
+
const tpl = html`
|
|
126
|
+
<div>
|
|
127
|
+
<!-- editor-shell's named slots -->
|
|
128
|
+
<button data-test="probe" @click=${handler}>X</button>
|
|
129
|
+
</div>
|
|
130
|
+
`;
|
|
131
|
+
stamp(tpl, container);
|
|
132
|
+
const btn = container.querySelector('[data-test="probe"]');
|
|
133
|
+
expect(btn).not.toBeNull();
|
|
134
|
+
btn.click();
|
|
135
|
+
expect(received).not.toBeNull();
|
|
136
|
+
expect(received.type).toBe('click');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('double-quote in HTML comment does not mis-classify subsequent attribute placeholder', () => {
|
|
140
|
+
const tpl = html`
|
|
141
|
+
<div>
|
|
142
|
+
<!-- the "Stop Override" button below -->
|
|
143
|
+
<button data-test="probe" data-id=${'42'}></button>
|
|
144
|
+
</div>
|
|
145
|
+
`;
|
|
146
|
+
stamp(tpl, container);
|
|
147
|
+
const btn = container.querySelector('[data-test="probe"]');
|
|
148
|
+
expect(btn).not.toBeNull();
|
|
149
|
+
expect(btn.getAttribute('data-id')).toBe('42');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('multiple apostrophes across multiple comments still keep subsequent placeholders attribute-position', () => {
|
|
153
|
+
let received = null;
|
|
154
|
+
const tpl = html`
|
|
155
|
+
<div>
|
|
156
|
+
<!-- one's first comment -->
|
|
157
|
+
<span>x</span>
|
|
158
|
+
<!-- two's second comment -->
|
|
159
|
+
<button data-test="probe" @click=${(e) => { received = e; }}></button>
|
|
160
|
+
</div>
|
|
161
|
+
`;
|
|
162
|
+
stamp(tpl, container);
|
|
163
|
+
container.querySelector('[data-test="probe"]').click();
|
|
164
|
+
expect(received).not.toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('apostrophe + em-dash (U+2014) in a single comment does not break parsing', () => {
|
|
168
|
+
// dts-export-drawer.ts case from FB-40's cited crashes:
|
|
169
|
+
// <!-- 2026-05-14 — migrated number inputs ... -->
|
|
170
|
+
let received = null;
|
|
171
|
+
const tpl = html`
|
|
172
|
+
<div>
|
|
173
|
+
<!-- 2026-05-14 — author's notes about migrated number inputs -->
|
|
174
|
+
<button data-test="probe" @click=${(e) => { received = e; }}></button>
|
|
175
|
+
</div>
|
|
176
|
+
`;
|
|
177
|
+
stamp(tpl, container);
|
|
178
|
+
container.querySelector('[data-test="probe"]').click();
|
|
179
|
+
expect(received).not.toBeNull();
|
|
180
|
+
});
|
|
181
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-components",
|
|
3
|
-
"version": "0.5.
|
|
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",
|