@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.
- package/CHANGELOG.md +1073 -0
- package/README.md +99 -0
- package/a2ui.schema.d.ts +192 -0
- package/controllers/accordion.js +73 -0
- package/controllers/base.js +68 -0
- package/controllers/data-stream.js +281 -0
- package/controllers/form.js +81 -0
- package/controllers/index.js +6 -0
- package/controllers/selection.js +82 -0
- package/controllers/state-machine.js +135 -0
- package/controllers/toggle.js +40 -0
- package/dockables/action.d.ts +55 -0
- package/dockables/action.js +152 -0
- package/dockables/base.d.ts +26 -0
- package/dockables/base.js +30 -0
- package/dockables/controller.d.ts +35 -0
- package/dockables/controller.js +97 -0
- package/dockables/data-source.d.ts +35 -0
- package/dockables/data-source.js +103 -0
- package/dockables/index.d.ts +21 -0
- package/dockables/index.js +6 -0
- package/dockables/lifecycle.d.ts +38 -0
- package/dockables/lifecycle.js +84 -0
- package/dockables/provider.d.ts +28 -0
- package/dockables/provider.js +59 -0
- package/index.d.ts +64 -0
- package/index.js +54 -0
- package/package.json +89 -0
- package/prop-apply.d.ts +13 -0
- package/prop-apply.js +113 -0
- package/registry.d.ts +17 -0
- package/registry.js +418 -0
- package/renderer.d.ts +67 -0
- package/renderer.js +715 -0
- package/stream.d.ts +62 -0
- package/stream.js +521 -0
- package/surface-manifest.d.ts +73 -0
- package/surface-manifest.js +294 -0
- package/surface.d.ts +72 -0
- package/surface.js +222 -0
- package/types.d.ts +26 -0
- package/validate/CHANGELOG.md +1005 -0
- package/validate/README.md +146 -0
- package/validate/index.d.ts +4 -0
- package/validate/index.js +12 -0
- package/validate/validator.d.ts +4 -0
- package/validate/validator.js +1232 -0
- package/wire-factory.d.ts +15 -0
- package/wire-factory.js +134 -0
- package/wiring-engine.d.ts +61 -0
- package/wiring-engine.js +209 -0
- package/wiring-registry.d.ts +80 -0
- 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
|
+
}
|