@adia-ai/a2ui-runtime 0.6.33 → 0.6.35
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 +12 -0
- package/css-channel.test.js +506 -0
- package/package.json +1 -1
- package/renderer.js +289 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog — @adia-ai/a2ui-runtime
|
|
2
2
|
|
|
3
|
+
## [0.6.35] — 2026-05-24
|
|
4
|
+
|
|
5
|
+
### Maintenance
|
|
6
|
+
|
|
7
|
+
- **Lockstep version bump only.** No source changes in this package; bumped to maintain 9-package coherence. Substantive v0.6.35: `@adia-ai/web-components` (Waves A+B+C — 14 P1 component implementations + FB-57/61/62/63 substrate sweep + class.js → <slug>.class.js refactor + spinner/calendar-grid fixes), `@adia-ai/web-modules` (FB-55 admin-shell warn + delegation refactor + Wave A+B+C cluster work), `@adia-ai/llm` (server.js SIGINT/SIGTERM handlers — dev-only fix), `@adia-ai/a2ui-corpus` (catalog regen absorbing FB-61/62/63 yaml backfills).
|
|
8
|
+
|
|
9
|
+
## [0.6.34] — 2026-05-23
|
|
10
|
+
|
|
11
|
+
### Added — Phase 1 CSS channel: `updateStyles` + `removeStyles`
|
|
12
|
+
|
|
13
|
+
- **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`.
|
|
14
|
+
|
|
3
15
|
## [0.6.33] — 2026-05-23
|
|
4
16
|
|
|
5
17
|
### 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.
|
|
3
|
+
"version": "0.6.35",
|
|
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();
|