@adia-ai/web-components 0.5.16 → 0.5.17

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,15 @@ runtime ships in the sibling `@adia-ai/a2ui-runtime` package
11
11
 
12
12
  _No pending changes._
13
13
 
14
+ ## [0.5.17] - 2026-05-16
15
+
16
+ ### Fixed
17
+ - §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.
18
+ - §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).
19
+
20
+ ### Tests
21
+ - §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.
22
+
14
23
  ## [0.5.16] - 2026-05-16
15
24
 
16
25
  ### v0.5.16 §331 — `<swatch-ui>` chrome-slot wrapper-span deep-walk (FB-39; P1)
@@ -227,9 +227,24 @@ export class UIListItem extends UIElement {
227
227
  iconEl.remove();
228
228
  }
229
229
 
230
- // Sync text — only touch elements we stamped.
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.#wasStamped(textEl)) textEl.textContent = this.text;
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
+ });
@@ -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] {
@@ -267,3 +267,65 @@ describe('<swatch-ui> chrome child nested in display:contents wrapper (FB-39 /
267
267
  expect(found.nextElementSibling).toBe(badge);
268
268
  });
269
269
  });
270
+
271
+ describe('<swatch-ui> [auto-contrast] does not override [label-position="overlay"] position (FB-41 / §339)', () => {
272
+ // Structural CSS-source-string assertion: happy-dom does not resolve
273
+ // @scope CSS specificity reliably, so we verify the fix by reading
274
+ // swatch.css text and asserting the auto-contrast rules don't carry
275
+ // `position: relative` (which collided at equal specificity with the
276
+ // overlay rule's `position: absolute` via source order).
277
+ //
278
+ // Pre-§339: `:scope[shape="block"][auto-contrast] > [data-label]`
279
+ // declared `position: relative; z-index: 1`. Combined with overlay
280
+ // attribute, the later rule won → label dropped out of overlay layout.
281
+ //
282
+ // Post-§339: only `z-index: 1` remains (z-index is no-op on static
283
+ // elements but harmless; for the overlay case it stacks above the
284
+ // tile correctly via the overlay rule's `position: absolute`).
285
+ it('auto-contrast label rule does NOT declare position: relative (regression for FB-41)', async () => {
286
+ const fs = await import('node:fs');
287
+ const path = await import('node:path');
288
+ const url = await import('node:url');
289
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
290
+ const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
291
+ const match = css.match(
292
+ /:scope\[shape="block"\]\[auto-contrast\]\s*>\s*\[data-label\]\s*\{([^}]*)\}/
293
+ );
294
+ expect(match).not.toBeNull();
295
+ const block = match[1];
296
+ expect(block).not.toMatch(/position\s*:\s*relative/);
297
+ expect(block).toMatch(/z-index\s*:\s*1/);
298
+ });
299
+
300
+ it('auto-contrast detail rule does NOT declare position: relative (regression for FB-41)', async () => {
301
+ const fs = await import('node:fs');
302
+ const path = await import('node:path');
303
+ const url = await import('node:url');
304
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
305
+ const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
306
+ const match = css.match(
307
+ /:scope\[shape="block"\]\[auto-contrast\]\s*>\s*\[data-detail\]\s*\{([^}]*)\}/
308
+ );
309
+ expect(match).not.toBeNull();
310
+ const block = match[1];
311
+ expect(block).not.toMatch(/position\s*:\s*relative/);
312
+ expect(block).toMatch(/z-index\s*:\s*1/);
313
+ });
314
+
315
+ it('overlay rule still declares position: absolute (regression for FB-41)', async () => {
316
+ // Defensive: also assert the overlay rule's `position: absolute` is
317
+ // still in place — if a future refactor drops it, the §253 contract
318
+ // breaks regardless of the auto-contrast collision.
319
+ const fs = await import('node:fs');
320
+ const path = await import('node:path');
321
+ const url = await import('node:url');
322
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
323
+ const css = fs.readFileSync(path.join(here, 'swatch.css'), 'utf8');
324
+ const match = css.match(
325
+ /:scope\[shape="block"\]\[label-position="overlay"\]\s*>\s*\[data-label\]\s*\{([^}]*)\}/
326
+ );
327
+ expect(match).not.toBeNull();
328
+ const block = match[1];
329
+ expect(block).toMatch(/position\s*:\s*absolute/);
330
+ });
331
+ });
@@ -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.16",
3
+ "version": "0.5.17",
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",