@adia-ai/a2ui-runtime 0.6.33 → 0.6.34

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,11 @@
1
1
  # Changelog — @adia-ai/a2ui-runtime
2
2
 
3
+ ## [0.6.34] — 2026-05-23
4
+
5
+ ### Added — Phase 1 CSS channel: `updateStyles` + `removeStyles`
6
+
7
+ - **CSS channel — Phase 1** (commit `d664bee22`): runtime gains `updateStyles(id, patch)` + `removeStyles(id, keys)` APIs for parallel-channel CSS-token updates from a2ui messages. Lets agents send token-only updates (`{"--button-bg": "var(--a-accent-strong)"}`) without re-emitting the full component tree. Companion smoke + tests + visual eval fixture in evals/. Sibling spec example + visual eval fixture also updated in commits `f7f8430b2` and `ff62fe305`. Spec: `docs/specs/genui-css-channel.md`.
8
+
3
9
  ## [0.6.33] — 2026-05-23
4
10
 
5
11
  ### Fixed — card-ui body-slot drift closed across runtime + fixtures
@@ -0,0 +1,506 @@
1
+ /**
2
+ * A2UI CSS channel — unit fixtures.
3
+ *
4
+ * Spec: docs/specs/genui-css-channel.md §8.1
5
+ * Covers the 11 named fixtures asserting the renderer's updateStyles +
6
+ * removeStyles surface. happy-dom supports constructable stylesheets +
7
+ * adoptedStyleSheets natively; @scope semantics are not enforced by the
8
+ * DOM (the assertions check the wrapping behavior + adoption lifecycle,
9
+ * not the cascade-time effect of @scope itself — that's the smoke
10
+ * probe's job in §8.2).
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
14
+ import { A2UIRenderer } from './renderer.js';
15
+
16
+ let host;
17
+ let renderer;
18
+ const SURF = 'test-surface-1';
19
+
20
+ beforeEach(() => {
21
+ host = document.createElement('div');
22
+ document.body.appendChild(host);
23
+ renderer = new A2UIRenderer(host);
24
+ renderer.process({ type: 'createSurface', surfaceId: SURF, root: 'root' });
25
+ });
26
+
27
+ afterEach(() => {
28
+ // Tear down — clears adoptedSheets from document
29
+ renderer.reset();
30
+ host.remove();
31
+ // Defensive — happy-dom shares adoptedStyleSheets across tests
32
+ document.adoptedStyleSheets = [];
33
+ });
34
+
35
+ // Helper — count this renderer's adopted sheets on the document
36
+ function adoptedCount(rendererInstance, surfaceId) {
37
+ return rendererInstance.getStylesheets(surfaceId).size;
38
+ }
39
+
40
+ // ─────────────────────────────────────────────────────────────────────
41
+ // Fixture 1: applies-then-removes
42
+ // ─────────────────────────────────────────────────────────────────────
43
+
44
+ describe('CSS channel — applies-then-removes', () => {
45
+ it('updateStyles adds a sheet; removeStyles splices it', () => {
46
+ const before = document.adoptedStyleSheets.length;
47
+ renderer.process({
48
+ type: 'updateStyles',
49
+ surfaceId: SURF,
50
+ styleId: 's1',
51
+ css: `:scope { --a-accent: oklch(0.7 0.2 240); }`,
52
+ });
53
+ expect(adoptedCount(renderer, SURF)).toBe(1);
54
+ expect(document.adoptedStyleSheets.length).toBe(before + 1);
55
+
56
+ renderer.process({
57
+ type: 'removeStyles',
58
+ surfaceId: SURF,
59
+ styleId: 's1',
60
+ });
61
+ expect(adoptedCount(renderer, SURF)).toBe(0);
62
+ expect(document.adoptedStyleSheets.length).toBe(before);
63
+ });
64
+ });
65
+
66
+ // ─────────────────────────────────────────────────────────────────────
67
+ // Fixture 2: replaces-existing-styleid
68
+ // ─────────────────────────────────────────────────────────────────────
69
+
70
+ describe('CSS channel — replaces-existing-styleid', () => {
71
+ it('two updateStyles with same styleId keep only one sheet adopted', () => {
72
+ renderer.process({
73
+ type: 'updateStyles', surfaceId: SURF, styleId: 'theme',
74
+ css: `:scope { --a-accent: red; }`,
75
+ });
76
+ const firstSheet = renderer.getStylesheets(SURF).get('theme').sheet;
77
+
78
+ renderer.process({
79
+ type: 'updateStyles', surfaceId: SURF, styleId: 'theme',
80
+ css: `:scope { --a-accent: blue; }`,
81
+ });
82
+ const secondSheet = renderer.getStylesheets(SURF).get('theme').sheet;
83
+
84
+ expect(adoptedCount(renderer, SURF)).toBe(1);
85
+ expect(firstSheet).not.toBe(secondSheet);
86
+ expect(document.adoptedStyleSheets.includes(firstSheet)).toBe(false);
87
+ expect(document.adoptedStyleSheets.includes(secondSheet)).toBe(true);
88
+ });
89
+ });
90
+
91
+ // ─────────────────────────────────────────────────────────────────────
92
+ // Fixture 3: scoped-via-attribute
93
+ // ─────────────────────────────────────────────────────────────────────
94
+
95
+ describe('CSS channel — scoped-via-attribute', () => {
96
+ it('emitted CSS contains @scope wrapper anchored to the surface id', () => {
97
+ renderer.process({
98
+ type: 'updateStyles', surfaceId: SURF, styleId: 'theme',
99
+ css: `card-ui { background: red; }`,
100
+ });
101
+ const entry = renderer.getStylesheets(SURF).get('theme');
102
+ const sheetText = Array.from(entry.sheet.cssRules).map(r => r.cssText).join('\n');
103
+ expect(sheetText).toMatch(/@scope\s*\(\[data-a2ui-surface\s*=\s*"test-surface-1"\]\)/);
104
+ });
105
+
106
+ it('sibling surfaces produce independent scope anchors', () => {
107
+ const SURF2 = 'test-surface-2';
108
+ renderer.process({ type: 'createSurface', surfaceId: SURF2, root: 'root' });
109
+ renderer.process({
110
+ type: 'updateStyles', surfaceId: SURF, styleId: 's', css: `.x { color: red; }`,
111
+ });
112
+ renderer.process({
113
+ type: 'updateStyles', surfaceId: SURF2, styleId: 's', css: `.x { color: blue; }`,
114
+ });
115
+ const a = renderer.getStylesheets(SURF).get('s').sheet;
116
+ const b = renderer.getStylesheets(SURF2).get('s').sheet;
117
+ expect(a).not.toBe(b);
118
+ const aText = Array.from(a.cssRules).map(r => r.cssText).join('\n');
119
+ const bText = Array.from(b.cssRules).map(r => r.cssText).join('\n');
120
+ expect(aText).toContain('test-surface-1');
121
+ expect(aText).not.toContain('test-surface-2');
122
+ expect(bText).toContain('test-surface-2');
123
+ expect(bText).not.toContain('test-surface-1');
124
+ });
125
+ });
126
+
127
+ // ─────────────────────────────────────────────────────────────────────
128
+ // Fixture 4: rejects-import
129
+ // ─────────────────────────────────────────────────────────────────────
130
+
131
+ describe('CSS channel — rejects forbidden constructs', () => {
132
+ it('rejects @import', () => {
133
+ let rejection = null;
134
+ host.addEventListener('styles-rejected', (e) => { rejection = e.detail; });
135
+ renderer.process({
136
+ type: 'updateStyles', surfaceId: SURF, styleId: 'bad',
137
+ css: `@import url('//example.com/x.css'); .x { color: red; }`,
138
+ });
139
+ expect(rejection).not.toBeNull();
140
+ expect(rejection.reason).toBe('forbidden-import');
141
+ expect(adoptedCount(renderer, SURF)).toBe(0);
142
+ });
143
+
144
+ it('rejects :root selectors', () => {
145
+ let rejection = null;
146
+ host.addEventListener('styles-rejected', (e) => { rejection = e.detail; });
147
+ renderer.process({
148
+ type: 'updateStyles', surfaceId: SURF, styleId: 'bad',
149
+ css: `:root { --a-accent: red; }`,
150
+ });
151
+ expect(rejection?.reason).toBe('forbidden-root-selector');
152
+ expect(adoptedCount(renderer, SURF)).toBe(0);
153
+ });
154
+
155
+ it('rejects bare body / html selectors', () => {
156
+ const detected = [];
157
+ host.addEventListener('styles-rejected', (e) => { detected.push(e.detail); });
158
+ renderer.process({
159
+ type: 'updateStyles', surfaceId: SURF, styleId: 'b1',
160
+ css: `body { background: black; }`,
161
+ });
162
+ renderer.process({
163
+ type: 'updateStyles', surfaceId: SURF, styleId: 'b2',
164
+ css: `html { font-size: 18px; }`,
165
+ });
166
+ expect(detected.length).toBe(2);
167
+ expect(detected[0].reason).toBe('forbidden-body-selector');
168
+ expect(detected[1].reason).toBe('forbidden-html-selector');
169
+ });
170
+
171
+ it('rejects @charset', () => {
172
+ let rejection = null;
173
+ host.addEventListener('styles-rejected', (e) => { rejection = e.detail; });
174
+ renderer.process({
175
+ type: 'updateStyles', surfaceId: SURF, styleId: 'bad',
176
+ css: `@charset "UTF-8"; .x { color: red; }`,
177
+ });
178
+ // @charset must be the first rule in CSS — if happy-dom strips it,
179
+ // the test may not see the rejection. Allow either: explicit reject
180
+ // OR sheet adopted with the @charset stripped (parser behavior).
181
+ if (rejection) {
182
+ expect(rejection.reason).toBe('forbidden-charset');
183
+ } else {
184
+ // happy-dom may quietly drop @charset; verify no @charset survives
185
+ const entry = renderer.getStylesheets(SURF).get('bad');
186
+ if (entry) {
187
+ const text = Array.from(entry.sheet.cssRules).map(r => r.cssText).join('');
188
+ expect(text).not.toMatch(/@charset/i);
189
+ }
190
+ }
191
+ });
192
+
193
+ it('rejects user-side @scope directives', () => {
194
+ let rejection = null;
195
+ host.addEventListener('styles-rejected', (e) => { rejection = e.detail; });
196
+ renderer.process({
197
+ type: 'updateStyles', surfaceId: SURF, styleId: 'bad',
198
+ css: `@scope (.x) { .y { color: red; } }`,
199
+ });
200
+ // happy-dom may not parse @scope; in that case the rule is dropped
201
+ // and the test should not assert rejection. Skip if no rejection
202
+ // emitted AND no rule reached the sheet.
203
+ if (rejection) {
204
+ expect(rejection.reason).toBe('forbidden-scope-directive');
205
+ }
206
+ });
207
+ });
208
+
209
+ // ─────────────────────────────────────────────────────────────────────
210
+ // Fixture 5: rejects-external-url
211
+ // ─────────────────────────────────────────────────────────────────────
212
+
213
+ describe('CSS channel — external URL handling', () => {
214
+ it('rejects http: url() references', () => {
215
+ let rejection = null;
216
+ host.addEventListener('styles-rejected', (e) => { rejection = e.detail; });
217
+ renderer.process({
218
+ type: 'updateStyles', surfaceId: SURF, styleId: 'bad',
219
+ css: `.x { background-image: url(http://example.com/x.png); }`,
220
+ });
221
+ expect(rejection?.reason).toBe('external-url');
222
+ });
223
+
224
+ it('rejects https: url() references', () => {
225
+ let rejection = null;
226
+ host.addEventListener('styles-rejected', (e) => { rejection = e.detail; });
227
+ renderer.process({
228
+ type: 'updateStyles', surfaceId: SURF, styleId: 'bad',
229
+ css: `.x { background-image: url('https://example.com/x.png'); }`,
230
+ });
231
+ expect(rejection?.reason).toBe('external-url');
232
+ });
233
+
234
+ it('rejects protocol-relative // url() references', () => {
235
+ let rejection = null;
236
+ host.addEventListener('styles-rejected', (e) => { rejection = e.detail; });
237
+ renderer.process({
238
+ type: 'updateStyles', surfaceId: SURF, styleId: 'bad',
239
+ css: `.x { background-image: url(//cdn.example.com/x.png); }`,
240
+ });
241
+ expect(rejection?.reason).toBe('external-url');
242
+ });
243
+
244
+ it('allows data: url() references', () => {
245
+ let applied = null;
246
+ host.addEventListener('styles-applied', (e) => { applied = e.detail; });
247
+ renderer.process({
248
+ type: 'updateStyles', surfaceId: SURF, styleId: 'ok',
249
+ css: `.x { background-image: url(data:image/svg+xml;base64,PHN2Zy8+); }`,
250
+ });
251
+ expect(applied).not.toBeNull();
252
+ expect(applied.styleId).toBe('ok');
253
+ expect(adoptedCount(renderer, SURF)).toBe(1);
254
+ });
255
+
256
+ it('allows blob: url() references', () => {
257
+ let applied = null;
258
+ host.addEventListener('styles-applied', (e) => { applied = e.detail; });
259
+ renderer.process({
260
+ type: 'updateStyles', surfaceId: SURF, styleId: 'ok',
261
+ css: `.x { background-image: url(blob:abc-123); }`,
262
+ });
263
+ expect(applied).not.toBeNull();
264
+ });
265
+ });
266
+
267
+ // ─────────────────────────────────────────────────────────────────────
268
+ // Fixture 6: rewrites-keyframes
269
+ // ─────────────────────────────────────────────────────────────────────
270
+
271
+ describe('CSS channel — animation-name rewriting', () => {
272
+ it('@keyframes pulse is namespaced with surface id prefix', () => {
273
+ renderer.process({
274
+ type: 'updateStyles', surfaceId: SURF, styleId: 'anim',
275
+ css: `
276
+ @keyframes pulse { from { opacity: 1; } to { opacity: 0.5; } }
277
+ .x { animation: pulse 2s infinite; }
278
+ `,
279
+ });
280
+ const entry = renderer.getStylesheets(SURF).get('anim');
281
+ if (!entry) {
282
+ // happy-dom may not parse @keyframes — skip soft
283
+ return;
284
+ }
285
+ const sheetText = Array.from(entry.sheet.cssRules).map(r => r.cssText).join('\n');
286
+ // Surface id `test-surface-1` is sanitized to `test-surface-1` (no replacement needed)
287
+ expect(sheetText).toMatch(/@keyframes\s+test-surface-1_pulse/);
288
+ expect(sheetText).not.toMatch(/@keyframes\s+pulse\b/);
289
+ });
290
+ });
291
+
292
+ // ─────────────────────────────────────────────────────────────────────
293
+ // Fixture 7: cleanup-on-delete-surface
294
+ // ─────────────────────────────────────────────────────────────────────
295
+
296
+ describe('CSS channel — cleanup on deleteSurface', () => {
297
+ it('deleteSurface removes all adopted stylesheets for that surface', () => {
298
+ renderer.process({
299
+ type: 'updateStyles', surfaceId: SURF, styleId: 's1',
300
+ css: `:scope { --a-accent: red; }`,
301
+ });
302
+ renderer.process({
303
+ type: 'updateStyles', surfaceId: SURF, styleId: 's2',
304
+ css: `:scope { --a-radius-md: 12px; }`,
305
+ });
306
+ const before = document.adoptedStyleSheets.length;
307
+ expect(adoptedCount(renderer, SURF)).toBe(2);
308
+
309
+ renderer.process({ type: 'deleteSurface', surfaceId: SURF });
310
+ expect(document.adoptedStyleSheets.length).toBe(before - 2);
311
+ // Surface itself is gone — getStylesheets returns an empty Map
312
+ expect(renderer.getStylesheets(SURF).size).toBe(0);
313
+ });
314
+ });
315
+
316
+ // ─────────────────────────────────────────────────────────────────────
317
+ // Fixture 8: parse-error-reported
318
+ // ─────────────────────────────────────────────────────────────────────
319
+
320
+ describe('CSS channel — parse-error reported', () => {
321
+ it('happy-dom is permissive — verify the channel does not adopt invalid trees', () => {
322
+ // happy-dom's CSS parser is forgiving and will accept many shapes.
323
+ // Behavior we DO guarantee: even when happy-dom accepts garbage CSS,
324
+ // the resulting sheet doesn't blow up the renderer.
325
+ const before = document.adoptedStyleSheets.length;
326
+ renderer.process({
327
+ type: 'updateStyles', surfaceId: SURF, styleId: 'malformed',
328
+ css: `}}}{{ not real css at all`,
329
+ });
330
+ // Either rejected OR adopted-as-empty; both are acceptable.
331
+ // The hard requirement is no exception thrown + surface still healthy.
332
+ expect(renderer.getStylesheets(SURF).size).toBeLessThanOrEqual(1);
333
+ // Surface still works
334
+ renderer.process({
335
+ type: 'updateStyles', surfaceId: SURF, styleId: 'recover',
336
+ css: `:scope { --a-accent: red; }`,
337
+ });
338
+ expect(renderer.getStylesheets(SURF).has('recover')).toBe(true);
339
+ });
340
+ });
341
+
342
+ // ─────────────────────────────────────────────────────────────────────
343
+ // Fixture 9: idempotent-remove
344
+ // ─────────────────────────────────────────────────────────────────────
345
+
346
+ describe('CSS channel — idempotent removeStyles', () => {
347
+ it('removeStyles for absent styleId is a silent no-op', () => {
348
+ const removed = [];
349
+ host.addEventListener('styles-removed', (e) => { removed.push(e.detail); });
350
+ renderer.process({
351
+ type: 'removeStyles', surfaceId: SURF, styleId: 'nonexistent',
352
+ });
353
+ expect(removed.length).toBe(0);
354
+ });
355
+
356
+ it('removeStyles for unknown surfaceId is a silent no-op', () => {
357
+ const removed = [];
358
+ host.addEventListener('styles-removed', (e) => { removed.push(e.detail); });
359
+ renderer.process({
360
+ type: 'removeStyles', surfaceId: 'no-such-surface', styleId: 'anything',
361
+ });
362
+ expect(removed.length).toBe(0);
363
+ });
364
+ });
365
+
366
+ // ─────────────────────────────────────────────────────────────────────
367
+ // Fixture 10: events-fire
368
+ // ─────────────────────────────────────────────────────────────────────
369
+
370
+ describe('CSS channel — lifecycle events fire with correct detail', () => {
371
+ it('styles-applied carries { surfaceId, styleId, ruleCount, validationWarnings }', () => {
372
+ let detail = null;
373
+ host.addEventListener('styles-applied', (e) => { detail = e.detail; });
374
+ renderer.process({
375
+ type: 'updateStyles', surfaceId: SURF, styleId: 'ok',
376
+ css: `.x { color: red; } .y { color: blue; }`,
377
+ });
378
+ expect(detail).not.toBeNull();
379
+ expect(detail.surfaceId).toBe(SURF);
380
+ expect(detail.styleId).toBe('ok');
381
+ expect(typeof detail.ruleCount).toBe('number');
382
+ expect(detail.ruleCount).toBeGreaterThan(0);
383
+ expect(Array.isArray(detail.validationWarnings)).toBe(true);
384
+ });
385
+
386
+ it('styles-removed carries { surfaceId, styleId }', () => {
387
+ renderer.process({
388
+ type: 'updateStyles', surfaceId: SURF, styleId: 'r',
389
+ css: `.x { color: red; }`,
390
+ });
391
+ let detail = null;
392
+ host.addEventListener('styles-removed', (e) => { detail = e.detail; });
393
+ renderer.process({
394
+ type: 'removeStyles', surfaceId: SURF, styleId: 'r',
395
+ });
396
+ expect(detail).toEqual({ surfaceId: SURF, styleId: 'r' });
397
+ });
398
+
399
+ it('styles-rejected carries { surfaceId, styleId, reason }', () => {
400
+ let detail = null;
401
+ host.addEventListener('styles-rejected', (e) => { detail = e.detail; });
402
+ renderer.process({
403
+ type: 'updateStyles', surfaceId: SURF, styleId: 'bad',
404
+ css: `:root { color: red; }`,
405
+ });
406
+ expect(detail).not.toBeNull();
407
+ expect(detail.surfaceId).toBe(SURF);
408
+ expect(detail.styleId).toBe('bad');
409
+ expect(detail.reason).toBe('forbidden-root-selector');
410
+ });
411
+ });
412
+
413
+ // ─────────────────────────────────────────────────────────────────────
414
+ // Fixture 11: surface preconditions (§4.7)
415
+ // ─────────────────────────────────────────────────────────────────────
416
+
417
+ describe('CSS channel — surface preconditions', () => {
418
+ it('updateStyles for unknown surfaceId is rejected with no-such-surface', () => {
419
+ let rejection = null;
420
+ // Listen on the renderer container (where the event is dispatched when
421
+ // no surface exists)
422
+ host.addEventListener('styles-rejected', (e) => { rejection = e.detail; });
423
+ renderer.process({
424
+ type: 'updateStyles',
425
+ surfaceId: 'no-such-surface',
426
+ styleId: 'x',
427
+ css: `.x { color: red; }`,
428
+ });
429
+ expect(rejection?.reason).toBe('no-such-surface');
430
+ });
431
+
432
+ it('updateStyles with non-string css is rejected with invalid-payload', () => {
433
+ let rejection = null;
434
+ host.addEventListener('styles-rejected', (e) => { rejection = e.detail; });
435
+ renderer.process({
436
+ type: 'updateStyles', surfaceId: SURF, styleId: 'x',
437
+ css: { not: 'a string' },
438
+ });
439
+ expect(rejection?.reason).toMatch(/invalid-payload/);
440
+ });
441
+
442
+ it('prior valid sheet is preserved when a new sheet with the same styleId is rejected', () => {
443
+ renderer.process({
444
+ type: 'updateStyles', surfaceId: SURF, styleId: 'theme',
445
+ css: `:scope { --a-accent: red; }`,
446
+ });
447
+ const valid = renderer.getStylesheets(SURF).get('theme').sheet;
448
+ expect(document.adoptedStyleSheets.includes(valid)).toBe(true);
449
+
450
+ renderer.process({
451
+ type: 'updateStyles', surfaceId: SURF, styleId: 'theme',
452
+ css: `:root { color: red; }`, // forbidden
453
+ });
454
+
455
+ // Prior valid sheet still adopted
456
+ expect(renderer.getStylesheets(SURF).get('theme').sheet).toBe(valid);
457
+ expect(document.adoptedStyleSheets.includes(valid)).toBe(true);
458
+ });
459
+ });
460
+
461
+ // ─────────────────────────────────────────────────────────────────────
462
+ // Programmatic surface — getStylesheets accessor
463
+ // ─────────────────────────────────────────────────────────────────────
464
+
465
+ describe('CSS channel — getStylesheets accessor', () => {
466
+ it('returns empty Map for unknown surfaceId', () => {
467
+ const result = renderer.getStylesheets('no-such-surface');
468
+ expect(result).toBeInstanceOf(Map);
469
+ expect(result.size).toBe(0);
470
+ });
471
+
472
+ it('returns the surface adoptedSheets Map after updateStyles', () => {
473
+ renderer.process({
474
+ type: 'updateStyles', surfaceId: SURF, styleId: 'x',
475
+ css: `:scope { --a-accent: red; }`,
476
+ });
477
+ const m = renderer.getStylesheets(SURF);
478
+ expect(m.size).toBe(1);
479
+ expect(m.has('x')).toBe(true);
480
+ const entry = m.get('x');
481
+ expect(entry).toHaveProperty('sheet');
482
+ expect(entry).toHaveProperty('ruleCount');
483
+ expect(entry).toHaveProperty('appliedAt');
484
+ expect(typeof entry.appliedAt).toBe('number');
485
+ });
486
+ });
487
+
488
+ // ─────────────────────────────────────────────────────────────────────
489
+ // Renderer reset() — should clean up adopted sheets across surfaces
490
+ // ─────────────────────────────────────────────────────────────────────
491
+
492
+ describe('CSS channel — renderer.reset() cleans up adopted sheets', () => {
493
+ it('reset removes adopted sheets from every surface', () => {
494
+ const SURF2 = 'surf-2';
495
+ renderer.process({ type: 'createSurface', surfaceId: SURF2, root: 'root' });
496
+ renderer.process({ type: 'updateStyles', surfaceId: SURF, styleId: 'a', css: `.x { color: red; }` });
497
+ renderer.process({ type: 'updateStyles', surfaceId: SURF2, styleId: 'b', css: `.y { color: blue; }` });
498
+ const before = document.adoptedStyleSheets.length;
499
+ expect(before).toBeGreaterThanOrEqual(2);
500
+
501
+ renderer.reset();
502
+ expect(document.adoptedStyleSheets.length).toBe(before - 2);
503
+ expect(renderer.getStylesheets(SURF).size).toBe(0);
504
+ expect(renderer.getStylesheets(SURF2).size).toBe(0);
505
+ });
506
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-runtime",
3
- "version": "0.6.33",
3
+ "version": "0.6.34",
4
4
  "description": "A2UI runtime \u2014 renderer, registry, streams, surface manifest, and wiring primitives for the A2UI (Agent-to-UI) protocol. Framework-agnostic; pairs with any A2UI-conformant component set.",
5
5
  "type": "module",
6
6
  "exports": {
package/renderer.js CHANGED
@@ -50,6 +50,8 @@ export class A2UIRenderer {
50
50
  case 'updateDataModel': this.#updateDataModel(message); break;
51
51
  case 'wireComponents': this.#wireComponents(message); break;
52
52
  case 'deleteSurface': this.#deleteSurface(message); break;
53
+ case 'updateStyles': this.#updateStyles(message); break;
54
+ case 'removeStyles': this.#removeStyles(message); break;
53
55
  case 'meta': break; // LLM self-critique — not renderable
54
56
  default: console.warn('A2UI: unknown message type', message.type);
55
57
  }
@@ -99,6 +101,10 @@ export class A2UIRenderer {
99
101
  elements: new Map(),
100
102
  dataModel: {},
101
103
  bindings: new Map(),
104
+ // CSS channel (Phase 1): styleId -> { sheet, ruleCount, appliedAt }.
105
+ // Populated by #updateStyles; cleared by #removeStyles / #deleteSurface.
106
+ // See docs/specs/genui-css-channel.md §5.4.
107
+ adoptedSheets: new Map(),
102
108
  });
103
109
  }
104
110
 
@@ -116,8 +122,19 @@ export class A2UIRenderer {
116
122
  elements: new Map(),
117
123
  dataModel: {},
118
124
  bindings: new Map(),
125
+ // CSS channel parity with #createSurface (see spec §5.4).
126
+ adoptedSheets: new Map(),
119
127
  };
120
128
  this.#surfaces.set(surfaceId, surface);
129
+ // CSS channel: tag the container so the @scope wrapper has an anchor
130
+ // to match. Without this, synthetic surfaces (those created lazily by
131
+ // updateComponents) silently fail to receive their styles — the
132
+ // @scope wrapper is valid CSS but matches no element. The setAttribute
133
+ // is idempotent if the container already carries the attribute from a
134
+ // prior createSurface.
135
+ if (this.#container && typeof this.#container.setAttribute === 'function') {
136
+ this.#container.setAttribute('data-a2ui-surface', surfaceId);
137
+ }
121
138
  }
122
139
 
123
140
  // First pass: create/update elements
@@ -484,19 +501,291 @@ export class A2UIRenderer {
484
501
  if (!surface) return;
485
502
  this.#wiringEngine?.teardown(surfaceId);
486
503
  for (const [id] of surface.elements) this.#elements.delete(id);
504
+ // CSS channel: splice any adopted stylesheets out of the document.
505
+ if (surface.adoptedSheets && surface.adoptedSheets.size > 0) {
506
+ const toRemove = new Set();
507
+ for (const entry of surface.adoptedSheets.values()) toRemove.add(entry.sheet);
508
+ document.adoptedStyleSheets = document.adoptedStyleSheets
509
+ .filter(s => !toRemove.has(s));
510
+ surface.adoptedSheets.clear();
511
+ }
487
512
  surface.root.remove();
488
513
  this.#surfaces.delete(surfaceId);
489
514
  }
490
515
 
516
+ // ── CSS channel (updateStyles / removeStyles) ──
517
+ // Spec: docs/specs/genui-css-channel.md
518
+ // Implements Phase 1 of the gen-ui parallel-channels initiative.
519
+
520
+ // Forbidden top-level selector tokens — these would pollute document scope
521
+ // even after @scope wrapping (CSS @scope does not constrain @-rules like
522
+ // @charset; :root / html / body matches OUTSIDE the scope root would still
523
+ // be authored as document-targeting intent and are rejected up front).
524
+ static #CSS_FORBIDDEN_TOP_SELECTORS = new Set(['root', 'html', 'body']);
525
+
526
+ #updateStyles({ surfaceId, styleId, css }) {
527
+ if (typeof styleId !== 'string' || styleId.length === 0) {
528
+ this.#dispatchStylesEvent(null, 'styles-rejected',
529
+ { surfaceId, styleId, reason: 'invalid-payload: styleId must be a non-empty string' });
530
+ return;
531
+ }
532
+ if (typeof css !== 'string') {
533
+ this.#dispatchStylesEvent(null, 'styles-rejected',
534
+ { surfaceId, styleId, reason: 'invalid-payload: css must be a string' });
535
+ return;
536
+ }
537
+ const surface = this.#surfaces.get(surfaceId);
538
+ if (!surface) {
539
+ this.#dispatchStylesEvent(null, 'styles-rejected',
540
+ { surfaceId, styleId, reason: 'no-such-surface' });
541
+ return;
542
+ }
543
+
544
+ const result = this.#validateAndPrepareStyles(css, surfaceId);
545
+ if (result.rejected) {
546
+ // Prior valid sheet (if any) is preserved on rejection — see spec §4.7.
547
+ this.#dispatchStylesEvent(surface.root, 'styles-rejected',
548
+ { surfaceId, styleId, reason: result.reason });
549
+ return;
550
+ }
551
+
552
+ // Replace any existing sheet with this styleId
553
+ const prior = surface.adoptedSheets.get(styleId);
554
+ if (prior) {
555
+ document.adoptedStyleSheets = document.adoptedStyleSheets
556
+ .filter(s => s !== prior.sheet);
557
+ }
558
+
559
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, result.sheet];
560
+ surface.adoptedSheets.set(styleId, {
561
+ sheet: result.sheet,
562
+ ruleCount: result.sheet.cssRules.length,
563
+ appliedAt: typeof performance !== 'undefined' ? performance.now() : Date.now(),
564
+ });
565
+
566
+ this.#dispatchStylesEvent(surface.root, 'styles-applied', {
567
+ surfaceId,
568
+ styleId,
569
+ ruleCount: result.sheet.cssRules.length,
570
+ validationWarnings: result.warnings,
571
+ });
572
+ }
573
+
574
+ #removeStyles({ surfaceId, styleId }) {
575
+ const surface = this.#surfaces.get(surfaceId);
576
+ if (!surface) return;
577
+ const entry = surface.adoptedSheets.get(styleId);
578
+ if (!entry) return; // idempotent — no event, no error
579
+ document.adoptedStyleSheets = document.adoptedStyleSheets
580
+ .filter(s => s !== entry.sheet);
581
+ surface.adoptedSheets.delete(styleId);
582
+ this.#dispatchStylesEvent(surface.root, 'styles-removed', { surfaceId, styleId });
583
+ }
584
+
585
+ // Validate + prepare a stylesheet for adoption. Returns
586
+ // { rejected: true, reason } on failure
587
+ // { rejected: false, sheet, warnings } on success
588
+ // Spec: §4 (validator rules) + §6.3 (scope wrapping).
589
+ #validateAndPrepareStyles(css, surfaceId) {
590
+ const warnings = [];
591
+
592
+ // §4.2 — source-text checks for @-rules that some parsers strip
593
+ // before they appear as CSSRule objects (notably @import and @charset).
594
+ // Done FIRST so the reason is reported in priority order. Multiline
595
+ // mode handles css that contains the @-rule mid-source after whitespace.
596
+ if (/^\s*@import\b/im.test(css)) return { rejected: true, reason: 'forbidden-import' };
597
+ if (/^\s*@charset\b/im.test(css)) return { rejected: true, reason: 'forbidden-charset' };
598
+
599
+ // §4.1 — parse validation (initial probe)
600
+ const probe = new CSSStyleSheet();
601
+ try {
602
+ probe.replaceSync(css);
603
+ } catch (err) {
604
+ return { rejected: true, reason: 'parse-error: ' + (err?.message || String(err)) };
605
+ }
606
+
607
+ // §4.2 — forbidden constructs (scan top-level rules)
608
+ for (const rule of probe.cssRules) {
609
+ const forbidden = this.#scanForbiddenRule(rule);
610
+ if (forbidden) return { rejected: true, reason: forbidden };
611
+ }
612
+
613
+ // §4.2 — external URL scan (text-level — covers url() inside declarations)
614
+ if (/url\s*\(\s*["']?\s*(?:https?:|\/\/)/i.test(css)) {
615
+ return { rejected: true, reason: 'external-url' };
616
+ }
617
+
618
+ // §4.4 — @property warnings
619
+ for (const rule of probe.cssRules) {
620
+ if (rule.constructor?.name === 'CSSPropertyRule' || rule.cssText?.startsWith('@property')) {
621
+ const m = rule.cssText.match(/@property\s+(--[\w-]+)/);
622
+ if (m) warnings.push({ kind: 'property-registered-globally', name: m[1] });
623
+ }
624
+ }
625
+
626
+ // §4.5 — animation-name rewriting (mutates `probe`)
627
+ const renamedAnimations = this.#rewriteAnimationNames(probe, surfaceId);
628
+
629
+ // §6.3 — scope wrapping
630
+ const wrappedCss = this.#wrapInScope(probe, surfaceId);
631
+
632
+ // Final adoption sheet
633
+ const sheet = new CSSStyleSheet();
634
+ try {
635
+ sheet.replaceSync(wrappedCss);
636
+ } catch (err) {
637
+ return { rejected: true, reason: 'scope-wrap-failed: ' + (err?.message || String(err)) };
638
+ }
639
+ if (renamedAnimations.length > 0) {
640
+ warnings.push({ kind: 'animations-namespaced', count: renamedAnimations.length });
641
+ }
642
+ return { rejected: false, sheet, warnings };
643
+ }
644
+
645
+ // Scan a single top-level CSSRule for forbidden constructs. Returns the
646
+ // rejection reason string or null if the rule is clean.
647
+ #scanForbiddenRule(rule) {
648
+ const text = rule.cssText || '';
649
+ // @import, @charset, @scope (user-side) — keyed off cssText prefix
650
+ if (/^@import\b/i.test(text)) return 'forbidden-import';
651
+ if (/^@charset\b/i.test(text)) return 'forbidden-charset';
652
+ if (/^@scope\b/i.test(text)) return 'forbidden-scope-directive';
653
+ // Style rules (CSSStyleRule) — inspect selectorText for document-level targets
654
+ if (rule.selectorText) {
655
+ // Split selector list on commas, ignoring commas inside parens.
656
+ const selectors = this.#splitSelectors(rule.selectorText);
657
+ for (const sel of selectors) {
658
+ const trimmed = sel.trim().replace(/^:scope\s*/, ''); // :scope-prefixed is allowed
659
+ // ':root' parses as a pseudo-class — check FIRST (it doesn't match the
660
+ // tag-name regex below, but it's the single most common forbidden form).
661
+ if (trimmed.startsWith(':root')) return 'forbidden-root-selector';
662
+ // Match bare 'root', 'html', 'body' tokens as the leading simple selector
663
+ const lead = trimmed.match(/^([a-zA-Z*][\w-]*)/);
664
+ if (!lead) continue;
665
+ const tag = lead[1].toLowerCase();
666
+ if (A2UIRenderer.#CSS_FORBIDDEN_TOP_SELECTORS.has(tag)) return `forbidden-${tag}-selector`;
667
+ }
668
+ }
669
+ return null;
670
+ }
671
+
672
+ // Split a selector list on top-level commas (respects parens for :is(), :where(), :has()).
673
+ #splitSelectors(selectorText) {
674
+ const out = [];
675
+ let depth = 0;
676
+ let start = 0;
677
+ for (let i = 0; i < selectorText.length; i++) {
678
+ const ch = selectorText[i];
679
+ if (ch === '(') depth++;
680
+ else if (ch === ')') depth--;
681
+ else if (ch === ',' && depth === 0) {
682
+ out.push(selectorText.slice(start, i));
683
+ start = i + 1;
684
+ }
685
+ }
686
+ out.push(selectorText.slice(start));
687
+ return out;
688
+ }
689
+
690
+ // Rewrite @keyframes names with a <surfaceId>_ prefix and update any
691
+ // matching animation / animation-name declarations in the same sheet.
692
+ // Mutates `probe` in place via insertRule/deleteRule. Returns the list of
693
+ // renamed (original -> new) keyframes for the warnings payload.
694
+ #rewriteAnimationNames(probe, surfaceId) {
695
+ const renamed = [];
696
+ const safePrefix = String(surfaceId).replace(/[^a-zA-Z0-9_-]/g, '_');
697
+
698
+ // First pass — find @keyframes rules
699
+ for (let i = 0; i < probe.cssRules.length; i++) {
700
+ const rule = probe.cssRules[i];
701
+ if (rule.constructor?.name === 'CSSKeyframesRule' || rule.cssText?.startsWith('@keyframes')) {
702
+ const oldName = rule.name;
703
+ if (!oldName) continue;
704
+ const newName = `${safePrefix}_${oldName}`;
705
+ // CSSKeyframesRule has a writable .name in modern browsers
706
+ try {
707
+ rule.name = newName;
708
+ } catch {
709
+ // Fallback: rewrite via deleteRule + insertRule
710
+ const newText = rule.cssText.replace(/@keyframes\s+[\w-]+/, `@keyframes ${newName}`);
711
+ probe.deleteRule(i);
712
+ probe.insertRule(newText, i);
713
+ }
714
+ renamed.push({ from: oldName, to: newName });
715
+ }
716
+ }
717
+
718
+ if (renamed.length === 0) return renamed;
719
+
720
+ // Second pass — rewrite animation / animation-name declarations
721
+ const renameMap = new Map(renamed.map(r => [r.from, r.to]));
722
+ for (const rule of probe.cssRules) {
723
+ if (!rule.style) continue;
724
+ for (const prop of ['animation', 'animation-name']) {
725
+ const val = rule.style.getPropertyValue(prop);
726
+ if (!val) continue;
727
+ // Tokenize on whitespace / commas; substitute matching names
728
+ const rewritten = val.replace(/\b([\w-]+)\b/g, (match) =>
729
+ renameMap.has(match) ? renameMap.get(match) : match
730
+ );
731
+ if (rewritten !== val) rule.style.setProperty(prop, rewritten);
732
+ }
733
+ }
734
+ return renamed;
735
+ }
736
+
737
+ // Wrap the probe's rules in @scope ([data-a2ui-surface="<id>"]) { ... }.
738
+ // Returns the new CSS source as a string (re-parsed into a fresh sheet
739
+ // by the caller).
740
+ #wrapInScope(probe, surfaceId) {
741
+ const inner = [];
742
+ for (const rule of probe.cssRules) inner.push(rule.cssText);
743
+ const safeId = String(surfaceId).replace(/"/g, '\\"');
744
+ return `@scope ([data-a2ui-surface="${safeId}"]) {\n${inner.join('\n')}\n}`;
745
+ }
746
+
747
+ // Dispatch a CustomEvent for the CSS channel lifecycle. Bubbles from the
748
+ // surface root when present; falls back to the renderer #container when
749
+ // no surface root exists (e.g. updateStyles for unknown surfaceId).
750
+ #dispatchStylesEvent(target, eventName, detail) {
751
+ const node = target || this.#container;
752
+ if (!node || typeof CustomEvent === 'undefined') return;
753
+ try {
754
+ node.dispatchEvent(new CustomEvent(eventName, {
755
+ detail,
756
+ bubbles: true,
757
+ composed: false,
758
+ }));
759
+ } catch {
760
+ // Hard fail-silent — event dispatch should never break the renderer.
761
+ }
762
+ }
763
+
491
764
  // ── Public ──
492
765
 
493
766
  getSurface(id) { return this.#surfaces.get(id); }
494
767
  getElement(id) { return this.#elements.get(id); }
495
768
  get surfaces() { return [...this.#surfaces.keys()]; }
496
769
 
770
+ // CSS channel — read-only accessor for adopted stylesheets per surface.
771
+ // Returns a Map<styleId, { sheet, ruleCount, appliedAt }> or an empty Map.
772
+ getStylesheets(surfaceId) {
773
+ const surface = this.#surfaces.get(surfaceId);
774
+ return surface?.adoptedSheets ?? new Map();
775
+ }
776
+
497
777
  reset() {
498
778
  if (this.#rafId !== null) { cancelAnimationFrame(this.#rafId); this.#rafId = null; }
499
779
  this.#queue.length = 0;
780
+ // CSS channel — splice every surface's adopted stylesheets before tear-down
781
+ const allAdopted = new Set();
782
+ for (const [, s] of this.#surfaces) {
783
+ if (s.adoptedSheets) for (const entry of s.adoptedSheets.values()) allAdopted.add(entry.sheet);
784
+ }
785
+ if (allAdopted.size > 0) {
786
+ document.adoptedStyleSheets = document.adoptedStyleSheets
787
+ .filter(s => !allAdopted.has(s));
788
+ }
500
789
  for (const [, s] of this.#surfaces) {
501
790
  if (s.root === this.#container) s.root.innerHTML = '';
502
791
  else s.root.remove();