@adia-ai/a2ui 0.8.37

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +1073 -0
  2. package/README.md +99 -0
  3. package/a2ui.schema.d.ts +192 -0
  4. package/controllers/accordion.js +73 -0
  5. package/controllers/base.js +68 -0
  6. package/controllers/data-stream.js +281 -0
  7. package/controllers/form.js +81 -0
  8. package/controllers/index.js +6 -0
  9. package/controllers/selection.js +82 -0
  10. package/controllers/state-machine.js +135 -0
  11. package/controllers/toggle.js +40 -0
  12. package/dockables/action.d.ts +55 -0
  13. package/dockables/action.js +152 -0
  14. package/dockables/base.d.ts +26 -0
  15. package/dockables/base.js +30 -0
  16. package/dockables/controller.d.ts +35 -0
  17. package/dockables/controller.js +97 -0
  18. package/dockables/data-source.d.ts +35 -0
  19. package/dockables/data-source.js +103 -0
  20. package/dockables/index.d.ts +21 -0
  21. package/dockables/index.js +6 -0
  22. package/dockables/lifecycle.d.ts +38 -0
  23. package/dockables/lifecycle.js +84 -0
  24. package/dockables/provider.d.ts +28 -0
  25. package/dockables/provider.js +59 -0
  26. package/index.d.ts +64 -0
  27. package/index.js +54 -0
  28. package/package.json +89 -0
  29. package/prop-apply.d.ts +13 -0
  30. package/prop-apply.js +113 -0
  31. package/registry.d.ts +17 -0
  32. package/registry.js +418 -0
  33. package/renderer.d.ts +67 -0
  34. package/renderer.js +715 -0
  35. package/stream.d.ts +62 -0
  36. package/stream.js +521 -0
  37. package/surface-manifest.d.ts +73 -0
  38. package/surface-manifest.js +294 -0
  39. package/surface.d.ts +72 -0
  40. package/surface.js +222 -0
  41. package/types.d.ts +26 -0
  42. package/validate/CHANGELOG.md +1005 -0
  43. package/validate/README.md +146 -0
  44. package/validate/index.d.ts +4 -0
  45. package/validate/index.js +12 -0
  46. package/validate/validator.d.ts +4 -0
  47. package/validate/validator.js +1232 -0
  48. package/wire-factory.d.ts +15 -0
  49. package/wire-factory.js +134 -0
  50. package/wiring-engine.d.ts +61 -0
  51. package/wiring-engine.js +209 -0
  52. package/wiring-registry.d.ts +80 -0
  53. package/wiring-registry.js +342 -0
package/renderer.js ADDED
@@ -0,0 +1,715 @@
1
+ /**
2
+ * A2UI Renderer — processes A2UI messages and renders AdiaUI components.
3
+ *
4
+ * Usage:
5
+ * import { A2UIRenderer } from './renderer.js';
6
+ * const renderer = new A2UIRenderer(container);
7
+ * renderer.process(message);
8
+ */
9
+
10
+ import { resolveTag, registry } from './registry.js';
11
+ import { applyResolvedProp, toAttr } from './prop-apply.js';
12
+
13
+ export class A2UIRenderer {
14
+ #container;
15
+ #registry;
16
+ #surfaces = new Map();
17
+ #elements = new Map();
18
+ #prevProps = new Map();
19
+ #queue = [];
20
+ #rafId = null;
21
+ #batching = false;
22
+
23
+ constructor(container, reg = registry, { batch = false } = {}) {
24
+ this.#container = container;
25
+ this.#registry = reg;
26
+ this.#batching = batch;
27
+ }
28
+
29
+ process(message) {
30
+ if (!message) return;
31
+ if (this.#batching) {
32
+ this.#queue.push(message);
33
+ if (this.#rafId === null) {
34
+ this.#rafId = requestAnimationFrame(() => this.#flush());
35
+ }
36
+ } else {
37
+ this.#processOne(message);
38
+ }
39
+ }
40
+
41
+ #flush() {
42
+ this.#rafId = null;
43
+ const batch = this.#queue.splice(0);
44
+ for (const msg of batch) this.#processOne(msg);
45
+ }
46
+
47
+ #processOne(message) {
48
+ switch (message.type || message.messageType) {
49
+ case 'createSurface': this.#createSurface(message); break;
50
+ case 'updateComponents': this.#updateComponents(message); break;
51
+ case 'updateDataModel': this.#updateDataModel(message); break;
52
+ case 'wireComponents': this.#wireComponents(message); break;
53
+ case 'deleteSurface': this.#deleteSurface(message); break;
54
+ case 'updateStyles': this.#updateStyles(message); break;
55
+ case 'removeStyles': this.#removeStyles(message); break;
56
+ case 'meta': break; // LLM self-critique — not renderable
57
+ default: console.warn('A2UI: unknown message type', message.type);
58
+ }
59
+ }
60
+
61
+ // ── wireComponents (lazy-loaded) ──
62
+
63
+ #wiringEngine = null;
64
+
65
+ async #wireComponents(message) {
66
+ if (!this.#wiringEngine) {
67
+ const { WiringEngine } = await import('./wiring-engine.js');
68
+ this.#wiringEngine = new WiringEngine({
69
+ updateDataModel: (surfaceId, path, data) => {
70
+ this.#updateDataModel({ surfaceId, path, value: data });
71
+ },
72
+ getElement: (surfaceId, componentId) => {
73
+ const surface = this.#surfaces.get(surfaceId);
74
+ return surface?.elements.get(componentId) || null;
75
+ },
76
+ });
77
+ }
78
+ await this.#wiringEngine.process(message);
79
+ }
80
+
81
+ async processStream(stream) {
82
+ for await (const message of stream) this.process(message);
83
+ }
84
+
85
+ // ── createSurface ──
86
+
87
+ #createSurface({ surfaceId, catalogId, root: rootId }) {
88
+ if (this.#surfaces.has(surfaceId)) return;
89
+
90
+ const root = document.createElement('div');
91
+ root.setAttribute('data-a2ui-surface', surfaceId);
92
+ if (catalogId) root.setAttribute('data-catalog', catalogId);
93
+ this.#container.appendChild(root);
94
+
95
+ this.#surfaces.set(surfaceId, {
96
+ root,
97
+ // `rootId` = the id of the component to attach as the surface's
98
+ // rendered root. Honors the A2UI protocol's `createSurface.root`
99
+ // field when present; falls back to the legacy `'root'` convention
100
+ // that earlier docs baked in. See #updateComponents attach step.
101
+ rootId: typeof rootId === 'string' && rootId ? rootId : 'root',
102
+ elements: new Map(),
103
+ dataModel: {},
104
+ bindings: new Map(),
105
+ // CSS channel (Phase 1): styleId -> { sheet, ruleCount, appliedAt }.
106
+ // Populated by #updateStyles; cleared by #removeStyles / #deleteSurface.
107
+ // See .claude/docs/specs/genui-css-channel.md §5.4.
108
+ adoptedSheets: new Map(),
109
+ });
110
+ }
111
+
112
+ // ── updateComponents ──
113
+
114
+ #updateComponents({ surfaceId, components }) {
115
+ let surface = this.#surfaces.get(surfaceId);
116
+
117
+ if (!surface) {
118
+ surface = {
119
+ root: this.#container,
120
+ // Synthetic surfaces (updateComponents without a prior
121
+ // createSurface) default to the legacy 'root' convention.
122
+ rootId: 'root',
123
+ elements: new Map(),
124
+ dataModel: {},
125
+ bindings: new Map(),
126
+ // CSS channel parity with #createSurface (see spec §5.4).
127
+ adoptedSheets: new Map(),
128
+ };
129
+ this.#surfaces.set(surfaceId, surface);
130
+ // CSS channel: tag the container so the @scope wrapper has an anchor
131
+ // to match. Without this, synthetic surfaces (those created lazily by
132
+ // updateComponents) silently fail to receive their styles — the
133
+ // @scope wrapper is valid CSS but matches no element. The setAttribute
134
+ // is idempotent if the container already carries the attribute from a
135
+ // prior createSurface.
136
+ if (this.#container && typeof this.#container.setAttribute === 'function') {
137
+ this.#container.setAttribute('data-a2ui-surface', surfaceId);
138
+ }
139
+ }
140
+
141
+ // First pass: create/update elements
142
+ for (const comp of components) {
143
+ if (!comp.id && comp.id !== 0) continue;
144
+ if (comp.component === 'ContextBindings') {
145
+ surface.contextBindings = comp;
146
+ continue;
147
+ }
148
+
149
+ try {
150
+ let el = surface.elements.get(comp.id);
151
+ const tagName = comp.component === 'Text'
152
+ ? this.#resolveTextTag(comp.variant, comp)
153
+ : resolveTag(comp.component, this.#registry);
154
+
155
+ if (!tagName) {
156
+ if (!el) {
157
+ el = document.createElement('div');
158
+ el.setAttribute('data-a2ui-id', comp.id);
159
+ el.id = comp.id;
160
+ el.setAttribute('data-a2ui-unknown', comp.component);
161
+ el.textContent = `[unknown: ${comp.component}]`;
162
+ surface.elements.set(comp.id, el);
163
+ this.#elements.set(comp.id, el);
164
+ }
165
+ continue;
166
+ }
167
+
168
+ if (!el) {
169
+ el = document.createElement(tagName);
170
+ el.setAttribute('data-a2ui-id', comp.id);
171
+ el.id = comp.id;
172
+ surface.elements.set(comp.id, el);
173
+ this.#elements.set(comp.id, el);
174
+ } else if (el.localName !== tagName) {
175
+ const newEl = document.createElement(tagName);
176
+ newEl.setAttribute('data-a2ui-id', comp.id);
177
+ newEl.id = comp.id;
178
+ el.replaceWith(newEl);
179
+ el = newEl;
180
+ surface.elements.set(comp.id, el);
181
+ this.#elements.set(comp.id, el);
182
+ }
183
+
184
+ comp._surfaceId = surfaceId;
185
+
186
+ const hasBindings = Object.values(comp).some(v => v && typeof v === 'object' && v.path);
187
+ if (hasBindings) surface.bindings.set(comp.id, comp);
188
+
189
+ this.#applyProps(el, comp);
190
+ } catch (err) {
191
+ console.warn(`A2UI: component "${comp.id}" (${comp.component}) failed:`, err.message);
192
+ }
193
+ }
194
+
195
+ // Structural validation: warn when Card/Drawer body content skips
196
+ // the canonical <Section> wrap. Mirrors scripts/audit/audit-card-structure.mjs
197
+ // (which scans authored HTML) — this hook catches runtime-built trees
198
+ // where the audit can't see. See feedback_card_drawer_body_section_wrap
199
+ // memory + packages/web-components/components/{card,drawer}/yaml.
200
+ this.#validateContainerStructure(components);
201
+
202
+ // Build parent map for cycle detection
203
+ const parentMap = new Map();
204
+ for (const comp of components) {
205
+ for (const childId of (Array.isArray(comp.children) ? comp.children : [])) {
206
+ parentMap.set(childId, comp.id);
207
+ }
208
+ if (comp.child) parentMap.set(comp.child, comp.id);
209
+ }
210
+
211
+ const wouldCycle = (parentId, childId) => {
212
+ const visited = new Set();
213
+ let cursor = parentId;
214
+ while (cursor != null) {
215
+ if (cursor === childId) return true;
216
+ if (visited.has(cursor)) return true;
217
+ visited.add(cursor);
218
+ cursor = parentMap.get(cursor);
219
+ }
220
+ return false;
221
+ };
222
+
223
+ // Second pass: build tree
224
+ for (const comp of components) {
225
+ const el = surface.elements.get(comp.id);
226
+ if (!el) continue;
227
+
228
+ try {
229
+ const childIds = Array.isArray(comp.children) ? comp.children : [];
230
+ for (const childId of childIds) {
231
+ if (childId === comp.id || wouldCycle(comp.id, childId)) continue;
232
+ const childEl = surface.elements.get(childId);
233
+ if (childEl && childEl.parentElement !== el) el.appendChild(childEl);
234
+ }
235
+
236
+ if (comp.child && comp.child !== comp.id && !wouldCycle(comp.id, comp.child)) {
237
+ const childEl = surface.elements.get(comp.child);
238
+ if (childEl && childEl.parentElement !== el) el.appendChild(childEl);
239
+ }
240
+ } catch (err) {
241
+ console.warn(`A2UI: tree build failed for "${comp.id}":`, err.message);
242
+ }
243
+ }
244
+
245
+ // Attach root — use the surface's declared rootId (from
246
+ // createSurface.root) if present, else fall back to the legacy
247
+ // 'root' convention. Both surface.rootId and the fallback are
248
+ // looked up in the same elements Map.
249
+ const attachId = surface.rootId ?? 'root';
250
+ const rootComp = components.find(c => c.id === attachId);
251
+ if (rootComp) {
252
+ const rootEl = surface.elements.get(attachId);
253
+ if (rootEl && rootEl.parentElement !== surface.root) {
254
+ surface.root.appendChild(rootEl);
255
+ }
256
+ }
257
+ }
258
+
259
+ // ── Structural validation (Card / Drawer body contract) ──
260
+
261
+ static #CARD_CANONICAL_CHILDREN = new Set([
262
+ 'Header', 'Section', 'Footer',
263
+ // Media-first siblings (mirrors audit-card-structure.mjs whitelist):
264
+ // void/media elements may sit as siblings of header/section/footer.
265
+ 'Img', 'Image', 'Video', 'Picture', 'Iframe',
266
+ // Layout siblings:
267
+ // Aside — "card with side nav" pattern per aside.yaml.
268
+ // Divider — visual rule between sibling content blocks (pricing-tier
269
+ // pattern in eval-025).
270
+ 'Aside', 'Divider',
271
+ ]);
272
+
273
+ static #DRAWER_CANONICAL_CHILDREN = new Set([
274
+ 'Header', 'Section', 'Footer',
275
+ ]);
276
+
277
+ // Slot values that opt a child into a canonical body slot
278
+ // (drawer-ui supports explicit [slot="header|body|footer"]; card-ui
279
+ // accepts header/section/footer children for the same slots).
280
+ static #CANONICAL_SLOT_VALUES = new Set(['header', 'body', 'footer']);
281
+
282
+ #warnedStructure = new Set();
283
+
284
+ #validateContainerStructure(components) {
285
+ const componentsById = new Map();
286
+ for (const comp of components) {
287
+ if (comp.id != null) componentsById.set(comp.id, comp);
288
+ }
289
+
290
+ for (const comp of components) {
291
+ const isCard = comp.component === 'Card' || comp.component === 'ErrorContainer';
292
+ const isDrawer = comp.component === 'Drawer';
293
+ if (!isCard && !isDrawer) continue;
294
+
295
+ const allowed = isDrawer
296
+ ? A2UIRenderer.#DRAWER_CANONICAL_CHILDREN
297
+ : A2UIRenderer.#CARD_CANONICAL_CHILDREN;
298
+ const containerLabel = isDrawer ? '<drawer-ui>' : '<card-ui>';
299
+
300
+ const childIds = Array.isArray(comp.children) ? comp.children : [];
301
+ for (const childId of childIds) {
302
+ const child = componentsById.get(childId);
303
+ if (!child) continue;
304
+ // Allow if child has slot attribute matching canonical body slots
305
+ if (typeof child.slot === 'string'
306
+ && A2UIRenderer.#CANONICAL_SLOT_VALUES.has(child.slot)) continue;
307
+ if (allowed.has(child.component)) continue;
308
+
309
+ const key = `${comp.id}::${childId}::${child.component}`;
310
+ if (this.#warnedStructure.has(key)) continue;
311
+ this.#warnedStructure.add(key);
312
+ console.warn(
313
+ `A2UI: ${containerLabel} (id="${comp.id}") body must wrap "${child.component}" ` +
314
+ `(id="${childId}") in <Section>. Direct flow children bypass the canonical body ` +
315
+ `slot and lose --card-inset margin. Canonical children: ${[...allowed].join(', ')}` +
316
+ (isDrawer ? `, or slot="header|body|footer".` : `.`)
317
+ );
318
+ }
319
+ }
320
+ }
321
+
322
+ // ── Apply props ──
323
+
324
+ // Semantic HTML variants — these render as native tags (h1, p, small)
325
+ // Everything else renders as <text-ui> which is now display:inline
326
+ static #TEXT_TAG_MAP = {
327
+ h1: 'h1', h2: 'h2', h3: 'h3', h4: 'h4', h5: 'h5', h6: 'h6',
328
+ body: 'p', caption: 'small',
329
+ };
330
+
331
+ #resolveTextTag(variant, comp) {
332
+ // Semantic HTML variants get native tags (accessibility)
333
+ if (!comp?.slot && A2UIRenderer.#TEXT_TAG_MAP[variant]) {
334
+ return A2UIRenderer.#TEXT_TAG_MAP[variant];
335
+ }
336
+ // Everything else → text-ui (inline, supports truncate/lines/variant)
337
+ return 'text-ui';
338
+ }
339
+
340
+ #applyProps(el, comp) {
341
+ const skip = new Set(['id', 'component', 'children', 'child', '_surfaceId']);
342
+ // Skip variant attr when the tag IS the variant (h1 for h1, p for body, small for caption)
343
+ if (comp.component === 'Text' && comp.variant && !comp.slot && A2UIRenderer.#TEXT_TAG_MAP[comp.variant]) {
344
+ skip.add('variant');
345
+ }
346
+
347
+ const prev = this.#prevProps.get(comp.id);
348
+ const next = {};
349
+
350
+ for (const [key, value] of Object.entries(comp)) {
351
+ if (skip.has(key)) continue;
352
+
353
+ const isBinding = value && typeof value === 'object' && value.path;
354
+ const resolved = this.#resolveValue(value, comp._surfaceId);
355
+ next[key] = resolved;
356
+
357
+ if (prev && Object.is(prev[key], resolved)) continue;
358
+
359
+ if (resolved == null && !isBinding) continue; // no value, no binding to clear either
360
+
361
+ applyResolvedProp(el, key, resolved);
362
+ }
363
+
364
+ if (prev) {
365
+ for (const key of Object.keys(prev)) {
366
+ if (!(key in next) && !skip.has(key)) el.removeAttribute(toAttr(key));
367
+ }
368
+ }
369
+
370
+ this.#prevProps.set(comp.id, next);
371
+ }
372
+
373
+ #resolveValue(value, surfaceId) {
374
+ if (value == null) return null;
375
+ if (typeof value !== 'object') return value;
376
+ if (value.path) {
377
+ const surface = surfaceId ? this.#surfaces.get(surfaceId) : null;
378
+ return surface ? this.#getByPath(surface.dataModel, value.path) : value.path;
379
+ }
380
+ return value;
381
+ }
382
+
383
+ #getByPath(obj, path) {
384
+ if (!path || path === '/') return obj;
385
+ return path.split('/').filter(Boolean).reduce((o, k) => o?.[k], obj);
386
+ }
387
+
388
+ // ── updateDataModel ──
389
+
390
+ #updateDataModel({ surfaceId, path, value }) {
391
+ const surface = this.#surfaces.get(surfaceId);
392
+ if (!surface) return;
393
+
394
+ if (!path || path === '/') {
395
+ surface.dataModel = value ?? {};
396
+ } else {
397
+ const parts = path.split('/').filter(Boolean);
398
+ let cur = surface.dataModel;
399
+ for (let i = 0; i < parts.length - 1; i++) {
400
+ if (cur[parts[i]] == null) cur[parts[i]] = {};
401
+ cur = cur[parts[i]];
402
+ }
403
+ cur[parts[parts.length - 1]] = value;
404
+ }
405
+
406
+ for (const [compId, comp] of surface.bindings) {
407
+ const el = surface.elements.get(compId);
408
+ if (el) this.#applyProps(el, comp);
409
+ }
410
+ }
411
+
412
+ // ── deleteSurface ──
413
+
414
+ #deleteSurface({ surfaceId }) {
415
+ const surface = this.#surfaces.get(surfaceId);
416
+ if (!surface) return;
417
+ this.#wiringEngine?.teardown(surfaceId);
418
+ for (const [id] of surface.elements) this.#elements.delete(id);
419
+ // CSS channel: splice any adopted stylesheets out of the document.
420
+ if (surface.adoptedSheets && surface.adoptedSheets.size > 0) {
421
+ const toRemove = new Set();
422
+ for (const entry of surface.adoptedSheets.values()) toRemove.add(entry.sheet);
423
+ document.adoptedStyleSheets = document.adoptedStyleSheets
424
+ .filter(s => !toRemove.has(s));
425
+ surface.adoptedSheets.clear();
426
+ }
427
+ surface.root.remove();
428
+ this.#surfaces.delete(surfaceId);
429
+ }
430
+
431
+ // ── CSS channel (updateStyles / removeStyles) ──
432
+ // Spec: .claude/docs/specs/genui-css-channel.md
433
+ // Implements Phase 1 of the gen-ui parallel-channels initiative.
434
+
435
+ // Forbidden top-level selector tokens — these would pollute document scope
436
+ // even after @scope wrapping (CSS @scope does not constrain @-rules like
437
+ // @charset; :root / html / body matches OUTSIDE the scope root would still
438
+ // be authored as document-targeting intent and are rejected up front).
439
+ static #CSS_FORBIDDEN_TOP_SELECTORS = new Set(['root', 'html', 'body']);
440
+
441
+ #updateStyles({ surfaceId, styleId, css }) {
442
+ if (typeof styleId !== 'string' || styleId.length === 0) {
443
+ this.#dispatchStylesEvent(null, 'styles-rejected',
444
+ { surfaceId, styleId, reason: 'invalid-payload: styleId must be a non-empty string' });
445
+ return;
446
+ }
447
+ if (typeof css !== 'string') {
448
+ this.#dispatchStylesEvent(null, 'styles-rejected',
449
+ { surfaceId, styleId, reason: 'invalid-payload: css must be a string' });
450
+ return;
451
+ }
452
+ const surface = this.#surfaces.get(surfaceId);
453
+ if (!surface) {
454
+ this.#dispatchStylesEvent(null, 'styles-rejected',
455
+ { surfaceId, styleId, reason: 'no-such-surface' });
456
+ return;
457
+ }
458
+
459
+ const result = this.#validateAndPrepareStyles(css, surfaceId);
460
+ if (result.rejected) {
461
+ // Prior valid sheet (if any) is preserved on rejection — see spec §4.7.
462
+ this.#dispatchStylesEvent(surface.root, 'styles-rejected',
463
+ { surfaceId, styleId, reason: result.reason });
464
+ return;
465
+ }
466
+
467
+ // Replace any existing sheet with this styleId
468
+ const prior = surface.adoptedSheets.get(styleId);
469
+ if (prior) {
470
+ document.adoptedStyleSheets = document.adoptedStyleSheets
471
+ .filter(s => s !== prior.sheet);
472
+ }
473
+
474
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, result.sheet];
475
+ surface.adoptedSheets.set(styleId, {
476
+ sheet: result.sheet,
477
+ ruleCount: result.sheet.cssRules.length,
478
+ appliedAt: typeof performance !== 'undefined' ? performance.now() : Date.now(),
479
+ });
480
+
481
+ this.#dispatchStylesEvent(surface.root, 'styles-applied', {
482
+ surfaceId,
483
+ styleId,
484
+ ruleCount: result.sheet.cssRules.length,
485
+ validationWarnings: result.warnings,
486
+ });
487
+ }
488
+
489
+ #removeStyles({ surfaceId, styleId }) {
490
+ const surface = this.#surfaces.get(surfaceId);
491
+ if (!surface) return;
492
+ const entry = surface.adoptedSheets.get(styleId);
493
+ if (!entry) return; // idempotent — no event, no error
494
+ document.adoptedStyleSheets = document.adoptedStyleSheets
495
+ .filter(s => s !== entry.sheet);
496
+ surface.adoptedSheets.delete(styleId);
497
+ this.#dispatchStylesEvent(surface.root, 'styles-removed', { surfaceId, styleId });
498
+ }
499
+
500
+ // Validate + prepare a stylesheet for adoption. Returns
501
+ // { rejected: true, reason } on failure
502
+ // { rejected: false, sheet, warnings } on success
503
+ // Spec: §4 (validator rules) + §6.3 (scope wrapping).
504
+ #validateAndPrepareStyles(css, surfaceId) {
505
+ const warnings = [];
506
+
507
+ // §4.2 — source-text checks for @-rules that some parsers strip
508
+ // before they appear as CSSRule objects (notably @import and @charset).
509
+ // Done FIRST so the reason is reported in priority order. Multiline
510
+ // mode handles css that contains the @-rule mid-source after whitespace.
511
+ if (/^\s*@import\b/im.test(css)) return { rejected: true, reason: 'forbidden-import' };
512
+ if (/^\s*@charset\b/im.test(css)) return { rejected: true, reason: 'forbidden-charset' };
513
+
514
+ // §4.1 — parse validation (initial probe)
515
+ const probe = new CSSStyleSheet();
516
+ try {
517
+ probe.replaceSync(css);
518
+ } catch (err) {
519
+ return { rejected: true, reason: 'parse-error: ' + (err?.message || String(err)) };
520
+ }
521
+
522
+ // §4.2 — forbidden constructs (scan top-level rules)
523
+ for (const rule of probe.cssRules) {
524
+ const forbidden = this.#scanForbiddenRule(rule);
525
+ if (forbidden) return { rejected: true, reason: forbidden };
526
+ }
527
+
528
+ // §4.2 — external URL scan (text-level — covers url() inside declarations)
529
+ if (/url\s*\(\s*["']?\s*(?:https?:|\/\/)/i.test(css)) {
530
+ return { rejected: true, reason: 'external-url' };
531
+ }
532
+
533
+ // §4.4 — @property warnings
534
+ for (const rule of probe.cssRules) {
535
+ if (rule.constructor?.name === 'CSSPropertyRule' || rule.cssText?.startsWith('@property')) {
536
+ const m = rule.cssText.match(/@property\s+(--[\w-]+)/);
537
+ if (m) warnings.push({ kind: 'property-registered-globally', name: m[1] });
538
+ }
539
+ }
540
+
541
+ // §4.5 — animation-name rewriting (mutates `probe`)
542
+ const renamedAnimations = this.#rewriteAnimationNames(probe, surfaceId);
543
+
544
+ // §6.3 — scope wrapping
545
+ const wrappedCss = this.#wrapInScope(probe, surfaceId);
546
+
547
+ // Final adoption sheet
548
+ const sheet = new CSSStyleSheet();
549
+ try {
550
+ sheet.replaceSync(wrappedCss);
551
+ } catch (err) {
552
+ return { rejected: true, reason: 'scope-wrap-failed: ' + (err?.message || String(err)) };
553
+ }
554
+ if (renamedAnimations.length > 0) {
555
+ warnings.push({ kind: 'animations-namespaced', count: renamedAnimations.length });
556
+ }
557
+ return { rejected: false, sheet, warnings };
558
+ }
559
+
560
+ // Scan a single top-level CSSRule for forbidden constructs. Returns the
561
+ // rejection reason string or null if the rule is clean.
562
+ #scanForbiddenRule(rule) {
563
+ const text = rule.cssText || '';
564
+ // @import, @charset, @scope (user-side) — keyed off cssText prefix
565
+ if (/^@import\b/i.test(text)) return 'forbidden-import';
566
+ if (/^@charset\b/i.test(text)) return 'forbidden-charset';
567
+ if (/^@scope\b/i.test(text)) return 'forbidden-scope-directive';
568
+ // Style rules (CSSStyleRule) — inspect selectorText for document-level targets
569
+ if (rule.selectorText) {
570
+ // Split selector list on commas, ignoring commas inside parens.
571
+ const selectors = this.#splitSelectors(rule.selectorText);
572
+ for (const sel of selectors) {
573
+ const trimmed = sel.trim().replace(/^:scope\s*/, ''); // :scope-prefixed is allowed
574
+ // ':root' parses as a pseudo-class — check FIRST (it doesn't match the
575
+ // tag-name regex below, but it's the single most common forbidden form).
576
+ if (trimmed.startsWith(':root')) return 'forbidden-root-selector';
577
+ // Match bare 'root', 'html', 'body' tokens as the leading simple selector
578
+ const lead = trimmed.match(/^([a-zA-Z*][\w-]*)/);
579
+ if (!lead) continue;
580
+ const tag = lead[1].toLowerCase();
581
+ if (A2UIRenderer.#CSS_FORBIDDEN_TOP_SELECTORS.has(tag)) return `forbidden-${tag}-selector`;
582
+ }
583
+ }
584
+ return null;
585
+ }
586
+
587
+ // Split a selector list on top-level commas (respects parens for :is(), :where(), :has()).
588
+ #splitSelectors(selectorText) {
589
+ const out = [];
590
+ let depth = 0;
591
+ let start = 0;
592
+ for (let i = 0; i < selectorText.length; i++) {
593
+ const ch = selectorText[i];
594
+ if (ch === '(') depth++;
595
+ else if (ch === ')') depth--;
596
+ else if (ch === ',' && depth === 0) {
597
+ out.push(selectorText.slice(start, i));
598
+ start = i + 1;
599
+ }
600
+ }
601
+ out.push(selectorText.slice(start));
602
+ return out;
603
+ }
604
+
605
+ // Rewrite @keyframes names with a <surfaceId>_ prefix and update any
606
+ // matching animation / animation-name declarations in the same sheet.
607
+ // Mutates `probe` in place via insertRule/deleteRule. Returns the list of
608
+ // renamed (original -> new) keyframes for the warnings payload.
609
+ #rewriteAnimationNames(probe, surfaceId) {
610
+ const renamed = [];
611
+ const safePrefix = String(surfaceId).replace(/[^a-zA-Z0-9_-]/g, '_');
612
+
613
+ // First pass — find @keyframes rules
614
+ for (let i = 0; i < probe.cssRules.length; i++) {
615
+ const rule = probe.cssRules[i];
616
+ if (rule.constructor?.name === 'CSSKeyframesRule' || rule.cssText?.startsWith('@keyframes')) {
617
+ const oldName = rule.name;
618
+ if (!oldName) continue;
619
+ const newName = `${safePrefix}_${oldName}`;
620
+ // CSSKeyframesRule has a writable .name in modern browsers
621
+ try {
622
+ rule.name = newName;
623
+ } catch {
624
+ // Fallback: rewrite via deleteRule + insertRule
625
+ const newText = rule.cssText.replace(/@keyframes\s+[\w-]+/, `@keyframes ${newName}`);
626
+ probe.deleteRule(i);
627
+ probe.insertRule(newText, i);
628
+ }
629
+ renamed.push({ from: oldName, to: newName });
630
+ }
631
+ }
632
+
633
+ if (renamed.length === 0) return renamed;
634
+
635
+ // Second pass — rewrite animation / animation-name declarations
636
+ const renameMap = new Map(renamed.map(r => [r.from, r.to]));
637
+ for (const rule of probe.cssRules) {
638
+ if (!rule.style) continue;
639
+ for (const prop of ['animation', 'animation-name']) {
640
+ const val = rule.style.getPropertyValue(prop);
641
+ if (!val) continue;
642
+ // Tokenize on whitespace / commas; substitute matching names
643
+ const rewritten = val.replace(/\b([\w-]+)\b/g, (match) =>
644
+ renameMap.has(match) ? renameMap.get(match) : match
645
+ );
646
+ if (rewritten !== val) rule.style.setProperty(prop, rewritten);
647
+ }
648
+ }
649
+ return renamed;
650
+ }
651
+
652
+ // Wrap the probe's rules in @scope ([data-a2ui-surface="<id>"]) { ... }.
653
+ // Returns the new CSS source as a string (re-parsed into a fresh sheet
654
+ // by the caller).
655
+ #wrapInScope(probe, surfaceId) {
656
+ const inner = [];
657
+ for (const rule of probe.cssRules) inner.push(rule.cssText);
658
+ const safeId = String(surfaceId).replace(/"/g, '\\"');
659
+ return `@scope ([data-a2ui-surface="${safeId}"]) {\n${inner.join('\n')}\n}`;
660
+ }
661
+
662
+ // Dispatch a CustomEvent for the CSS channel lifecycle. Bubbles from the
663
+ // surface root when present; falls back to the renderer #container when
664
+ // no surface root exists (e.g. updateStyles for unknown surfaceId).
665
+ #dispatchStylesEvent(target, eventName, detail) {
666
+ const node = target || this.#container;
667
+ if (!node || typeof CustomEvent === 'undefined') return;
668
+ try {
669
+ node.dispatchEvent(new CustomEvent(eventName, {
670
+ detail,
671
+ bubbles: true,
672
+ composed: false,
673
+ }));
674
+ } catch {
675
+ // Hard fail-silent — event dispatch should never break the renderer.
676
+ }
677
+ }
678
+
679
+ // ── Public ──
680
+
681
+ getSurface(id) { return this.#surfaces.get(id); }
682
+ getElement(id) { return this.#elements.get(id); }
683
+ get surfaces() { return [...this.#surfaces.keys()]; }
684
+
685
+ // CSS channel — read-only accessor for adopted stylesheets per surface.
686
+ // Returns a Map<styleId, { sheet, ruleCount, appliedAt }> or an empty Map.
687
+ getStylesheets(surfaceId) {
688
+ const surface = this.#surfaces.get(surfaceId);
689
+ return surface?.adoptedSheets ?? new Map();
690
+ }
691
+
692
+ reset() {
693
+ if (this.#rafId !== null) { cancelAnimationFrame(this.#rafId); this.#rafId = null; }
694
+ this.#queue.length = 0;
695
+ // CSS channel — splice every surface's adopted stylesheets before tear-down
696
+ const allAdopted = new Set();
697
+ for (const [, s] of this.#surfaces) {
698
+ if (s.adoptedSheets) for (const entry of s.adoptedSheets.values()) allAdopted.add(entry.sheet);
699
+ }
700
+ if (allAdopted.size > 0) {
701
+ document.adoptedStyleSheets = document.adoptedStyleSheets
702
+ .filter(s => !allAdopted.has(s));
703
+ }
704
+ for (const [, s] of this.#surfaces) {
705
+ if (s.root === this.#container) s.root.innerHTML = '';
706
+ else s.root.remove();
707
+ }
708
+ this.#surfaces.clear();
709
+ this.#elements.clear();
710
+ this.#prevProps.clear();
711
+ }
712
+
713
+ set batching(v) { this.#batching = !!v; }
714
+ get batching() { return this.#batching; }
715
+ }