@adia-ai/a2ui-compose 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,196 @@ generator graph.
12
12
 
13
13
  _No pending changes._
14
14
 
15
+ ## [0.5.5] - 2026-05-14
16
+
17
+ ### Changed — §179 (v0.5.5) — principled `deprecated:` yaml field
18
+
19
+ Three new yaml schema fields under each prop:
20
+
21
+ - **`deprecated`** (boolean, default `false`) — when `true`, the LLM prompt builder appends `(deprecated, do not use)` to the prop's line in CORPUS CONTEXT.
22
+ - **`deprecated_since`** (string) — version when deprecation was introduced.
23
+ - **`deprecated_reason`** (string) — human-readable migration guidance.
24
+
25
+ Build pipeline (`scripts/build/components.mjs::compileProp`) collects deprecation metadata and inlines onto the prop schema. Prompt builder (`packages/a2ui/compose/strategies/monolithic/_shared.js`) reads `v.deprecated === true` FIRST; falls back to `/deprecated/i.test(v.description)` (the v0.5.4 §167a substring detection) for back-compat. Existing §167a-tagged props continue to work without yaml changes.
26
+
27
+ Demonstrated on `Avatar.name`. Verified via `buildSystemPrompt`: `name:String[] (deprecated, do not use)` surfaces in CORPUS CONTEXT.
28
+
29
+ Commit `0379dd498`. See root CHANGELOG and journal §179 for full context.
30
+
31
+ ## [0.5.4] - 2026-05-14
32
+
33
+ ### Fixed — §163 transpiler prop-fidelity; catalog-driven extractor closes the harvester drop class (v0.5.4)
34
+
35
+ `packages/a2ui/compose/transpiler/transpiler-maps.js` rewritten to derive
36
+ the extractable-prop set from the v0.9 catalog at module init, replacing
37
+ the pre-§163 hand-maintained per-type allowlists that omitted `label`,
38
+ `icon`, literal `variant=`, and similar first-class props.
39
+
40
+ **Bug class diagnosed 2026-05-14** against composition `auth-signin-card-password`
41
+ rendering blank rectangles in the gen-ui canvas. Verified pre-§163 vs post-§163
42
+ against `apps/user-flow/app/auth/sign-in/password/password.contents.html`:
43
+
44
+ Password Input pre={} post={label,type,autocomplete,name,placeholder,required}
45
+ Remember check pre={name} post={name,label}
46
+ Sign in button pre={type} post={type,text,variant}
47
+ Divider pre={} post={label}
48
+ Magic-link btn pre={} post={icon,text,variant}
49
+ 6-digit btn pre={} post={icon,text,variant}
50
+
51
+ **Implementation**: `CATALOG_PROPS` Map built at module init from
52
+ `@adia-ai/a2ui-corpus/catalog-a2ui_0_9.json`; `TYPE_ALIAS` map with
53
+ 4 resolution passes (catalog-self-map + direct registry-match +
54
+ shared-tag-match + kebab-to-PascalCase fallback) — closes the
55
+ registry-vs-catalog name drift (registry has `CheckBox → check-ui`;
56
+ catalog uses `Check`). Generic catalog-driven pass runs FIRST in
57
+ `extractProps()`; legacy per-type blocks defer with `if (props.X === undefined)`
58
+ guards so catalog values win on conflict.
59
+
60
+ **Side-effect fixes**: pre-existing legacy quirks (Slider `'0'` → `'0'`
61
+ returning string instead of numeric `0`, Button `disabled="false"` → `true`)
62
+ incidentally fixed by the deferred-legacy pattern.
63
+
64
+ NEW `packages/a2ui/compose/transpiler/extract-props.test.js` (+228 LOC,
65
+ 19 tests) pinning the regression class.
66
+
67
+ Cycle followup: §164 re-harvested all 230 chunks with the §163-fixed
68
+ transpiler (corpus package; +2920 LOC restored prop fidelity). §166a
69
+ added audit-script-family slot 12 (`check-extract-props-coverage.mjs`)
70
+ that re-verifies the catalog-vs-extractor invariant at every commit.
71
+
72
+ ### Fixed — §168 canvas-iteration merge always-on; close the 50-threshold mismatch (v0.5.4)
73
+
74
+ `packages/a2ui/compose/strategies/monolithic/generate-pro.js:374` —
75
+ removed the `priorComponents.length >= 50` gate. `mergeCanvasDiff()`
76
+ now runs on every iteration turn regardless of canvas size.
77
+
78
+ Closes the chart-workflow regression diagnosed 2026-05-14:
79
+ 32-component canvas + "add a chart" intent → 202-component response
80
+ wholesale-replaced the user's prior work (`exec_mp4zbj47_1`,
81
+ `comps= 202 score= 105`). Pre-§168 the 0–49 band had no merge gate
82
+ even though the prompt asked the LLM to return COMPLETE; LLM canvas
83
+ explosions wholesale-replaced. Now: `mergeCanvasDiff` runs always,
84
+ the hallucination-detection guards (matchRatio < 0.2 reject + < 0.6
85
+ accept-with-warning) discriminate by match ratio (more principled than
86
+ canvas-size heuristics), and small-canvas iterations correctly preserve
87
+ prior work when LLM speculatively expands.
88
+
89
+ `mergeCanvasDiff` is a strict superset of "wholesale-replace"
90
+ behaviorally: COMPLETE arrays merge to identical shape; DIFF arrays
91
+ process unchanged; hallucinated rewrites get rejected with
92
+ "Hallucinated replacement rejected" + canvas preservation.
93
+
94
+ NEW `merge-canvas-diff.test.js` (+251 LOC, 12 tests) pinning behavior
95
+ across all three bands (strict-superset-of-replace, hallucination
96
+ guards, DIFF format, layout-type guards, the §168 chart-workflow
97
+ regression fixture).
98
+
99
+ ### Fixed — §169 iteration-prompt structural priming; Card/Stat/Chart anatomy carry-over (v0.5.4)
100
+
101
+ `packages/a2ui/compose/strategies/monolithic/_shared.js`
102
+ `buildCanvasDiffPrompt()` — added NEW `ITERATION_STRUCTURAL_RULES`
103
+ block injected into both the full-canvas + compact-summary branches.
104
+
105
+ Pre-§169 the iteration prompts said "Preserve slot attributes and
106
+ Card anatomy rules" without defining what those rules ARE. The
107
+ fresh-generation prompt at `buildSystemPrompt()` defines them
108
+ explicitly (Card > Header > [slot=heading...], Card > Section >
109
+ Column > [content], etc.) but iteration mode relied on multi-turn
110
+ context retention, which is unreliable.
111
+
112
+ Empirically the corpus enforces Card > Section > Column > Chart
113
+ (233 Section→Column + 95 Card→Section across 230 chunks). The
114
+ 2026-05-14 user-reported "where chart data should live in the tree"
115
+ ticket was the LLM emitting Chart at the wrong depth during
116
+ iteration.
117
+
118
+ Token cost: ~150 tokens added to a 500-1500-token iteration prompt;
119
+ well within 32k max_tokens budget.
120
+
121
+ ### Fixed — §167 surface yaml-declared deprecation to LLM via prompt builder (v0.5.4)
122
+
123
+ `packages/a2ui/compose/strategies/monolithic/_shared.js:274` (+ legacy
124
+ fallback at `:312`) — when building the per-component prop string,
125
+ append `(deprecated, do not use)` to any prop whose description
126
+ matches `/deprecated/i`.
127
+
128
+ Closes the `<avatar-ui name="...">` false-positive class observed
129
+ 2026-05-14 console:
130
+
131
+ ```
132
+ class.js:60 [AdiaUI] <avatar-ui name="…"> is deprecated — use text="…" instead.
133
+ ```
134
+
135
+ Root cause: `avatar.yaml` + sidecar + catalog all declare
136
+ `Avatar.name` as a prop with description `"Deprecated alias for
137
+ text..."`. The prompt builder captured description into
138
+ `out.description` (line ~56) but never read it when assembling the
139
+ `props:` line. LLM saw both `name` and `text` as equally-valid prop
140
+ names and naturally picked `name` for "team member names" intents.
141
+
142
+ Post-§167 prompt:
143
+ ```
144
+ - Avatar: ... props: icon:String, name:String (deprecated, do not use), ...
145
+ ```
146
+
147
+ ### Fixed — §172 ChatInput catalog entry + scanner sibling-yaml fix + intent-keyword family inclusion (v0.5.4)
148
+
149
+ Closes ticket `20260514045025-chatinput-training-data-unclea` —
150
+ user reported the generator created a redundant Button(primary)
151
+ sibling next to ChatInput, duplicating the built-in send button
152
+ that `<chat-input-ui>` already stamps.
153
+
154
+ **Three architectural layers fixed:**
155
+
156
+ 1. **Build scanner sibling-yaml discovery** —
157
+ `scripts/build/components.mjs` (+15 LOC). Pre-§172 the scanner
158
+ only looked for `<name>/<name>.yaml`; sibling yamls in the same
159
+ dir were invisible. `<chat-input-ui>` (sibling of chat-thread)
160
+ and `<feed-item-ui>` (sibling of feed) were orphans —
161
+ registered at runtime but invisible to the catalog. Post-§172
162
+ sibling discovery is automatic.
163
+
164
+ 2. **Retrieval catalog loader sibling-sidecar discovery** —
165
+ `packages/a2ui/retrieval/component-catalog.js:_loadNode()`
166
+ (+24 LOC). Same blind spot; same fix. Browser path already
167
+ recursive via `import.meta.glob('**/*.a2ui.json')`; Node path
168
+ now matches.
169
+
170
+ 3. **Intent-keyword family inclusion in prompt builder** —
171
+ `packages/a2ui/compose/strategies/monolithic/_shared.js`
172
+ (+26 LOC). Pre-§172 the prompt's always-include list had generic
173
+ primitives only; chat/table/form/chart/nav components only
174
+ entered via retrieval-pattern match. Post-§172 the chat family
175
+ (`ChatInput, ChatThread, ChatComposer, ChatShell, ChatHeader,
176
+ ChatSidebar`) is force-included when the intent mentions
177
+ `chat/message/conversation/composer/thread`. Same for the other
178
+ 4 families.
179
+
180
+ Together: ChatInput's full description, 3 a2ui rules, and 3
181
+ anti-patterns (the "DO NOT add a separate Button sibling for send"
182
+ guidance) now appear in every chat-intent prompt's CORPUS CONTEXT
183
+ block.
184
+
185
+ **Sixth distinct application of the SoT-coherence pattern** in
186
+ v0.5.4 (§163 + §167 + §168 + §169 + §170 + §172).
187
+
188
+ Commit `514e72697`.
189
+
190
+ ### Maintenance — audit-script companions §173 + §175 ship in v0.5.4 (no source change to compose)
191
+
192
+ Repo-level audit scripts targeting compose drift classes:
193
+
194
+ - **§173 `check-iteration-prompt-coherence.mjs`** (slot 14) — guards against
195
+ regressions in `monolithic/_shared.js` deprecation-marker logic (§167) +
196
+ iteration-prompt structural rules block (§169).
197
+ - **§175 `check-registry-catalog-coherence.mjs`** (slot 16) — guards against
198
+ registry/catalog drift like §172 ChatInput orphan-component class.
199
+
200
+ No compose source change in §173/§175 — both detect future drift in compose's
201
+ catalog-vs-prompt + registry-vs-catalog invariants.
202
+
203
+ ### Changed — `version`: `0.5.3` → `0.5.4`.
204
+
15
205
  ## [0.5.3] - 2026-05-14
16
206
 
17
207
  ### Fixed — Deterministic chunk-loading order in zettel composition library (§160, v0.5.3)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.5.3",
4
- "description": "AdiaUI A2UI compose engine framework-agnostic. Takes natural-language intents + a catalog and produces A2UI protocol messages. Pairs with `@adia-ai/a2ui-retrieval` (intent classification, catalog lookup) and `@adia-ai/a2ui-validator` (schema + semantic checks).",
3
+ "version": "0.5.5",
4
+ "description": "AdiaUI A2UI compose engine \u2014 framework-agnostic. Takes natural-language intents + a catalog and produces A2UI protocol messages. Pairs with `@adia-ai/a2ui-retrieval` (intent classification, catalog lookup) and `@adia-ai/a2ui-validator` (schema + semantic checks).",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./index.js",
@@ -248,6 +248,32 @@ KEY RULES:
248
248
  'Text', 'Button', 'Input', 'Badge', 'Icon', 'Avatar', 'Image', 'Divider']) {
249
249
  relevantTypes.add(t);
250
250
  }
251
+ // §172 (v0.5.4): intent-keyword inclusion — add component families when
252
+ // the intent mentions their domain. Pre-§172 the LLM had to learn chat
253
+ // components via multi-turn context (unreliable) or pattern-match (which
254
+ // depends on retrieval). The 2026-05-14 redundant-send-button regression
255
+ // happened because the LLM saw `ChatInput` in the runtime registry but
256
+ // had no schema describing what `<chat-input-ui>` stamps. Now the chat
257
+ // family is force-included whenever the intent mentions chat/message/
258
+ // conversation.
259
+ const intentLower = (intent || '').toLowerCase();
260
+ const FAMILIES = {
261
+ chat: { keywords: ['chat', 'message', 'conversation', 'composer', 'thread'],
262
+ types: ['ChatInput', 'ChatThread', 'ChatComposer', 'ChatShell', 'ChatHeader', 'ChatSidebar'] },
263
+ table: { keywords: ['table', 'grid of rows', 'tabular', 'spreadsheet'],
264
+ types: ['Table', 'TableToolbar'] },
265
+ form: { keywords: ['form', 'sign in', 'sign up', 'register', 'login', 'signup'],
266
+ types: ['Field', 'Input', 'CheckBox', 'Switch', 'Select', 'TextArea', 'Slider'] },
267
+ chart: { keywords: ['chart', 'graph', 'metric', 'kpi', 'dashboard', 'trend'],
268
+ types: ['Chart', 'ChartLegend', 'Stat'] },
269
+ navigation: { keywords: ['nav', 'sidebar', 'breadcrumb', 'tabs', 'menu'],
270
+ types: ['Tabs', 'Tab', 'Menu', 'Breadcrumbs', 'NavGroup', 'NavItem'] },
271
+ };
272
+ for (const family of Object.values(FAMILIES)) {
273
+ if (family.keywords.some(kw => intentLower.includes(kw))) {
274
+ for (const t of family.types) relevantTypes.add(t);
275
+ }
276
+ }
251
277
  // Add types from matched patterns
252
278
  for (const p of patterns) {
253
279
  for (const c of (p.components || [])) relevantTypes.add(c);
@@ -276,6 +302,13 @@ KEY RULES:
276
302
  if (v.enum) desc += `=(${v.enum.join('|')})`;
277
303
  else if (v.type) desc += `:${v.type}`;
278
304
  if (v.default !== undefined) desc += `[${v.default}]`;
305
+ // §167 (v0.5.4): surface yaml-declared deprecation to the LLM. Pre-§167
306
+ // the description field was captured into out.description (line ~56)
307
+ // but never read here, so deprecated props (Avatar.name, Field.persist
308
+ // alias, etc.) looked indistinguishable from canonical props. LLM
309
+ // would pick e.g. <avatar-ui name="..."> instead of text="...".
310
+ // §179 (v0.5.5): principled `deprecated:` field wins; falls back to §167a substring detection for back-compat.
311
+ if (v.deprecated === true || (v.description && /deprecated/i.test(v.description))) desc += ' (deprecated, do not use)';
279
312
  return desc;
280
313
  }).join(', ') : '';
281
314
  const events = a2uiData.events?.length ? ` events: ${a2uiData.events.join(', ')}` : '';
@@ -313,6 +346,13 @@ KEY RULES:
313
346
  if (v.enum) desc += `=(${v.enum.join('|')})`;
314
347
  else if (v.type) desc += `:${v.type}`;
315
348
  if (v.default !== undefined) desc += `[${v.default}]`;
349
+ // §167 (v0.5.4): surface yaml-declared deprecation to the LLM. Pre-§167
350
+ // the description field was captured into out.description (line ~56)
351
+ // but never read here, so deprecated props (Avatar.name, Field.persist
352
+ // alias, etc.) looked indistinguishable from canonical props. LLM
353
+ // would pick e.g. <avatar-ui name="..."> instead of text="...".
354
+ // §179 (v0.5.5): principled `deprecated:` field wins; falls back to §167a substring detection for back-compat.
355
+ if (v.deprecated === true || (v.description && /deprecated/i.test(v.description))) desc += ' (deprecated, do not use)';
316
356
  return desc;
317
357
  }).join(', ') : '';
318
358
  const events = comp.events?.length ? ` events: ${comp.events.join(', ')}` : '';
@@ -929,6 +969,43 @@ export function mergeCanvasDiff(priorComponents, diffComponents) {
929
969
  * @param {string|null} [opts.originalIntent] — original design brief for drift anchoring
930
970
  * @returns {string} prompt text for the canvas diff
931
971
  */
972
+ /**
973
+ * §169 (v0.5.4): structural rules block injected into iteration prompts.
974
+ *
975
+ * Pre-§169 the iteration prompts said "Preserve slot attributes and Card
976
+ * anatomy rules" without defining what those rules ARE. The fresh-generation
977
+ * prompt at buildSystemPrompt() defines them explicitly (Card > Header >
978
+ * [Text slot=heading...], Card > Section > Column > [content], etc.) but
979
+ * iteration mode relied on multi-turn context retention, which is unreliable.
980
+ *
981
+ * The 2026-05-14 chart-workflow ticket symptom: 32-component canvas + "add
982
+ * a chart" intent → LLM emitted Chart at the wrong nesting depth, prompting
983
+ * the user-friction complaint about "where chart data should live in the
984
+ * tree." Empirically the corpus enforces Card > Section > Column > Chart
985
+ * (233 Section→Column + 95 Card→Section across 230 chunks).
986
+ *
987
+ * This block echoes the fresh-generation contract's most-important
988
+ * structural guidance into iteration mode. Keeps token cost negligible
989
+ * (~150 tokens added to a 500-1500-token iteration prompt; well within
990
+ * 32k max_tokens budget).
991
+ */
992
+ const ITERATION_STRUCTURAL_RULES = `
993
+ STRUCTURAL RULES (carry-over from fresh-generation contract — apply when ADDING new components):
994
+ - Card content: Card > Header > [Text slot="heading", Text slot="description", Button slot="action"]
995
+ Card > Section > Column > [content] (Section ALWAYS wraps children in a Column)
996
+ Card > Footer > [Button, Button] (action buttons go in Footer, not Section)
997
+ - Stats: use Stat component directly with {label, value, change, trend}; do NOT manually
998
+ build stat cards from Card+Header+Section+Text — emit a Stat node.
999
+ - Charts: place inside Section > Column. Chart props: type ("line"|"bar"|"area"), x (string),
1000
+ y (string or array). Charts go in Section, not directly inside Card.
1001
+ - Forms: wrap fields in Column with gap="3". Submit button in separate Row or Footer.
1002
+ - Tabs: Tab children = ONLY Tab components. Content panels are SIBLINGS of Tabs, not children.
1003
+ Column > [Tabs, Card, Card] (panels show/hide based on active tab; NOT Tab > Card).
1004
+ - Header slots: every Header child MUST have a slot= attribute (heading|description|action|icon).
1005
+ - Layout types: Grid/Row/Column types MUST NOT change during iteration unless the user explicitly
1006
+ requests a layout change. To restructure, DELETE the old container + ADD a new one.
1007
+ `;
1008
+
932
1009
  export function buildCanvasDiffPrompt(intent, priorComponents, { originalIntent = null } = {}) {
933
1010
  // Design intent anchor — prevents drift from the original brief
934
1011
  const intentAnchor = originalIntent && originalIntent !== intent
@@ -946,10 +1023,10 @@ export function buildCanvasDiffPrompt(intent, priorComponents, { originalIntent
946
1023
  ${JSON.stringify(priorComponents, null, 2)}
947
1024
 
948
1025
  The user is ITERATING on their existing canvas shown above. They want to MODIFY it, not replace it.
949
-
1026
+ ${ITERATION_STRUCTURAL_RULES}
950
1027
  Instructions:
951
1028
  - Preserve ALL existing components unless explicitly asked to remove them
952
- - Add new components where they logically belong
1029
+ - Add new components where they logically belong (following the STRUCTURAL RULES above)
953
1030
  - Change text content, labels, icons to match the new intent
954
1031
  - Preserve slot attributes and Card anatomy rules
955
1032
  - CRITICAL: Preserve layout container types — do NOT change Grid to Column or Row to Column. If the canvas has a Grid with columns="3" for a card collection, keep it as Grid. Only change layout types when the user explicitly requests a layout change.
@@ -966,6 +1043,7 @@ Instructions:
966
1043
  ${summary}
967
1044
  ${intentAnchor}
968
1045
  The user wants to MODIFY their existing canvas. Return ONLY the components you need to ADD, MODIFY, or DELETE:
1046
+ ${ITERATION_STRUCTURAL_RULES}
969
1047
  CRITICAL PRESERVATION RULES:
970
1048
  - NEVER change layout container types (Grid→Column, Row→Column, Grid→Row) unless the user explicitly asks to change the layout structure.
971
1049
  - If a Grid has columns="3", keep it as Grid with columns="3". Adding a toggle/filter/button to a toolbar does NOT change the content layout below.
@@ -368,10 +368,29 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
368
368
  }
369
369
  }
370
370
 
371
- // ── Canvas diff merge: apply LLM diff response onto prior canvas ──
372
- // Only merge when using diff format (large canvases ≥50 components).
373
- // For small canvases, the LLM was asked to return the COMPLETE modified array.
374
- if (hasPriorCanvas && priorComponents.length >= 50 && messages && messages.length > 0) {
371
+ // ── Canvas diff merge: apply LLM response onto prior canvas ──
372
+ //
373
+ // §168 (v0.5.4): merge always runs on iteration turns regardless of canvas
374
+ // size. Pre-§168 the gate was `priorComponents.length >= 50`, which created
375
+ // a three-band threshold mismatch:
376
+ // 0–49 : prompt asked for COMPLETE array, merge skipped → LLM output
377
+ // wholesale-replaced canvas (verified 2026-05-14 chart-workflow
378
+ // ticket: 32-component canvas + "add a chart" intent →
379
+ // 202-component response wiped the user's prior work)
380
+ // 50–299 : prompt asked for COMPLETE array, merge ran treating output
381
+ // as DIFF → prompt/code mismatch
382
+ // 300+ : prompt asked for DIFF only, merge ran (correct band)
383
+ //
384
+ // mergeCanvasDiff() is a strict superset of "replace" behaviorally:
385
+ // - When LLM returns COMPLETE array: every prior id is present in output
386
+ // → merged in-place (no-op if unchanged; updates if changed). Missing
387
+ // prior ids preserved. New ids appended. Net behavior = identical to
388
+ // wholesale-replace when LLM cooperates, safer when LLM returns subset.
389
+ // - When LLM returns DIFF: existing behavior preserved.
390
+ // Plus the hallucination-detection guards in mergeCanvasDiff (matchRatio
391
+ // < 0.2 reject + < 0.6 accept-with-warning) discriminate by match ratio,
392
+ // which is more principled than canvas-size heuristics.
393
+ if (hasPriorCanvas && messages && messages.length > 0) {
375
394
  for (const msg of messages) {
376
395
  if (msg.type === 'updateComponents' && msg.components) {
377
396
  msg.components = mergeCanvasDiff(priorComponents, msg.components);
@@ -0,0 +1,251 @@
1
+ /**
2
+ * §168 (v0.5.4) — Pin canvas-iteration merge behavior across canvas sizes.
3
+ *
4
+ * Pre-§168 the merge gate was `priorComponents.length >= 50`, creating a
5
+ * three-band threshold mismatch:
6
+ * 0–49 : prompt asked for COMPLETE array, merge skipped → wholesale replace
7
+ * 50–299 : prompt asked for COMPLETE array, merge ran treating output as DIFF
8
+ * 300+ : prompt asked for DIFF only, merge ran (correct band)
9
+ *
10
+ * Post-§168 the merge runs on every iteration turn regardless of size.
11
+ * mergeCanvasDiff is a strict superset of "replace" behaviorally — when LLM
12
+ * returns COMPLETE array, every prior id is matched + merged in place;
13
+ * when LLM returns DIFF, existing behavior preserved.
14
+ *
15
+ * Hallucination guards (matchRatio < 0.2 reject + < 0.6 accept-with-warning)
16
+ * protect against LLM canvas explosions like the 2026-05-14 chart-workflow
17
+ * regression (32 → 202 components).
18
+ */
19
+
20
+ import { describe, it, expect, vi } from 'vitest';
21
+ import { mergeCanvasDiff } from './_shared.js';
22
+
23
+ describe('§168 mergeCanvasDiff — iteration handoff invariants', () => {
24
+
25
+ describe('Strict-superset of wholesale-replace (the small-canvas band)', () => {
26
+ it('LLM returns COMPLETE array with all prior ids → merge preserves prior shape', () => {
27
+ const prior = [
28
+ { id: 'root', component: 'Grid', columns: 3, children: ['c1', 'c2', 'c3'] },
29
+ { id: 'c1', component: 'Card' },
30
+ { id: 'c2', component: 'Card' },
31
+ { id: 'c3', component: 'Card' },
32
+ ];
33
+ const llmOutput = [
34
+ { id: 'root', component: 'Grid', columns: 3, children: ['c1', 'c2', 'c3'] },
35
+ { id: 'c1', component: 'Card' },
36
+ { id: 'c2', component: 'Card' },
37
+ { id: 'c3', component: 'Card' },
38
+ ];
39
+ const result = mergeCanvasDiff(prior, llmOutput);
40
+ expect(result.length).toBe(4);
41
+ expect(result.map(c => c.id)).toEqual(['root', 'c1', 'c2', 'c3']);
42
+ });
43
+
44
+ it('LLM returns COMPLETE with one modification → merge applies the change', () => {
45
+ const prior = [
46
+ { id: 'root', component: 'Card', children: ['btn'] },
47
+ { id: 'btn', component: 'Button', text: 'Save' },
48
+ ];
49
+ const llmOutput = [
50
+ { id: 'root', component: 'Card', children: ['btn'] },
51
+ { id: 'btn', component: 'Button', text: 'Submit', variant: 'primary' },
52
+ ];
53
+ const result = mergeCanvasDiff(prior, llmOutput);
54
+ const btn = result.find(c => c.id === 'btn');
55
+ expect(btn.text).toBe('Submit');
56
+ expect(btn.variant).toBe('primary');
57
+ });
58
+
59
+ it('LLM returns COMPLETE with new addition → prior preserved + new appended', () => {
60
+ const prior = [
61
+ { id: 'root', component: 'Card', children: ['btn'] },
62
+ { id: 'btn', component: 'Button', text: 'Save' },
63
+ ];
64
+ const llmOutput = [
65
+ { id: 'root', component: 'Card', children: ['btn', 'btn-2'] },
66
+ { id: 'btn', component: 'Button', text: 'Save' },
67
+ { id: 'btn-2', component: 'Button', text: 'Cancel', variant: 'ghost' },
68
+ ];
69
+ const result = mergeCanvasDiff(prior, llmOutput);
70
+ expect(result.length).toBe(3);
71
+ expect(result.find(c => c.id === 'btn-2').text).toBe('Cancel');
72
+ });
73
+ });
74
+
75
+ describe('Hallucination-detection guards (the 32→202 regression class)', () => {
76
+ it('LLM hallucinates wholly-new canvas (matchRatio < 0.2) → REJECT and preserve prior', () => {
77
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
78
+ const prior = [
79
+ { id: 'root', component: 'Card', children: ['c1', 'c2', 'c3'] },
80
+ { id: 'c1', component: 'Card' },
81
+ { id: 'c2', component: 'Card' },
82
+ { id: 'c3', component: 'Card' },
83
+ ];
84
+ // LLM "added a chart" but rebuilt from memory with no matching ids
85
+ const llmOutput = [
86
+ { id: 'root', component: 'Grid', columns: 2, children: ['new-1', 'new-2', 'new-3', 'new-4', 'new-5'] },
87
+ { id: 'new-1', component: 'Card' },
88
+ { id: 'new-2', component: 'Card' },
89
+ { id: 'new-3', component: 'Chart', type: 'line' },
90
+ { id: 'new-4', component: 'Stat' },
91
+ { id: 'new-5', component: 'Text' },
92
+ ];
93
+ const result = mergeCanvasDiff(prior, llmOutput);
94
+ // Result preserves prior canvas (user is better off unchanged)
95
+ expect(result).toBe(prior);
96
+ expect(warn).toHaveBeenCalledWith(
97
+ expect.stringContaining('Hallucinated replacement rejected')
98
+ );
99
+ warn.mockRestore();
100
+ });
101
+
102
+ it('LLM does a major restructure (matchRatio 0.2-0.6) → accept-with-warning', () => {
103
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
104
+ const prior = [
105
+ { id: 'root', component: 'Card', children: ['a', 'b', 'c', 'd', 'e'] },
106
+ { id: 'a', component: 'Text' },
107
+ { id: 'b', component: 'Text' },
108
+ { id: 'c', component: 'Button' },
109
+ { id: 'd', component: 'Button' },
110
+ { id: 'e', component: 'Divider' },
111
+ ];
112
+ // LLM kept 1 of 7 ids (~14% → falls into the < 0.2 band; flip to 0.2-0.6
113
+ // band by sharing 2 of 7 ids = 28%)
114
+ const llmOutput = [
115
+ { id: 'root', component: 'Card', children: ['a', 'new-1', 'new-2', 'new-3', 'new-4', 'new-5'] },
116
+ { id: 'a', component: 'Text' },
117
+ { id: 'new-1', component: 'Chart' },
118
+ { id: 'new-2', component: 'Stat' },
119
+ { id: 'new-3', component: 'Stat' },
120
+ { id: 'new-4', component: 'Stat' },
121
+ { id: 'new-5', component: 'Stat' },
122
+ ];
123
+ // matchRatio = 2/7 = 0.286 (between 0.2 and 0.6) → accept-with-warning path
124
+ const result = mergeCanvasDiff(prior, llmOutput);
125
+ expect(result).toBe(llmOutput);
126
+ expect(warn).toHaveBeenCalledWith(
127
+ expect.stringContaining('Low-match rewrite accepted')
128
+ );
129
+ warn.mockRestore();
130
+ });
131
+ });
132
+
133
+ describe('DIFF format (the large-canvas band, unchanged behavior)', () => {
134
+ it('LLM returns ADD-only diff → prior preserved + new appended', () => {
135
+ const prior = [
136
+ { id: 'root', component: 'Card', children: ['btn'] },
137
+ { id: 'btn', component: 'Button', text: 'Save' },
138
+ ];
139
+ const llmDiff = [
140
+ { id: 'cancel', component: 'Button', text: 'Cancel', variant: 'ghost' },
141
+ ];
142
+ const result = mergeCanvasDiff(prior, llmDiff);
143
+ // Note: matchRatio is 0 here (1 new id, 0 matching), but only 1 component
144
+ // so the "hasRoot && length >= 5" guard doesn't fire (length < 5).
145
+ expect(result.length).toBe(3);
146
+ expect(result.find(c => c.id === 'btn')).toBeTruthy();
147
+ expect(result.find(c => c.id === 'cancel').text).toBe('Cancel');
148
+ });
149
+
150
+ it('LLM returns MODIFY diff → only matching id changes', () => {
151
+ const prior = [
152
+ { id: 'root', component: 'Card', children: ['btn', 'txt'] },
153
+ { id: 'btn', component: 'Button', text: 'Save' },
154
+ { id: 'txt', component: 'Text', textContent: 'Hello' },
155
+ ];
156
+ const llmDiff = [
157
+ { id: 'btn', text: 'Submit' },
158
+ ];
159
+ const result = mergeCanvasDiff(prior, llmDiff);
160
+ expect(result.find(c => c.id === 'btn').text).toBe('Submit');
161
+ // Component type preserved (modify-merge only updates declared fields)
162
+ expect(result.find(c => c.id === 'btn').component).toBe('Button');
163
+ // Other components untouched
164
+ expect(result.find(c => c.id === 'txt').textContent).toBe('Hello');
165
+ });
166
+
167
+ it('LLM returns DELETE diff → marked component removed', () => {
168
+ const prior = [
169
+ { id: 'root', component: 'Card', children: ['btn', 'txt'] },
170
+ { id: 'btn', component: 'Button' },
171
+ { id: 'txt', component: 'Text', textContent: 'Hello' },
172
+ ];
173
+ const llmDiff = [
174
+ { id: 'btn', _delete: true },
175
+ ];
176
+ const result = mergeCanvasDiff(prior, llmDiff);
177
+ expect(result.find(c => c.id === 'btn')).toBeUndefined();
178
+ expect(result.find(c => c.id === 'txt')).toBeTruthy();
179
+ });
180
+ });
181
+
182
+ describe('Layout-type guards (preserve Grid/Row/Column type during iteration)', () => {
183
+ it('LLM tries to change Grid → Column → block + warn', () => {
184
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
185
+ const prior = [
186
+ { id: 'root', component: 'Grid', columns: 3, children: ['c1', 'c2', 'c3'] },
187
+ { id: 'c1', component: 'Card' },
188
+ { id: 'c2', component: 'Card' },
189
+ { id: 'c3', component: 'Card' },
190
+ ];
191
+ const llmOutput = [
192
+ { id: 'root', component: 'Column', children: ['c1', 'c2', 'c3'] },
193
+ { id: 'c1', component: 'Card' },
194
+ { id: 'c2', component: 'Card' },
195
+ { id: 'c3', component: 'Card' },
196
+ ];
197
+ const result = mergeCanvasDiff(prior, llmOutput);
198
+ // Grid type preserved despite LLM's attempted change
199
+ expect(result.find(c => c.id === 'root').component).toBe('Grid');
200
+ expect(warn).toHaveBeenCalledWith(
201
+ expect.stringContaining('Blocked layout type change')
202
+ );
203
+ warn.mockRestore();
204
+ });
205
+ });
206
+
207
+ describe('§168 chart-workflow regression pin', () => {
208
+ // The 2026-05-14 user-reported scenario: 32-component canvas + "add chart"
209
+ // intent. Pre-§168 the LLM's 200-component response wholesale-replaced the
210
+ // canvas. Post-§168 the hallucination guard rejects the replacement.
211
+ it('32-component canvas + 200-component LLM response with low match ratio → reject', () => {
212
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
213
+ const prior = Array.from({ length: 32 }, (_, i) => ({
214
+ id: `prior-${i}`,
215
+ component: i === 0 ? 'Grid' : (i < 10 ? 'Card' : 'Text'),
216
+ ...(i === 0 ? { children: ['prior-1', 'prior-2', 'prior-3'] } : {}),
217
+ }));
218
+ prior[0] = { id: 'root', component: 'Grid', children: prior.slice(1, 9).map(c => c.id) };
219
+
220
+ // LLM rebuilt from memory — 200 components, 0 matching ids
221
+ const llmOutput = Array.from({ length: 200 }, (_, i) => ({
222
+ id: `new-${i}`,
223
+ component: i === 0 ? 'Grid' : 'Card',
224
+ ...(i === 0 ? { children: ['new-1', 'new-2'] } : {}),
225
+ }));
226
+ llmOutput[0] = { id: 'root', component: 'Grid', children: ['new-1', 'new-2'] };
227
+
228
+ const result = mergeCanvasDiff(prior, llmOutput);
229
+ // Result preserves prior 32-component canvas (guard fires)
230
+ expect(result).toBe(prior);
231
+ expect(warn).toHaveBeenCalledWith(
232
+ expect.stringContaining('Hallucinated replacement rejected')
233
+ );
234
+ warn.mockRestore();
235
+ });
236
+ });
237
+
238
+ describe('Empty-input edge cases', () => {
239
+ it('Empty diff → preserve prior unchanged', () => {
240
+ const prior = [{ id: 'root', component: 'Card' }];
241
+ const result = mergeCanvasDiff(prior, []);
242
+ expect(result).toBe(prior);
243
+ });
244
+
245
+ it('Empty prior → return diff as-is', () => {
246
+ const diff = [{ id: 'root', component: 'Card' }];
247
+ const result = mergeCanvasDiff([], diff);
248
+ expect(result).toBe(diff);
249
+ });
250
+ });
251
+ });
@@ -0,0 +1,228 @@
1
+ /**
2
+ * §163c (v0.5.4) — extractProps() catalog-driven prop fidelity test.
3
+ *
4
+ * Pins the §163 fix: the transpiler's `extractProps()` must extract
5
+ * every prop the v0.9 catalog declares for a given A2UI type, not just
6
+ * the props in the legacy per-type allowlist (which omitted `label`,
7
+ * `icon`, literal `variant=` for several types).
8
+ *
9
+ * Bug class (pre-§163, diagnosed 2026-05-14):
10
+ * <input-ui label="Password" type="password" placeholder="••••••••">
11
+ * → emitted { component: "Input" } — label/type/placeholder all dropped
12
+ * → rendered as blank rectangle in gen-ui canvas
13
+ *
14
+ * Test driver: synthetic MinimalElement objects (mimics the transpiler's
15
+ * fast-path token shape; no JSDOM dependency).
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import { extractProps } from './transpiler-maps.js';
20
+
21
+ /**
22
+ * Build a synthetic element with the given attributes Map.
23
+ * Matches the MinimalElement shape transpiler.js uses for tokens.
24
+ */
25
+ function elem(attrs = {}, opts = {}) {
26
+ const map = new Map(Object.entries(attrs));
27
+ return {
28
+ tagName: opts.tag || '',
29
+ attributes: map,
30
+ children: opts.children || [],
31
+ textContent: opts.text || '',
32
+ getAttribute(name) { return this.attributes.get(name) ?? null; },
33
+ };
34
+ }
35
+
36
+ describe('§163 extractProps — catalog-driven prop fidelity', () => {
37
+
38
+ describe('Input / TextField', () => {
39
+ it('extracts label, type, placeholder, autocomplete from <input-ui> attrs', () => {
40
+ const el = elem({
41
+ label: 'Password',
42
+ type: 'password',
43
+ name: 'password',
44
+ autocomplete: 'current-password',
45
+ placeholder: '••••••••',
46
+ required: '',
47
+ });
48
+ const props = extractProps(el, 'Input');
49
+ expect(props.label).toBe('Password');
50
+ expect(props.type).toBe('password');
51
+ expect(props.name).toBe('password');
52
+ expect(props.autocomplete).toBe('current-password');
53
+ expect(props.placeholder).toBe('••••••••');
54
+ expect(props.required).toBe(true);
55
+ });
56
+
57
+ it('keeps legacy TextField behavior', () => {
58
+ const el = elem({ name: 'email', value: 'foo@bar', readonly: '' });
59
+ const props = extractProps(el, 'TextField');
60
+ expect(props.name).toBe('email');
61
+ expect(props.value).toBe('foo@bar');
62
+ expect(props.readonly).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe('CheckBox / Toggle (alias-resolves to Check / Switch catalog entries)', () => {
67
+ it('extracts label from CheckBox (aliased to Check in catalog)', () => {
68
+ const el = elem({ name: 'remember', label: 'Remember me' });
69
+ const props = extractProps(el, 'CheckBox');
70
+ expect(props.label).toBe('Remember me');
71
+ expect(props.name).toBe('remember');
72
+ });
73
+
74
+ it('extracts label from Toggle (aliased to Switch in catalog)', () => {
75
+ const el = elem({ name: 'notifications', label: 'Enable notifications', checked: '' });
76
+ const props = extractProps(el, 'Toggle');
77
+ expect(props.label).toBe('Enable notifications');
78
+ expect(props.name).toBe('notifications');
79
+ expect(props.checked).toBe(true);
80
+ });
81
+ });
82
+
83
+ describe('Button', () => {
84
+ it('extracts icon and variant (literal attribute, not class-inferred)', () => {
85
+ const el = elem({
86
+ text: 'Email me a magic link',
87
+ icon: 'envelope-simple',
88
+ variant: 'outline',
89
+ });
90
+ const props = extractProps(el, 'Button');
91
+ expect(props.text).toBe('Email me a magic link');
92
+ expect(props.icon).toBe('envelope-simple');
93
+ expect(props.variant).toBe('outline');
94
+ });
95
+
96
+ it('extracts type=submit + variant=primary', () => {
97
+ const el = elem({ text: 'Sign in', variant: 'primary', type: 'submit' });
98
+ const props = extractProps(el, 'Button');
99
+ expect(props.text).toBe('Sign in');
100
+ expect(props.variant).toBe('primary');
101
+ expect(props.type).toBe('submit');
102
+ });
103
+ });
104
+
105
+ describe('Divider', () => {
106
+ it('extracts label (Divider had NO extractor pre-§163)', () => {
107
+ const el = elem({ label: 'or use a one-time code' });
108
+ const props = extractProps(el, 'Divider');
109
+ expect(props.label).toBe('or use a one-time code');
110
+ });
111
+ });
112
+
113
+ describe('Radio', () => {
114
+ it('extracts label + name + value + checked', () => {
115
+ const el = elem({ name: 'plan', value: 'pro', label: 'Pro tier', checked: '' });
116
+ const props = extractProps(el, 'Radio');
117
+ expect(props.label).toBe('Pro tier');
118
+ expect(props.name).toBe('plan');
119
+ expect(props.value).toBe('pro');
120
+ expect(props.checked).toBe(true);
121
+ });
122
+ });
123
+
124
+ describe('Image', () => {
125
+ it('extracts src + alt', () => {
126
+ const el = elem({ src: '/logo.svg', alt: 'Logo' });
127
+ const props = extractProps(el, 'Image');
128
+ expect(props.src).toBe('/logo.svg');
129
+ expect(props.alt).toBe('Logo');
130
+ });
131
+ });
132
+
133
+ describe('Link', () => {
134
+ it('extracts text + href + target', () => {
135
+ const el = elem({ href: '/docs', target: '_blank' }, { text: 'Read docs' });
136
+ const props = extractProps(el, 'Link');
137
+ expect(props.text).toBe('Read docs');
138
+ expect(props.href).toBe('/docs');
139
+ expect(props.target).toBe('_blank');
140
+ });
141
+ });
142
+
143
+ describe('Slider', () => {
144
+ it('extracts label + min/max/value/step', () => {
145
+ const el = elem({ label: 'Volume', min: '0', max: '100', value: '50', step: '5' });
146
+ const props = extractProps(el, 'Slider');
147
+ expect(props.label).toBe('Volume');
148
+ // min/max/value all numeric per catalog
149
+ expect(props.min).toBe(0);
150
+ expect(props.max).toBe(100);
151
+ expect(props.value).toBe(50);
152
+ });
153
+ });
154
+
155
+ describe('No regression on simple cases', () => {
156
+ it('Text extracts textContent from element body', () => {
157
+ const el = elem({}, { text: 'Hello world' });
158
+ const props = extractProps(el, 'Text');
159
+ expect(props.textContent).toBe('Hello world');
160
+ });
161
+
162
+ it('Layout types extract gap', () => {
163
+ const el = elem({ gap: '4' });
164
+ const props = extractProps(el, 'Column');
165
+ expect(props.gap).toBe('4');
166
+ });
167
+
168
+ it('Unknown A2UI type returns empty props (no catalog crash)', () => {
169
+ const el = elem({ foo: 'bar' });
170
+ const props = extractProps(el, 'NonexistentType');
171
+ // No throw; just returns {} or whatever falls through legacy logic
172
+ expect(typeof props).toBe('object');
173
+ });
174
+ });
175
+
176
+ describe('Boolean attribute handling', () => {
177
+ it('treats empty string as true (HTML boolean attr convention)', () => {
178
+ const el = elem({ disabled: '' });
179
+ const props = extractProps(el, 'Button');
180
+ expect(props.disabled).toBe(true);
181
+ });
182
+
183
+ it('treats explicit "false" as false', () => {
184
+ const el = elem({ disabled: 'false' });
185
+ const props = extractProps(el, 'Button');
186
+ // String 'false' should not become true (defensive: HTML never has disabled="false" in practice
187
+ // but our catalog-driven extractor handles it)
188
+ expect(props.disabled).toBe(false);
189
+ });
190
+ });
191
+
192
+ describe('§163 grounding: auth-signin-card-password regression pin', () => {
193
+ // These are the 6 elements from the 2026-05-14 diagnosis. Pre-§163
194
+ // they rendered as blank rectangles. Post-§163 they have full props.
195
+ it('Password Input — 6 props verified', () => {
196
+ const el = elem({
197
+ label: 'Password',
198
+ type: 'password',
199
+ name: 'password',
200
+ autocomplete: 'current-password',
201
+ placeholder: '••••••••',
202
+ required: '',
203
+ });
204
+ const props = extractProps(el, 'Input');
205
+ expect(Object.keys(props).sort()).toEqual(
206
+ ['autocomplete', 'label', 'name', 'placeholder', 'required', 'type'].sort()
207
+ );
208
+ });
209
+
210
+ it('Remember CheckBox — label survives the alias hop CheckBox→Check', () => {
211
+ const el = elem({ name: 'remember', label: 'Remember me' });
212
+ const props = extractProps(el, 'CheckBox');
213
+ expect(props).toEqual({ name: 'remember', label: 'Remember me' });
214
+ });
215
+
216
+ it('Magic-link Button — icon, text, variant all preserved', () => {
217
+ const el = elem({
218
+ text: 'Email me a magic link',
219
+ icon: 'envelope-simple',
220
+ variant: 'outline',
221
+ });
222
+ const props = extractProps(el, 'Button');
223
+ expect(props.text).toBe('Email me a magic link');
224
+ expect(props.icon).toBe('envelope-simple');
225
+ expect(props.variant).toBe('outline');
226
+ });
227
+ });
228
+ });
@@ -6,10 +6,162 @@
6
6
  * 1. Reverse A2UI registry (tag → type, for *-n elements)
7
7
  * 2. HTML tag map (native HTML → A2UI type)
8
8
  * 3. Input type / ARIA role sub-maps
9
+ *
10
+ * §163 (v0.5.4) — Prop-fidelity fix: extractProps() now runs a
11
+ * catalog-driven generic pass AFTER the legacy per-type blocks. The
12
+ * catalog (`@adia-ai/a2ui-corpus`'s v0.9 sidecars) is the single source
13
+ * of truth for which attributes are first-class props per component
14
+ * type. Pre-§163, the per-type blocks hardcoded an allowlist that
15
+ * omitted `label`, `icon`, literal `variant=`, and similar — see
16
+ * `.brain/postmortems/2026-05-14-lossy-transpiler-boundary.md`.
17
+ * The catalog-driven pass closes the drift class: when a new prop is
18
+ * added to the catalog (regenerated from `<name>.yaml`), the extractor
19
+ * picks it up automatically without manual sync.
9
20
  */
10
21
 
22
+ import catalogData from '../../corpus/catalog-a2ui_0_9.json' with { type: 'json' };
23
+
11
24
  import { registry } from '@adia-ai/a2ui-runtime';
12
25
 
26
+ // ── §163 Catalog-driven prop schema ──────────────────────────────────────
27
+ //
28
+ // Build CATALOG_PROPS at module-init by reading the v0.9 catalog. Each
29
+ // component's `properties` block declares its first-class props with
30
+ // type hints (string / boolean / number / enum). The transpiler uses
31
+ // this map to extract any prop the catalog declares, closing the
32
+ // pre-§163 drift class where the per-type allowlists (lines ~155-249
33
+ // below) omitted `label`, `icon`, literal `variant=`, etc.
34
+ //
35
+ // Aliasing: the runtime registry maps multiple A2UI type names to the
36
+ // same tag (e.g. CheckBox → check-ui, Toggle → switch-ui, Select →
37
+ // select-ui = ChoicePicker). The catalog is keyed by ONE canonical
38
+ // name per tag (e.g. `Check`, `Switch`, `ChoicePicker`). We build an
39
+ // alias map below so `extractProps(el, 'CheckBox')` resolves to the
40
+ // `Check` catalog entry.
41
+
42
+ /** @type {Map<string, {props: Map<string, {type: string, enum?: string[]}>, canonical: string}>} */
43
+ const CATALOG_PROPS = (() => {
44
+ const map = new Map();
45
+ const components = catalogData?.components || {};
46
+ for (const [name, def] of Object.entries(components)) {
47
+ const props = def?.properties || {};
48
+ const propMap = new Map();
49
+ for (const [pname, pdef] of Object.entries(props)) {
50
+ // Skip internal/computed props that aren't HTML attributes
51
+ if (pname === 'component' || pname === 'textContent') continue;
52
+ if (typeof pdef !== 'object' || !pdef) continue;
53
+ propMap.set(pname, {
54
+ type: pdef.type || 'string',
55
+ enum: pdef.enum || null,
56
+ });
57
+ }
58
+ map.set(name, { props: propMap, canonical: name });
59
+ }
60
+ return map;
61
+ })();
62
+
63
+ /**
64
+ * Map an A2UI type name (which may be an alias used by the runtime
65
+ * registry but not the catalog) to the canonical catalog name.
66
+ *
67
+ * Examples (driven by runtime/registry.js mappings):
68
+ * CheckBox → Check (registry: CheckBox→check-ui; catalog: Check)
69
+ * Toggle → Switch (registry: Toggle→switch-ui; catalog: Switch)
70
+ * Select → ChoicePicker (registry: Select→select-ui; catalog: ChoicePicker)
71
+ *
72
+ * Strategy: for every registry alias type, find the catalog entry whose
73
+ * tag (= kebab-cased catalog name + '-ui', or registry forward-lookup)
74
+ * matches the alias's runtime tag. The catalog often uses a name that
75
+ * is NOT in the registry as a forward entry (e.g. `Check` is in the
76
+ * catalog but the registry only has `CheckBox → check-ui`).
77
+ *
78
+ * Cached at module-init.
79
+ */
80
+ const TYPE_ALIAS = (() => {
81
+ const m = new Map();
82
+ // Pre-pass: every catalog name maps to itself (catalog names ARE valid A2UI types
83
+ // even when the runtime registry doesn't carry them as forward entries — e.g.
84
+ // `Check` is in catalog but registry only has `CheckBox → check-ui`).
85
+ for (const catName of CATALOG_PROPS.keys()) {
86
+ m.set(catName, catName);
87
+ }
88
+ // First pass: direct match (typeName IS the catalog canonical)
89
+ for (const typeName of registry.keys()) {
90
+ if (CATALOG_PROPS.has(typeName)) {
91
+ m.set(typeName, typeName);
92
+ }
93
+ }
94
+ // Second pass: type's tag matches another registry entry that's in the catalog
95
+ for (const [typeName, tagName] of registry.entries()) {
96
+ if (m.has(typeName)) continue;
97
+ for (const [otherType, otherTag] of registry.entries()) {
98
+ if (otherTag === tagName && CATALOG_PROPS.has(otherType)) {
99
+ m.set(typeName, otherType);
100
+ break;
101
+ }
102
+ }
103
+ }
104
+ // Third pass: derive canonical from tag — `check-ui` → `Check`,
105
+ // `switch-ui` → `Switch`. The catalog often has the canonical
106
+ // PascalCase that the registry doesn't list as a forward entry.
107
+ // We convert tag kebab → PascalCase and check the catalog.
108
+ for (const [typeName, tagName] of registry.entries()) {
109
+ if (m.has(typeName)) continue;
110
+ // 'check-ui' → 'Check'; 'switch-ui' → 'Switch'; 'list-item-ui' → 'ListItem'
111
+ const stripped = tagName.replace(/-ui$/, '');
112
+ const pascal = stripped.split('-').map(s => s[0]?.toUpperCase() + s.slice(1)).join('');
113
+ if (CATALOG_PROPS.has(pascal)) {
114
+ m.set(typeName, pascal);
115
+ }
116
+ }
117
+ return m;
118
+ })();
119
+
120
+ /**
121
+ * §163 generic extractor: pull every catalog-declared prop from the
122
+ * element's attributes. Type-aware (booleans become true on presence,
123
+ * numbers parse, enums validate optimistically).
124
+ *
125
+ * Runs AFTER the legacy per-type blocks below — so type-specific
126
+ * extraction (e.g. Button.text from textContent, Image.alt) wins on
127
+ * conflict. The generic pass FILLS IN props the legacy blocks miss
128
+ * (the prop-fidelity gap this fix closes).
129
+ *
130
+ * @param {object} el — DOM element or MinimalElement
131
+ * @param {string} a2uiType — Resolved A2UI type name (may be alias)
132
+ * @returns {Record<string, unknown>}
133
+ */
134
+ function extractCatalogProps(el, a2uiType) {
135
+ const canonical = TYPE_ALIAS.get(a2uiType);
136
+ if (!canonical) return {};
137
+ const schema = CATALOG_PROPS.get(canonical);
138
+ if (!schema) return {};
139
+ const attr = (name) => el.getAttribute?.(name) ?? el.attributes?.get?.(name) ?? null;
140
+ const props = {};
141
+ for (const [pname, pdef] of schema.props.entries()) {
142
+ const raw = attr(pname);
143
+ if (raw === null || raw === undefined) continue;
144
+
145
+ if (pdef.type === 'boolean') {
146
+ // HTML boolean attr convention: presence (incl. empty string) = true.
147
+ // <input required> → raw === '' → true
148
+ // <input required=""> → raw === '' → true
149
+ // <input required="required"> → raw === 'required' → true
150
+ // <input required="false"> → raw === 'false' → false (defensive)
151
+ props[pname] = raw !== 'false';
152
+ } else if (pdef.type === 'number' || pdef.type === 'integer') {
153
+ if (raw === '') continue;
154
+ const n = Number(raw);
155
+ props[pname] = Number.isFinite(n) ? n : raw;
156
+ } else {
157
+ // string (incl. enum) — skip empty
158
+ if (raw === '') continue;
159
+ props[pname] = raw;
160
+ }
161
+ }
162
+ return props;
163
+ }
164
+
13
165
  // ── Alias types to skip in reverse registry (prefer primary type) ────────
14
166
 
15
167
  const ALIAS_TYPES = new Set([
@@ -144,6 +296,13 @@ export function extractProps(el, a2uiType) {
144
296
  const props = {};
145
297
  const attr = (name) => el.getAttribute?.(name) ?? el.attributes?.get?.(name) ?? null;
146
298
 
299
+ // §163 (v0.5.4) — Catalog-driven generic extraction FIRST. Fills in
300
+ // every prop the v0.9 catalog declares for this type (label, icon,
301
+ // variant=, etc.). Legacy per-type blocks below run AFTER and can
302
+ // overwrite specific values (e.g. Button.text inferred from
303
+ // textContent when no `text=` attribute is set).
304
+ Object.assign(props, extractCatalogProps(el, a2uiType));
305
+
147
306
  // ── Text content (for leaf elements) ──
148
307
  if (['Text', 'Kbd', 'Code'].includes(a2uiType)) {
149
308
  const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
@@ -152,17 +311,23 @@ export function extractProps(el, a2uiType) {
152
311
 
153
312
  // ── Button ──
154
313
  if (a2uiType === 'Button') {
155
- const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
156
- if (text) props.text = text;
157
- const type = attr('type');
158
- if (type && type !== 'button') props.type = type;
159
- if (attr('disabled') !== null) props.disabled = true;
160
- // Variant inference from class
161
- const cls = attr('class') || '';
162
- if (cls.includes('primary')) props.variant = 'primary';
163
- else if (cls.includes('danger')) props.variant = 'danger';
164
- else if (cls.includes('ghost')) props.variant = 'ghost';
165
- else if (cls.includes('outline')) props.variant = 'outline';
314
+ if (props.text === undefined) {
315
+ const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
316
+ if (text) props.text = text;
317
+ }
318
+ if (props.type === undefined) {
319
+ const type = attr('type');
320
+ if (type && type !== 'button') props.type = type;
321
+ }
322
+ if (props.disabled === undefined && attr('disabled') !== null) props.disabled = true;
323
+ // Variant inference from class (only if generic pass didn't already extract literal `variant=` attr)
324
+ if (props.variant === undefined) {
325
+ const cls = attr('class') || '';
326
+ if (cls.includes('primary')) props.variant = 'primary';
327
+ else if (cls.includes('danger')) props.variant = 'danger';
328
+ else if (cls.includes('ghost')) props.variant = 'ghost';
329
+ else if (cls.includes('outline')) props.variant = 'outline';
330
+ }
166
331
  }
167
332
 
168
333
  // ── Link ── (§47 — <a> tags map to Link, not Button; carries href, target, rel)
@@ -204,10 +369,11 @@ export function extractProps(el, a2uiType) {
204
369
  // ── Slider ──
205
370
  if (a2uiType === 'Slider') {
206
371
  for (const name of ['min', 'max', 'value', 'step', 'name']) {
372
+ if (props[name] !== undefined) continue; // catalog pass already filled this
207
373
  const v = attr(name);
208
374
  if (v !== null && v !== '') props[name] = name === 'step' ? v : Number(v) || v;
209
375
  }
210
- if (attr('disabled') !== null) props.disabled = true;
376
+ if (props.disabled === undefined && attr('disabled') !== null) props.disabled = true;
211
377
  }
212
378
 
213
379
  // ── Image ──