@tmorrow/cre8-wc 2.0.8 → 2.1.0

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.
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Asserts that every layer describing the component library agrees.
4
+ *
5
+ * node a2ui/check-layer-parity.mjs
6
+ *
7
+ * The library is described six times over — the source that registers the
8
+ * elements, the MCP manifest, the A2UI catalog, the compact projection, the
9
+ * React manifest, and custom-elements.json — each generated by a different
10
+ * script from a different glob. Nothing forced them to agree, and they didn't:
11
+ * an analyzer glob one level too shallow dropped five components from all five
12
+ * derived layers at once, silently, for months.
13
+ *
14
+ * This is the check that makes that unmergeable. It compares the layers against
15
+ * each other rather than against a hardcoded expectation, so it keeps working as
16
+ * components are added.
17
+ *
18
+ * Deliberate omissions are legal but must be declared: `internalElements` in the
19
+ * manifest is the single place they live, and every one is re-verified here.
20
+ */
21
+
22
+ import { readFileSync, existsSync } from 'node:fs';
23
+ import { execSync } from 'node:child_process';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { dirname, join } from 'node:path';
26
+
27
+ const HERE = dirname(fileURLToPath(import.meta.url));
28
+ const WC = join(HERE, '..');
29
+ const REPO = join(WC, '..', '..');
30
+
31
+ const read = (p) => JSON.parse(readFileSync(p, 'utf8'));
32
+
33
+ const failures = [];
34
+ const notes = [];
35
+ function check(name, fn) {
36
+ try {
37
+ const note = fn();
38
+ if (note) notes.push(` ok ${name} — ${note}`);
39
+ else notes.push(` ok ${name}`);
40
+ } catch (err) {
41
+ failures.push(`FAIL ${name}\n ${err.message}`);
42
+ }
43
+ }
44
+
45
+ function assertSameSet(label, a, b, aName, bName) {
46
+ const missingFromB = [...a].filter((x) => !b.has(x)).sort();
47
+ const missingFromA = [...b].filter((x) => !a.has(x)).sort();
48
+ if (missingFromB.length || missingFromA.length) {
49
+ const parts = [];
50
+ if (missingFromB.length) parts.push(`in ${aName} but not ${bName}: ${missingFromB.join(', ')}`);
51
+ if (missingFromA.length) parts.push(`in ${bName} but not ${aName}: ${missingFromA.join(', ')}`);
52
+ throw new Error(`${label}\n ${parts.join('\n ')}`);
53
+ }
54
+ }
55
+
56
+ // ── Load every layer ─────────────────────────────────────────────────
57
+
58
+ const manifest = read(join(WC, 'mcp-manifest.json'));
59
+ const catalog = read(join(WC, 'a2ui', 'catalog.json'));
60
+ const compact = read(join(WC, 'a2ui', 'catalog.compact.json'));
61
+
62
+ const registered = new Set(
63
+ execSync(`grep -rho "customElements.define('[^']*'" "${join(WC, 'components')}" --include='*.ts' || true`)
64
+ .toString()
65
+ .split('\n')
66
+ .map((line) => (line.match(/'([^']+)'/) || [])[1])
67
+ .filter(Boolean)
68
+ );
69
+
70
+ const internal = new Set(manifest.internalElements ?? []);
71
+ const publicSource = new Set([...registered].filter((n) => !internal.has(n)));
72
+
73
+ const manifestNames = new Set(manifest.components.map((c) => c.name));
74
+ const catalogNames = new Set(Object.keys(catalog.$defs.components));
75
+ const compactNames = new Set(compact.components.map((c) => c.name));
76
+
77
+ // ── Component-set parity ─────────────────────────────────────────────
78
+
79
+ check('every registered element is accounted for', () => {
80
+ if (!registered.size) throw new Error('found no customElements.define calls — the grep is broken');
81
+ const unaccounted = [...registered].filter((n) => !manifestNames.has(n) && !internal.has(n));
82
+ if (unaccounted.length) {
83
+ throw new Error(
84
+ `element(s) register but appear in no layer and are not declared internal:\n` +
85
+ ` ${unaccounted.join(', ')}\n` +
86
+ ` Either export them, or add them to INTERNAL_ELEMENTS in scripts/generate-mcp-manifest.ts.`
87
+ );
88
+ }
89
+ return `${registered.size} registered, ${internal.size} declared internal`;
90
+ });
91
+
92
+ check('declared internals actually exist', () => {
93
+ const ghosts = [...internal].filter((n) => !registered.has(n));
94
+ if (ghosts.length) {
95
+ throw new Error(`declared internal but no longer registered: ${ghosts.join(', ')} — stale entry`);
96
+ }
97
+ const leaked = [...internal].filter((n) => manifestNames.has(n));
98
+ if (leaked.length) throw new Error(`declared internal but present in the manifest: ${leaked.join(', ')}`);
99
+ return internal.size ? [...internal].join(', ') : 'none';
100
+ });
101
+
102
+ check('source (minus internals) == manifest', () =>
103
+ assertSameSet('component sets differ', publicSource, manifestNames, 'source', 'manifest'));
104
+
105
+ check('manifest == catalog', () =>
106
+ assertSameSet('component sets differ', manifestNames, catalogNames, 'manifest', 'catalog'));
107
+
108
+ check('catalog == compact', () =>
109
+ assertSameSet('component sets differ', catalogNames, compactNames, 'catalog', 'compact'));
110
+
111
+ const reactPath = join(WC, 'react-manifest.json');
112
+ check('catalog == react manifest', () => {
113
+ if (!existsSync(reactPath)) throw new Error('react-manifest.json is missing');
114
+ const react = read(reactPath);
115
+ const names = new Set((react.components ?? []).map((c) => c.tagName ?? c.name));
116
+ assertSameSet('component sets differ', catalogNames, names, 'catalog', 'react');
117
+ });
118
+
119
+ const cemPath = join(REPO, 'custom-elements.json');
120
+ check('catalog == custom-elements.json', () => {
121
+ if (!existsSync(cemPath)) throw new Error('custom-elements.json is missing');
122
+ const cem = read(cemPath);
123
+ const names = new Set(
124
+ (cem.modules ?? []).flatMap((m) => (m.declarations ?? []).map((d) => d.tagName).filter(Boolean))
125
+ );
126
+ assertSameSet(
127
+ 'component sets differ (note: build:custom-elements.json uses a shallower glob than build:mcp-manifest)',
128
+ catalogNames,
129
+ names,
130
+ 'catalog',
131
+ 'custom-elements.json'
132
+ );
133
+ });
134
+
135
+ // ── Field-level parity ───────────────────────────────────────────────
136
+
137
+ check('catalog and compact agree on every prop and enum', () => {
138
+ const compactByName = new Map(compact.components.map((c) => [c.name, c]));
139
+ for (const [name, def] of Object.entries(catalog.$defs.components)) {
140
+ const catProps = def.properties?.props?.properties ?? {};
141
+ const cmpProps = compactByName.get(name)?.props ?? {};
142
+ assertSameSet(`${name}: prop sets differ`, new Set(Object.keys(catProps)), new Set(Object.keys(cmpProps)), 'catalog', 'compact');
143
+ for (const [prop, spec] of Object.entries(catProps)) {
144
+ const a = spec.enum ?? null;
145
+ const b = cmpProps[prop]?.enum ?? null;
146
+ if (JSON.stringify(a) !== JSON.stringify(b)) {
147
+ throw new Error(`${name}.${prop}: enum differs — catalog ${JSON.stringify(a)} vs compact ${JSON.stringify(b)}`);
148
+ }
149
+ }
150
+ }
151
+ const total = Object.values(catalog.$defs.components).reduce(
152
+ (n, d) => n + Object.keys(d.properties?.props?.properties ?? {}).length, 0);
153
+ return `${total} props`;
154
+ });
155
+
156
+ check('catalog and compact agree on containment', () => {
157
+ const compactByName = new Map(compact.components.map((c) => [c.name, c]));
158
+ for (const [name, def] of Object.entries(catalog.$defs.components)) {
159
+ const cmp = compactByName.get(name);
160
+ const catChildren = def.properties?.children !== undefined;
161
+ if (catChildren !== (cmp.acceptsChildren === true)) {
162
+ throw new Error(`${name}: catalog children=${catChildren} vs compact acceptsChildren=${cmp.acceptsChildren === true}`);
163
+ }
164
+ const catSlots = new Set(Object.keys(def.properties?.slots?.properties ?? {}));
165
+ assertSameSet(`${name}: slot sets differ`, catSlots, new Set(cmp.slots ?? []), 'catalog', 'compact');
166
+ }
167
+ });
168
+
169
+ check('manifest, catalog metadata, catalog schema and compact agree on events', () => {
170
+ const compactByName = new Map(compact.components.map((c) => [c.name, c]));
171
+ const native = new Set(catalog['x-native-events'] ?? []);
172
+ if (!native.size) throw new Error('catalog declares no x-native-events — the validator reads that list from here');
173
+
174
+ let total = 0;
175
+ for (const component of manifest.components) {
176
+ const fromManifest = new Set(Object.keys(component.events ?? {}));
177
+ const def = catalog.$defs.components[component.name];
178
+ const fromMetadata = new Set(Object.keys(def['x-events'] ?? {}));
179
+ const fromCompact = new Set(compactByName.get(component.name)?.events ?? []);
180
+
181
+ assertSameSet(`${component.name}: events differ`, fromManifest, fromMetadata, 'manifest', 'catalog x-events');
182
+ assertSameSet(`${component.name}: events differ`, fromMetadata, fromCompact, 'catalog x-events', 'compact');
183
+
184
+ // The validatable schema must admit every declared event, and must not
185
+ // invent any. Compared by admission rather than by set equality because a
186
+ // component may declare a custom event that shadows a native name —
187
+ // `cre8-tag` dispatches its own `change` — in which case the native branch
188
+ // legitimately absorbs it.
189
+ const names = def.properties?.events?.propertyNames;
190
+ if (!names) throw new Error(`${component.name}: no properties.events — events are not schema-validated`);
191
+ const branches = names.anyOf ?? [names];
192
+ const explicit = new Set(branches.flatMap((b) => b.enum ?? []));
193
+ const refsNative = branches.some((b) => b.$ref === '#/$defs/NativeEventName');
194
+ const admits = (n) => explicit.has(n) || (refsNative && native.has(n));
195
+
196
+ const unadmitted = [...fromMetadata].filter((n) => !admits(n));
197
+ if (unadmitted.length) {
198
+ throw new Error(`${component.name}: declared but not admitted by properties.events: ${unadmitted.join(', ')}`);
199
+ }
200
+ const invented = [...explicit].filter((n) => !fromMetadata.has(n) && !native.has(n));
201
+ if (invented.length) {
202
+ throw new Error(`${component.name}: properties.events admits undeclared event(s): ${invented.join(', ')}`);
203
+ }
204
+ total += fromManifest.size;
205
+ }
206
+ return `${total} events across ${manifest.components.length} components, ${native.size} native`;
207
+ });
208
+
209
+ check('the schema and the renderer agree on text children', () => {
210
+ const renderer = readFileSync(join(WC, 'a2ui', 'renderer.ts'), 'utf8');
211
+ const rendersText = /typeof\s+child\s*===\s*['"]string['"]/.test(renderer);
212
+ const child = catalog.$defs.Child;
213
+ const schemaAllowsText = !!child?.oneOf?.some((b) => b.type === 'string');
214
+
215
+ if (rendersText && !schemaAllowsText) {
216
+ throw new Error(
217
+ 'renderer.ts turns string children into text nodes, but the schema does not permit them.\n' +
218
+ ' Text renders yet fails validation, and no schema-constrained generator can emit it.'
219
+ );
220
+ }
221
+ if (!rendersText && schemaAllowsText) {
222
+ throw new Error('the schema permits text children but the renderer no longer handles them');
223
+ }
224
+
225
+ // Every containment point must route through Child, or the ones that don't
226
+ // silently keep the old behaviour.
227
+ const offenders = [];
228
+ for (const [name, def] of Object.entries(catalog.$defs.components)) {
229
+ const children = def.properties?.children;
230
+ if (children && children.items?.$ref !== '#/$defs/Child') offenders.push(`${name}.children`);
231
+ for (const [slot, spec] of Object.entries(def.properties?.slots?.properties ?? {})) {
232
+ if (spec.items?.$ref !== '#/$defs/Child') offenders.push(`${name}.slots.${slot}`);
233
+ }
234
+ }
235
+ if (offenders.length) {
236
+ throw new Error(`containment not routed through $defs/Child: ${offenders.slice(0, 5).join(', ')}`);
237
+ }
238
+
239
+ // A document root is a component; bare text is not a document.
240
+ if (catalog.properties?.root?.$ref !== '#/$defs/Component') {
241
+ throw new Error('the document root must be a Component, not a Child');
242
+ }
243
+ return `${offenders.length === 0 ? 'all containment routed through Child' : ''}`;
244
+ });
245
+
246
+ check('the runtime validator reads its native-event list from the catalog', () => {
247
+ const source = readFileSync(join(WC, 'a2ui', 'registry.ts'), 'utf8');
248
+ const code = source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
249
+ if (!/x-native-events/.test(code)) {
250
+ throw new Error('registry.ts no longer reads x-native-events — it has grown a second copy of the list');
251
+ }
252
+ if (/const\s+NATIVE_DOM_EVENTS\s*=\s*new Set\(\[/.test(code)) {
253
+ throw new Error('registry.ts hardcodes a native-event list again; read catalog["x-native-events"] instead');
254
+ }
255
+ });
256
+
257
+ check('the catalog matches the manifest it was generated from', () => {
258
+ if (catalog['x-a2ui']?.libraryVersion !== manifest.version) {
259
+ throw new Error(
260
+ `catalog was built from version ${catalog['x-a2ui']?.libraryVersion}, manifest is ${manifest.version} — regenerate with build:a2ui:catalog`
261
+ );
262
+ }
263
+ if (compact.libraryVersion !== manifest.version) {
264
+ throw new Error(`compact was built from version ${compact.libraryVersion}, manifest is ${manifest.version}`);
265
+ }
266
+ return `all at ${manifest.version}`;
267
+ });
268
+
269
+ // ── Report ───────────────────────────────────────────────────────────
270
+
271
+ for (const note of notes) console.log(note);
272
+ if (failures.length) {
273
+ console.error('\n' + failures.join('\n'));
274
+ console.error(`\n${failures.length} layer(s) out of parity`);
275
+ process.exit(1);
276
+ }
277
+ console.log(`\nok — ${notes.length} parity checks passed; every layer agrees`);
@@ -6,11 +6,56 @@ import { dirname, resolve } from 'node:path';
6
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const manifestPath = resolve(__dirname, '..', 'mcp-manifest.json');
8
8
  const outPath = resolve(__dirname, 'catalog.json');
9
+ const compactOutPath = resolve(__dirname, 'catalog.compact.json');
9
10
 
10
11
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
11
12
 
12
13
  const QUOTED_LITERAL = /^"([^"]*)"$/;
13
14
 
15
+ /**
16
+ * Native DOM events, bindable on every component.
17
+ *
18
+ * Emitted into the catalog as `x-native-events` so the runtime validator can
19
+ * read it from there rather than keeping its own copy. Two lists that must stay
20
+ * equal is exactly the drift this pipeline is supposed to prevent, so there is
21
+ * one list and it lives here, next to the schema it produces.
22
+ *
23
+ * A component's `@fires` tags describe only what it dispatches itself, so these
24
+ * can never be discovered from source — binding `click` to a button is both the
25
+ * most common thing an agent does and undocumentable by the analyzer.
26
+ */
27
+ const NATIVE_DOM_EVENTS = [
28
+ 'click', 'dblclick', 'contextmenu',
29
+ 'mousedown', 'mouseup', 'mouseenter', 'mouseleave', 'mouseover', 'mouseout', 'mousemove',
30
+ 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave',
31
+ 'touchstart', 'touchend', 'touchmove', 'touchcancel',
32
+ 'keydown', 'keyup', 'keypress',
33
+ 'focus', 'blur', 'focusin', 'focusout',
34
+ 'input', 'change', 'submit', 'reset', 'invalid', 'select',
35
+ 'scroll', 'wheel', 'resize',
36
+ 'copy', 'cut', 'paste',
37
+ 'drag', 'dragstart', 'dragend', 'dragenter', 'dragleave', 'dragover', 'drop',
38
+ 'load', 'error',
39
+ ];
40
+
41
+ /** Mirrors the `EventBinding` union in types.ts. */
42
+ const EVENT_BINDING_SCHEMA = {
43
+ description: 'A handler name, or an object naming the handler plus dispatch options.',
44
+ oneOf: [
45
+ { type: 'string', minLength: 1 },
46
+ {
47
+ type: 'object',
48
+ required: ['handler'],
49
+ additionalProperties: false,
50
+ properties: {
51
+ handler: { type: 'string', minLength: 1 },
52
+ stopPropagation: { type: 'boolean' },
53
+ preventDefault: { type: 'boolean' },
54
+ },
55
+ },
56
+ ],
57
+ };
58
+
14
59
  const SELECT_OPTION_SCHEMA = {
15
60
  type: 'object',
16
61
  required: ['label', 'value'],
@@ -291,7 +336,7 @@ function buildComponent(c) {
291
336
  def.properties.children = {
292
337
  type: 'array',
293
338
  description: (rawSlots.default?.description || '').trim() || 'Child instances rendered into the default slot.',
294
- items: { $ref: '#/$defs/Component' },
339
+ items: { $ref: '#/$defs/Child' },
295
340
  };
296
341
  } else if (hasSlots) {
297
342
  const slotProps = {};
@@ -301,7 +346,7 @@ function buildComponent(c) {
301
346
  slotProps[name] = {
302
347
  type: 'array',
303
348
  description: (slot.description || '').trim(),
304
- items: { $ref: '#/$defs/Component' },
349
+ items: { $ref: '#/$defs/Child' },
305
350
  };
306
351
  slotDescriptions[name] = (slot.description || '').trim();
307
352
  }
@@ -316,6 +361,24 @@ function buildComponent(c) {
316
361
 
317
362
  if (Object.keys(events).length) def['x-events'] = events;
318
363
 
364
+ // Events are declared under `properties`, alongside props and slots, rather
365
+ // than living only in `x-events` metadata. Previously the component defs were
366
+ // `additionalProperties: false` with no `events` key at all, so events were
367
+ // documented but second-class — and an invented event name validated cleanly.
368
+ // Native events are always permitted; declared custom events are added on top.
369
+ def.properties.events = {
370
+ type: 'object',
371
+ description:
372
+ 'Event bindings. Each key is an event name; the value names the handler to emit.',
373
+ // Native names are `$ref`'d rather than inlined. Repeating all 46 on each of
374
+ // 85 components cost ~57 KB and made the catalog a third larger for no
375
+ // information gain.
376
+ propertyNames: Object.keys(events).length
377
+ ? { anyOf: [{ $ref: '#/$defs/NativeEventName' }, { enum: Object.keys(events).sort() }] }
378
+ : { $ref: '#/$defs/NativeEventName' },
379
+ additionalProperties: { $ref: '#/$defs/EventBinding' },
380
+ };
381
+
319
382
  return def;
320
383
  }
321
384
 
@@ -332,6 +395,9 @@ const catalog = {
332
395
  $id: `https://cre8.dev/a2ui/catalogs/cre8-wc/${manifest.version}`,
333
396
  title: 'cre8-wc A2UI Catalog',
334
397
  description: manifest.description,
398
+ // Single source of truth for the native-event allowlist. The runtime validator
399
+ // reads it from here instead of keeping a second copy that could drift.
400
+ 'x-native-events': NATIVE_DOM_EVENTS,
335
401
  'x-a2ui': {
336
402
  catalogId: 'cre8-wc',
337
403
  library: manifest.library,
@@ -349,6 +415,24 @@ const catalog = {
349
415
  description: 'A component instance in the cre8-wc catalog.',
350
416
  oneOf: componentRefs,
351
417
  },
418
+ // Slot content is a component *or* literal text. `renderer.ts` has always
419
+ // turned a bare string into a text node, but the schema never said so, and
420
+ // that gap is load-bearing rather than cosmetic: 56 of 85 components carry
421
+ // no text-bearing prop, so their entire visible content arrives this way. A
422
+ // schema-constrained generator — guided decoding, or any JSON-Schema-
423
+ // constrained model — could not give those components any content at all.
424
+ //
425
+ // `root` stays a Component deliberately: a document cannot be bare text.
426
+ Child: {
427
+ description:
428
+ 'Slot content: either a nested component instance or literal text, which renders as a text node.',
429
+ oneOf: [{ $ref: '#/$defs/Component' }, { type: 'string' }],
430
+ },
431
+ EventBinding: EVENT_BINDING_SCHEMA,
432
+ NativeEventName: {
433
+ description: 'A native DOM event, bindable on any component.',
434
+ enum: [...NATIVE_DOM_EVENTS].sort(),
435
+ },
352
436
  components,
353
437
  },
354
438
  };
@@ -357,3 +441,57 @@ writeFileSync(outPath, JSON.stringify(catalog, null, 2) + '\n');
357
441
  console.log(
358
442
  `Wrote ${outPath} (${manifest.components.length} components, ${(JSON.stringify(catalog).length / 1024).toFixed(1)} KB)`
359
443
  );
444
+
445
+ // The compact projection: the minimum a model needs to emit valid A2UI. Prose is
446
+ // ~90% of the catalog's bytes and none of its decoding constraint, so dropping it
447
+ // is what lets a small-context model see the design system at all.
448
+ //
449
+ // Emitted here rather than in a separate script so it cannot drift from the
450
+ // catalog it projects.
451
+ function compactProps(propsNode) {
452
+ const out = {};
453
+ for (const [name, spec] of Object.entries(propsNode?.properties ?? {})) {
454
+ out[name] = spec.enum ? { enum: spec.enum } : { type: spec.type ?? 'string' };
455
+ }
456
+ return out;
457
+ }
458
+
459
+ const compactComponents = Object.entries(components)
460
+ .map(([name, def]) => {
461
+ const props = def.properties ?? {};
462
+ const entry = { name, category: def['x-category'] ?? 'Uncategorized' };
463
+
464
+ if (props.props) {
465
+ entry.props = compactProps(props.props);
466
+ if (props.props.required?.length) entry.required = props.props.required;
467
+ }
468
+ // Containment is expressed two ways and consumers need both: `children` for
469
+ // plain containers, `slots` for named regions. Dropping either makes a
470
+ // container look like a leaf.
471
+ if (props.children) entry.acceptsChildren = true;
472
+ if (props.slots) entry.slots = Object.keys(props.slots.properties ?? {});
473
+ // Events live under `x-events`, not under `properties`, which makes them easy
474
+ // to miss — the studio's hand-rolled summary looked for them in the wrong
475
+ // place and so showed the model none of the 22 events the library emits.
476
+ const events = Object.keys(def['x-events'] ?? {});
477
+ if (events.length) entry.events = events;
478
+ return entry;
479
+ })
480
+ .sort((a, b) => a.name.localeCompare(b.name));
481
+
482
+ const compact = {
483
+ contractVersion: 1,
484
+ sourceCatalog: catalog.$id,
485
+ libraryVersion: manifest.version,
486
+ componentCount: compactComponents.length,
487
+ components: compactComponents,
488
+ };
489
+
490
+ writeFileSync(compactOutPath, JSON.stringify(compact, null, 2) + '\n');
491
+
492
+ const fullBytes = JSON.stringify(components).length;
493
+ const compactBytes = JSON.stringify(compact).length;
494
+ console.log(
495
+ `Wrote ${compactOutPath} (${compactComponents.length} components, ` +
496
+ `${(compactBytes / 1024).toFixed(1)} KB, ${(fullBytes / compactBytes).toFixed(1)}x smaller)`
497
+ );
package/a2ui/registry.js CHANGED
@@ -87,6 +87,17 @@ function describeType(v) {
87
87
  return 'array';
88
88
  return typeof v;
89
89
  }
90
+ /**
91
+ * Native DOM events, bindable on any component.
92
+ *
93
+ * Read from the catalog's `x-native-events` rather than kept here, so there is
94
+ * one list rather than two that must be held equal. A component's `@fires` tags
95
+ * describe only what it dispatches itself, so these can never be derived from
96
+ * source — `click` on a button is legitimate and undocumentable.
97
+ */
98
+ function nativeEvents(catalog) {
99
+ return new Set(catalog.schema['x-native-events'] ?? []);
100
+ }
90
101
  export function validateSpec(spec, catalog, path = '$') {
91
102
  if (!spec || typeof spec !== 'object') {
92
103
  throw new Error(`${path}: spec must be an object`);
@@ -131,7 +142,24 @@ export function validateSpec(spec, catalog, path = '$') {
131
142
  if (!s.events || typeof s.events !== 'object' || Array.isArray(s.events)) {
132
143
  throw new Error(`${path}.events: must be an object`);
133
144
  }
145
+ // Custom event *names* are checked against the catalog, the same way props
146
+ // and slots are. Previously only the binding shape was validated, so an
147
+ // invented event bound cleanly and then silently never fired — the worst
148
+ // failure mode available, since the UI renders and simply does nothing.
149
+ //
150
+ // Native DOM events are always allowed: `addEventListener` handles them on
151
+ // any element, and `@fires` documents only what a component dispatches
152
+ // itself. `click` on a button is legitimate and undocumented by design.
153
+ const declaredEvents = new Set(Object.keys(def['x-events'] ?? {}));
154
+ const native = nativeEvents(catalog);
134
155
  for (const [evtName, binding] of Object.entries(s.events)) {
156
+ if (!native.has(evtName) && !declaredEvents.has(evtName)) {
157
+ const available = [...declaredEvents].sort().join(', ');
158
+ throw new Error(`${path}.events.${evtName}: not a declared event on ${s.component}. ` +
159
+ (available
160
+ ? `Custom events available: ${available}`
161
+ : `${s.component} declares no custom events`));
162
+ }
135
163
  if (typeof binding === 'string')
136
164
  continue;
137
165
  if (!binding || typeof binding !== 'object') {
package/a2ui/registry.ts CHANGED
@@ -100,6 +100,18 @@ function describeType(v: unknown): string {
100
100
  return typeof v;
101
101
  }
102
102
 
103
+ /**
104
+ * Native DOM events, bindable on any component.
105
+ *
106
+ * Read from the catalog's `x-native-events` rather than kept here, so there is
107
+ * one list rather than two that must be held equal. A component's `@fires` tags
108
+ * describe only what it dispatches itself, so these can never be derived from
109
+ * source — `click` on a button is legitimate and undocumentable.
110
+ */
111
+ function nativeEvents(catalog: RegisteredCatalog): Set<string> {
112
+ return new Set(catalog.schema['x-native-events'] ?? []);
113
+ }
114
+
103
115
  export function validateSpec(spec: unknown, catalog: RegisteredCatalog, path = '$'): asserts spec is ComponentSpec {
104
116
  if (!spec || typeof spec !== 'object') {
105
117
  throw new Error(`${path}: spec must be an object`);
@@ -147,7 +159,26 @@ export function validateSpec(spec: unknown, catalog: RegisteredCatalog, path = '
147
159
  if (!s.events || typeof s.events !== 'object' || Array.isArray(s.events)) {
148
160
  throw new Error(`${path}.events: must be an object`);
149
161
  }
162
+ // Custom event *names* are checked against the catalog, the same way props
163
+ // and slots are. Previously only the binding shape was validated, so an
164
+ // invented event bound cleanly and then silently never fired — the worst
165
+ // failure mode available, since the UI renders and simply does nothing.
166
+ //
167
+ // Native DOM events are always allowed: `addEventListener` handles them on
168
+ // any element, and `@fires` documents only what a component dispatches
169
+ // itself. `click` on a button is legitimate and undocumented by design.
170
+ const declaredEvents = new Set(Object.keys(def['x-events'] ?? {}));
171
+ const native = nativeEvents(catalog);
150
172
  for (const [evtName, binding] of Object.entries(s.events as Record<string, unknown>)) {
173
+ if (!native.has(evtName) && !declaredEvents.has(evtName)) {
174
+ const available = [...declaredEvents].sort().join(', ');
175
+ throw new Error(
176
+ `${path}.events.${evtName}: not a declared event on ${s.component}. ` +
177
+ (available
178
+ ? `Custom events available: ${available}`
179
+ : `${s.component} declares no custom events`)
180
+ );
181
+ }
151
182
  if (typeof binding === 'string') continue;
152
183
  if (!binding || typeof binding !== 'object') {
153
184
  throw new Error(`${path}.events.${evtName}: must be a string or { handler } object`);
@@ -73,9 +73,43 @@ try {
73
73
  console.log('slot reject:', e.message);
74
74
  }
75
75
 
76
+ // An invented custom event used to bind cleanly and then never fire — the UI
77
+ // renders and silently does nothing, which is worse than an error.
78
+ const badEvent = { component: 'cre8-modal', events: { 'totally-made-up': 'x' } };
79
+ try {
80
+ validateSpec(badEvent, cat);
81
+ console.error('FAIL: expected rejection of undeclared event');
82
+ process.exit(1);
83
+ } catch (e) {
84
+ console.log('event reject:', e.message);
85
+ }
86
+
87
+ // Binding a real event to the wrong component is the same failure wearing a
88
+ // disguise: the name exists in the library, just not on this component.
89
+ const misattributed = { component: 'cre8-button', events: { 'modal-close': 'x' } };
90
+ try {
91
+ validateSpec(misattributed, cat);
92
+ console.error('FAIL: expected rejection of an event from another component');
93
+ process.exit(1);
94
+ } catch (e) {
95
+ console.log('misattributed event reject:', e.message);
96
+ }
97
+
98
+ // Native DOM events stay bindable on anything — `@fires` documents only what a
99
+ // component dispatches itself, so the catalog can never list `click`.
100
+ for (const native of ['click', 'input', 'submit', 'keydown', 'focus']) {
101
+ validateSpec({ component: 'cre8-button', events: { [native]: 'h' } }, cat);
102
+ }
103
+ console.log('native events accepted on an undocumented component: ok');
104
+
105
+ // Binds one native event and one custom event, on the component that actually
106
+ // dispatches the custom one. It used to bind `split-button-text-click` to a
107
+ // plain `cre8-button`, which no `cre8-button` ever fires — a binding that
108
+ // renders cleanly and then silently does nothing. Event-name validation now
109
+ // rejects that, so the example has to be a real pairing.
76
110
  const eventSpec = {
77
- component: 'cre8-button',
78
- props: { text: 'Save', variant: 'primary' },
111
+ component: 'cre8-split-button',
112
+ props: { buttonText: 'Save' },
79
113
  events: {
80
114
  click: { handler: 'save-record', stopPropagation: true },
81
115
  'split-button-text-click': 'emit-telemetry',
package/a2ui/types.d.ts CHANGED
@@ -24,6 +24,11 @@ export interface CatalogSchema {
24
24
  $defs?: {
25
25
  components?: Record<string, CatalogComponentDef>;
26
26
  };
27
+ /**
28
+ * Native DOM events bindable on any component. The single source of truth —
29
+ * the runtime validator reads this rather than keeping its own copy.
30
+ */
31
+ 'x-native-events'?: string[];
27
32
  'x-a2ui'?: {
28
33
  catalogId?: string;
29
34
  library?: string;
package/a2ui/types.ts CHANGED
@@ -30,6 +30,11 @@ export interface CatalogSchema {
30
30
  $defs?: {
31
31
  components?: Record<string, CatalogComponentDef>;
32
32
  };
33
+ /**
34
+ * Native DOM events bindable on any component. The single source of truth —
35
+ * the runtime validator reads this rather than keeping its own copy.
36
+ */
37
+ 'x-native-events'?: string[];
33
38
  'x-a2ui'?: {
34
39
  catalogId?: string;
35
40
  library?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../a2ui/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAc,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9F,wBAAgB,eAAe,CAAC,MAAM,EAAE,aAAa,GAAG,iBAAiB,CAKxE;AA+FD,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,iBAAiB,EAAE,IAAI,SAAM,GAAG,OAAO,CAAC,IAAI,IAAI,aAAa,CA+EjH"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../a2ui/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,aAAa,EAAc,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9F,wBAAgB,eAAe,CAAC,MAAM,EAAE,aAAa,GAAG,iBAAiB,CAKxE;AA2GD,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,iBAAiB,EAAE,IAAI,SAAM,GAAG,OAAO,CAAC,IAAI,IAAI,aAAa,CAkGjH"}