@adia-ai/a2ui-compose 0.5.2 → 0.5.4
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 +197 -0
- package/package.json +1 -1
- package/strategies/monolithic/_shared.js +78 -2
- package/strategies/monolithic/generate-pro.js +23 -4
- package/strategies/monolithic/merge-canvas-diff.test.js +251 -0
- package/strategies/registry.js +27 -0
- package/strategies/zettel/composition-library.js +11 -1
- package/transpiler/extract-props.test.js +228 -0
- package/transpiler/transpiler-maps.js +178 -12
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,203 @@ generator graph.
|
|
|
12
12
|
|
|
13
13
|
_No pending changes._
|
|
14
14
|
|
|
15
|
+
## [0.5.4] - 2026-05-14
|
|
16
|
+
|
|
17
|
+
### Fixed — §163 transpiler prop-fidelity; catalog-driven extractor closes the harvester drop class (v0.5.4)
|
|
18
|
+
|
|
19
|
+
`packages/a2ui/compose/transpiler/transpiler-maps.js` rewritten to derive
|
|
20
|
+
the extractable-prop set from the v0.9 catalog at module init, replacing
|
|
21
|
+
the pre-§163 hand-maintained per-type allowlists that omitted `label`,
|
|
22
|
+
`icon`, literal `variant=`, and similar first-class props.
|
|
23
|
+
|
|
24
|
+
**Bug class diagnosed 2026-05-14** against composition `auth-signin-card-password`
|
|
25
|
+
rendering blank rectangles in the gen-ui canvas. Verified pre-§163 vs post-§163
|
|
26
|
+
against `apps/user-flow/app/auth/sign-in/password/password.contents.html`:
|
|
27
|
+
|
|
28
|
+
Password Input pre={} post={label,type,autocomplete,name,placeholder,required}
|
|
29
|
+
Remember check pre={name} post={name,label}
|
|
30
|
+
Sign in button pre={type} post={type,text,variant}
|
|
31
|
+
Divider pre={} post={label}
|
|
32
|
+
Magic-link btn pre={} post={icon,text,variant}
|
|
33
|
+
6-digit btn pre={} post={icon,text,variant}
|
|
34
|
+
|
|
35
|
+
**Implementation**: `CATALOG_PROPS` Map built at module init from
|
|
36
|
+
`@adia-ai/a2ui-corpus/catalog-a2ui_0_9.json`; `TYPE_ALIAS` map with
|
|
37
|
+
4 resolution passes (catalog-self-map + direct registry-match +
|
|
38
|
+
shared-tag-match + kebab-to-PascalCase fallback) — closes the
|
|
39
|
+
registry-vs-catalog name drift (registry has `CheckBox → check-ui`;
|
|
40
|
+
catalog uses `Check`). Generic catalog-driven pass runs FIRST in
|
|
41
|
+
`extractProps()`; legacy per-type blocks defer with `if (props.X === undefined)`
|
|
42
|
+
guards so catalog values win on conflict.
|
|
43
|
+
|
|
44
|
+
**Side-effect fixes**: pre-existing legacy quirks (Slider `'0'` → `'0'`
|
|
45
|
+
returning string instead of numeric `0`, Button `disabled="false"` → `true`)
|
|
46
|
+
incidentally fixed by the deferred-legacy pattern.
|
|
47
|
+
|
|
48
|
+
NEW `packages/a2ui/compose/transpiler/extract-props.test.js` (+228 LOC,
|
|
49
|
+
19 tests) pinning the regression class.
|
|
50
|
+
|
|
51
|
+
Cycle followup: §164 re-harvested all 230 chunks with the §163-fixed
|
|
52
|
+
transpiler (corpus package; +2920 LOC restored prop fidelity). §166a
|
|
53
|
+
added audit-script-family slot 12 (`check-extract-props-coverage.mjs`)
|
|
54
|
+
that re-verifies the catalog-vs-extractor invariant at every commit.
|
|
55
|
+
|
|
56
|
+
### Fixed — §168 canvas-iteration merge always-on; close the 50-threshold mismatch (v0.5.4)
|
|
57
|
+
|
|
58
|
+
`packages/a2ui/compose/strategies/monolithic/generate-pro.js:374` —
|
|
59
|
+
removed the `priorComponents.length >= 50` gate. `mergeCanvasDiff()`
|
|
60
|
+
now runs on every iteration turn regardless of canvas size.
|
|
61
|
+
|
|
62
|
+
Closes the chart-workflow regression diagnosed 2026-05-14:
|
|
63
|
+
32-component canvas + "add a chart" intent → 202-component response
|
|
64
|
+
wholesale-replaced the user's prior work (`exec_mp4zbj47_1`,
|
|
65
|
+
`comps= 202 score= 105`). Pre-§168 the 0–49 band had no merge gate
|
|
66
|
+
even though the prompt asked the LLM to return COMPLETE; LLM canvas
|
|
67
|
+
explosions wholesale-replaced. Now: `mergeCanvasDiff` runs always,
|
|
68
|
+
the hallucination-detection guards (matchRatio < 0.2 reject + < 0.6
|
|
69
|
+
accept-with-warning) discriminate by match ratio (more principled than
|
|
70
|
+
canvas-size heuristics), and small-canvas iterations correctly preserve
|
|
71
|
+
prior work when LLM speculatively expands.
|
|
72
|
+
|
|
73
|
+
`mergeCanvasDiff` is a strict superset of "wholesale-replace"
|
|
74
|
+
behaviorally: COMPLETE arrays merge to identical shape; DIFF arrays
|
|
75
|
+
process unchanged; hallucinated rewrites get rejected with
|
|
76
|
+
"Hallucinated replacement rejected" + canvas preservation.
|
|
77
|
+
|
|
78
|
+
NEW `merge-canvas-diff.test.js` (+251 LOC, 12 tests) pinning behavior
|
|
79
|
+
across all three bands (strict-superset-of-replace, hallucination
|
|
80
|
+
guards, DIFF format, layout-type guards, the §168 chart-workflow
|
|
81
|
+
regression fixture).
|
|
82
|
+
|
|
83
|
+
### Fixed — §169 iteration-prompt structural priming; Card/Stat/Chart anatomy carry-over (v0.5.4)
|
|
84
|
+
|
|
85
|
+
`packages/a2ui/compose/strategies/monolithic/_shared.js`
|
|
86
|
+
`buildCanvasDiffPrompt()` — added NEW `ITERATION_STRUCTURAL_RULES`
|
|
87
|
+
block injected into both the full-canvas + compact-summary branches.
|
|
88
|
+
|
|
89
|
+
Pre-§169 the iteration prompts said "Preserve slot attributes and
|
|
90
|
+
Card anatomy rules" without defining what those rules ARE. The
|
|
91
|
+
fresh-generation prompt at `buildSystemPrompt()` defines them
|
|
92
|
+
explicitly (Card > Header > [slot=heading...], Card > Section >
|
|
93
|
+
Column > [content], etc.) but iteration mode relied on multi-turn
|
|
94
|
+
context retention, which is unreliable.
|
|
95
|
+
|
|
96
|
+
Empirically the corpus enforces Card > Section > Column > Chart
|
|
97
|
+
(233 Section→Column + 95 Card→Section across 230 chunks). The
|
|
98
|
+
2026-05-14 user-reported "where chart data should live in the tree"
|
|
99
|
+
ticket was the LLM emitting Chart at the wrong depth during
|
|
100
|
+
iteration.
|
|
101
|
+
|
|
102
|
+
Token cost: ~150 tokens added to a 500-1500-token iteration prompt;
|
|
103
|
+
well within 32k max_tokens budget.
|
|
104
|
+
|
|
105
|
+
### Fixed — §167 surface yaml-declared deprecation to LLM via prompt builder (v0.5.4)
|
|
106
|
+
|
|
107
|
+
`packages/a2ui/compose/strategies/monolithic/_shared.js:274` (+ legacy
|
|
108
|
+
fallback at `:312`) — when building the per-component prop string,
|
|
109
|
+
append `(deprecated, do not use)` to any prop whose description
|
|
110
|
+
matches `/deprecated/i`.
|
|
111
|
+
|
|
112
|
+
Closes the `<avatar-ui name="...">` false-positive class observed
|
|
113
|
+
2026-05-14 console:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
class.js:60 [AdiaUI] <avatar-ui name="…"> is deprecated — use text="…" instead.
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Root cause: `avatar.yaml` + sidecar + catalog all declare
|
|
120
|
+
`Avatar.name` as a prop with description `"Deprecated alias for
|
|
121
|
+
text..."`. The prompt builder captured description into
|
|
122
|
+
`out.description` (line ~56) but never read it when assembling the
|
|
123
|
+
`props:` line. LLM saw both `name` and `text` as equally-valid prop
|
|
124
|
+
names and naturally picked `name` for "team member names" intents.
|
|
125
|
+
|
|
126
|
+
Post-§167 prompt:
|
|
127
|
+
```
|
|
128
|
+
- Avatar: ... props: icon:String, name:String (deprecated, do not use), ...
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Fixed — §172 ChatInput catalog entry + scanner sibling-yaml fix + intent-keyword family inclusion (v0.5.4)
|
|
132
|
+
|
|
133
|
+
Closes ticket `20260514045025-chatinput-training-data-unclea` —
|
|
134
|
+
user reported the generator created a redundant Button(primary)
|
|
135
|
+
sibling next to ChatInput, duplicating the built-in send button
|
|
136
|
+
that `<chat-input-ui>` already stamps.
|
|
137
|
+
|
|
138
|
+
**Three architectural layers fixed:**
|
|
139
|
+
|
|
140
|
+
1. **Build scanner sibling-yaml discovery** —
|
|
141
|
+
`scripts/build/components.mjs` (+15 LOC). Pre-§172 the scanner
|
|
142
|
+
only looked for `<name>/<name>.yaml`; sibling yamls in the same
|
|
143
|
+
dir were invisible. `<chat-input-ui>` (sibling of chat-thread)
|
|
144
|
+
and `<feed-item-ui>` (sibling of feed) were orphans —
|
|
145
|
+
registered at runtime but invisible to the catalog. Post-§172
|
|
146
|
+
sibling discovery is automatic.
|
|
147
|
+
|
|
148
|
+
2. **Retrieval catalog loader sibling-sidecar discovery** —
|
|
149
|
+
`packages/a2ui/retrieval/component-catalog.js:_loadNode()`
|
|
150
|
+
(+24 LOC). Same blind spot; same fix. Browser path already
|
|
151
|
+
recursive via `import.meta.glob('**/*.a2ui.json')`; Node path
|
|
152
|
+
now matches.
|
|
153
|
+
|
|
154
|
+
3. **Intent-keyword family inclusion in prompt builder** —
|
|
155
|
+
`packages/a2ui/compose/strategies/monolithic/_shared.js`
|
|
156
|
+
(+26 LOC). Pre-§172 the prompt's always-include list had generic
|
|
157
|
+
primitives only; chat/table/form/chart/nav components only
|
|
158
|
+
entered via retrieval-pattern match. Post-§172 the chat family
|
|
159
|
+
(`ChatInput, ChatThread, ChatComposer, ChatShell, ChatHeader,
|
|
160
|
+
ChatSidebar`) is force-included when the intent mentions
|
|
161
|
+
`chat/message/conversation/composer/thread`. Same for the other
|
|
162
|
+
4 families.
|
|
163
|
+
|
|
164
|
+
Together: ChatInput's full description, 3 a2ui rules, and 3
|
|
165
|
+
anti-patterns (the "DO NOT add a separate Button sibling for send"
|
|
166
|
+
guidance) now appear in every chat-intent prompt's CORPUS CONTEXT
|
|
167
|
+
block.
|
|
168
|
+
|
|
169
|
+
**Sixth distinct application of the SoT-coherence pattern** in
|
|
170
|
+
v0.5.4 (§163 + §167 + §168 + §169 + §170 + §172).
|
|
171
|
+
|
|
172
|
+
Commit `514e72697`.
|
|
173
|
+
|
|
174
|
+
### Maintenance — audit-script companions §173 + §175 ship in v0.5.4 (no source change to compose)
|
|
175
|
+
|
|
176
|
+
Repo-level audit scripts targeting compose drift classes:
|
|
177
|
+
|
|
178
|
+
- **§173 `check-iteration-prompt-coherence.mjs`** (slot 14) — guards against
|
|
179
|
+
regressions in `monolithic/_shared.js` deprecation-marker logic (§167) +
|
|
180
|
+
iteration-prompt structural rules block (§169).
|
|
181
|
+
- **§175 `check-registry-catalog-coherence.mjs`** (slot 16) — guards against
|
|
182
|
+
registry/catalog drift like §172 ChatInput orphan-component class.
|
|
183
|
+
|
|
184
|
+
No compose source change in §173/§175 — both detect future drift in compose's
|
|
185
|
+
catalog-vs-prompt + registry-vs-catalog invariants.
|
|
186
|
+
|
|
187
|
+
### Changed — `version`: `0.5.3` → `0.5.4`.
|
|
188
|
+
|
|
189
|
+
## [0.5.3] - 2026-05-14
|
|
190
|
+
|
|
191
|
+
### Fixed — Deterministic chunk-loading order in zettel composition library (§160, v0.5.3)
|
|
192
|
+
|
|
193
|
+
`strategies/zettel/composition-library.js`: `walk()` now sorts `fs.readdirSync` output via `localeCompare` before recursing. Pre-§160 the directory walker relied on filesystem-defined entry order, which varies across processes on APFS/ext4. When two chunks tied on retrieval score for a given intent, the first-loaded won the tie-break — so the same intent could match chunk A in one process and chunk B in the next.
|
|
194
|
+
|
|
195
|
+
Surfaced as ~40% flake rate on the `admin dashboard with kpi cards` retrieval probe post-§143 (the 8 new UI-primitive chunks compete with the original `dashboard-kpi-grid` template). Sorted walk pins load order; remaining flakiness is now constrained to a corpus-quality bug (Stat chunks strip `label`/`value`/`change` attrs in the `template` field) tracked as v0.5.4 F-S1a.
|
|
196
|
+
|
|
197
|
+
No behavior change for callers that don't hit a tie-break — most intents have a clear retrieval winner.
|
|
198
|
+
|
|
199
|
+
### Deprecated — `_debug.attempts` and `_debug.warnings` on free-form-composed results (§146, v0.5.3)
|
|
200
|
+
|
|
201
|
+
Finalizes the v0.6.0 deprecation schedule for the last two `_debug.*` fields that consumers might still read on `generateFreeFormAdapter`'s result. Both fields are dialog-recorder-gated (silently `undefined` when not recording) — the same access pattern §109 (v0.5.1) + §107a (v0.5.2) removed for `usedIngredients` / `rationale` / `plan`.
|
|
202
|
+
|
|
203
|
+
**v0.6.0 migration path** (folded into the inline `@deprecated` JSDoc on `strategies/registry.js`):
|
|
204
|
+
|
|
205
|
+
- `result._debug?.attempts` → `result.attempts: number` (LLM round-trips: 1 normally; 2-3 with hallucination retry or paraphrase-retry)
|
|
206
|
+
- `result._debug?.warnings` → `result.warnings: string[]` (non-fatal transpile findings: unknown substitution keys, layout-value fall-throughs, chunk-resolution warns)
|
|
207
|
+
|
|
208
|
+
**Scheduled removal**: v0.6.0 drops the `_debug` block entirely from the free-form-composed result shape. The dialog-recorder will read first-class fields directly. v0.5.3 is the **migration window** — any external consumers reading `_debug.attempts` / `_debug.warnings` should switch to the first-class fields before v0.6.0 ships.
|
|
209
|
+
|
|
210
|
+
**Internal verification**: zero live in-repo consumers (`grep -rn '_debug\?\.attempts\|_debug\.attempts\|_debug\?\.warnings\|_debug\.warnings' apps/ playgrounds/ catalog/ packages/` returns only the deprecation comment itself).
|
|
211
|
+
|
|
15
212
|
## [0.5.2] - 2026-05-13
|
|
16
213
|
|
|
17
214
|
### Changed — `plan` graduates from `_debug.*` to first-class on free-form-composed result (§107a infra, v0.5.2)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/a2ui-compose",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
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).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -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,12 @@ 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
|
+
if (v.description && /deprecated/i.test(v.description)) desc += ' (deprecated, do not use)';
|
|
279
311
|
return desc;
|
|
280
312
|
}).join(', ') : '';
|
|
281
313
|
const events = a2uiData.events?.length ? ` events: ${a2uiData.events.join(', ')}` : '';
|
|
@@ -313,6 +345,12 @@ KEY RULES:
|
|
|
313
345
|
if (v.enum) desc += `=(${v.enum.join('|')})`;
|
|
314
346
|
else if (v.type) desc += `:${v.type}`;
|
|
315
347
|
if (v.default !== undefined) desc += `[${v.default}]`;
|
|
348
|
+
// §167 (v0.5.4): surface yaml-declared deprecation to the LLM. Pre-§167
|
|
349
|
+
// the description field was captured into out.description (line ~56)
|
|
350
|
+
// but never read here, so deprecated props (Avatar.name, Field.persist
|
|
351
|
+
// alias, etc.) looked indistinguishable from canonical props. LLM
|
|
352
|
+
// would pick e.g. <avatar-ui name="..."> instead of text="...".
|
|
353
|
+
if (v.description && /deprecated/i.test(v.description)) desc += ' (deprecated, do not use)';
|
|
316
354
|
return desc;
|
|
317
355
|
}).join(', ') : '';
|
|
318
356
|
const events = comp.events?.length ? ` events: ${comp.events.join(', ')}` : '';
|
|
@@ -929,6 +967,43 @@ export function mergeCanvasDiff(priorComponents, diffComponents) {
|
|
|
929
967
|
* @param {string|null} [opts.originalIntent] — original design brief for drift anchoring
|
|
930
968
|
* @returns {string} prompt text for the canvas diff
|
|
931
969
|
*/
|
|
970
|
+
/**
|
|
971
|
+
* §169 (v0.5.4): structural rules block injected into iteration prompts.
|
|
972
|
+
*
|
|
973
|
+
* Pre-§169 the iteration prompts said "Preserve slot attributes and Card
|
|
974
|
+
* anatomy rules" without defining what those rules ARE. The fresh-generation
|
|
975
|
+
* prompt at buildSystemPrompt() defines them explicitly (Card > Header >
|
|
976
|
+
* [Text slot=heading...], Card > Section > Column > [content], etc.) but
|
|
977
|
+
* iteration mode relied on multi-turn context retention, which is unreliable.
|
|
978
|
+
*
|
|
979
|
+
* The 2026-05-14 chart-workflow ticket symptom: 32-component canvas + "add
|
|
980
|
+
* a chart" intent → LLM emitted Chart at the wrong nesting depth, prompting
|
|
981
|
+
* the user-friction complaint about "where chart data should live in the
|
|
982
|
+
* tree." Empirically the corpus enforces Card > Section > Column > Chart
|
|
983
|
+
* (233 Section→Column + 95 Card→Section across 230 chunks).
|
|
984
|
+
*
|
|
985
|
+
* This block echoes the fresh-generation contract's most-important
|
|
986
|
+
* structural guidance into iteration mode. Keeps token cost negligible
|
|
987
|
+
* (~150 tokens added to a 500-1500-token iteration prompt; well within
|
|
988
|
+
* 32k max_tokens budget).
|
|
989
|
+
*/
|
|
990
|
+
const ITERATION_STRUCTURAL_RULES = `
|
|
991
|
+
STRUCTURAL RULES (carry-over from fresh-generation contract — apply when ADDING new components):
|
|
992
|
+
- Card content: Card > Header > [Text slot="heading", Text slot="description", Button slot="action"]
|
|
993
|
+
Card > Section > Column > [content] (Section ALWAYS wraps children in a Column)
|
|
994
|
+
Card > Footer > [Button, Button] (action buttons go in Footer, not Section)
|
|
995
|
+
- Stats: use Stat component directly with {label, value, change, trend}; do NOT manually
|
|
996
|
+
build stat cards from Card+Header+Section+Text — emit a Stat node.
|
|
997
|
+
- Charts: place inside Section > Column. Chart props: type ("line"|"bar"|"area"), x (string),
|
|
998
|
+
y (string or array). Charts go in Section, not directly inside Card.
|
|
999
|
+
- Forms: wrap fields in Column with gap="3". Submit button in separate Row or Footer.
|
|
1000
|
+
- Tabs: Tab children = ONLY Tab components. Content panels are SIBLINGS of Tabs, not children.
|
|
1001
|
+
Column > [Tabs, Card, Card] (panels show/hide based on active tab; NOT Tab > Card).
|
|
1002
|
+
- Header slots: every Header child MUST have a slot= attribute (heading|description|action|icon).
|
|
1003
|
+
- Layout types: Grid/Row/Column types MUST NOT change during iteration unless the user explicitly
|
|
1004
|
+
requests a layout change. To restructure, DELETE the old container + ADD a new one.
|
|
1005
|
+
`;
|
|
1006
|
+
|
|
932
1007
|
export function buildCanvasDiffPrompt(intent, priorComponents, { originalIntent = null } = {}) {
|
|
933
1008
|
// Design intent anchor — prevents drift from the original brief
|
|
934
1009
|
const intentAnchor = originalIntent && originalIntent !== intent
|
|
@@ -946,10 +1021,10 @@ export function buildCanvasDiffPrompt(intent, priorComponents, { originalIntent
|
|
|
946
1021
|
${JSON.stringify(priorComponents, null, 2)}
|
|
947
1022
|
|
|
948
1023
|
The user is ITERATING on their existing canvas shown above. They want to MODIFY it, not replace it.
|
|
949
|
-
|
|
1024
|
+
${ITERATION_STRUCTURAL_RULES}
|
|
950
1025
|
Instructions:
|
|
951
1026
|
- Preserve ALL existing components unless explicitly asked to remove them
|
|
952
|
-
- Add new components where they logically belong
|
|
1027
|
+
- Add new components where they logically belong (following the STRUCTURAL RULES above)
|
|
953
1028
|
- Change text content, labels, icons to match the new intent
|
|
954
1029
|
- Preserve slot attributes and Card anatomy rules
|
|
955
1030
|
- 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 +1041,7 @@ Instructions:
|
|
|
966
1041
|
${summary}
|
|
967
1042
|
${intentAnchor}
|
|
968
1043
|
The user wants to MODIFY their existing canvas. Return ONLY the components you need to ADD, MODIFY, or DELETE:
|
|
1044
|
+
${ITERATION_STRUCTURAL_RULES}
|
|
969
1045
|
CRITICAL PRESERVATION RULES:
|
|
970
1046
|
- NEVER change layout container types (Grid→Column, Row→Column, Grid→Row) unless the user explicitly asks to change the layout structure.
|
|
971
1047
|
- 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
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
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
|
+
});
|
package/strategies/registry.js
CHANGED
|
@@ -191,6 +191,33 @@ async function generateFreeFormAdapter(ctx) {
|
|
|
191
191
|
// were removed by §109 / §107a; this block is now strictly debug-
|
|
192
192
|
// recorder fodder. Scheduled removal: v0.6.0 will fold `attempts` +
|
|
193
193
|
// `warnings` into first-class result fields and drop `_debug` here.
|
|
194
|
+
//
|
|
195
|
+
// §146 (v0.5.3): finalize the v0.6.0 deprecation schedule for the
|
|
196
|
+
// last two `_debug.*` fields that any consumer might still read.
|
|
197
|
+
//
|
|
198
|
+
// @deprecated `_debug.attempts` and `_debug.warnings` — both fields
|
|
199
|
+
// are dialog-recorder-gated (silently `undefined` when not
|
|
200
|
+
// recording), which is exactly the access pattern §109+§107a
|
|
201
|
+
// removed for `usedIngredients`/`rationale`/`plan`. v0.6.0 folds
|
|
202
|
+
// both into first-class result fields:
|
|
203
|
+
//
|
|
204
|
+
// - `result.attempts: number` — number of LLM round-trips taken
|
|
205
|
+
// to produce the final plan (1 normally; 2-3 with hallucination
|
|
206
|
+
// retry or paraphrase-retry; same value as §106's loop counter)
|
|
207
|
+
// - `result.warnings: string[]` — non-fatal transpile findings
|
|
208
|
+
// (unknown substitution keys, layout-value fall-throughs,
|
|
209
|
+
// chunk-resolution warns); same value as transpilePlan output
|
|
210
|
+
//
|
|
211
|
+
// Consumers should switch reads from `result._debug?.attempts` →
|
|
212
|
+
// `result.attempts` and `result._debug?.warnings` → `result.warnings`
|
|
213
|
+
// before v0.6.0 ships. The `_debug` block itself disappears from
|
|
214
|
+
// the free-form-composed result shape entirely in v0.6.0; the
|
|
215
|
+
// dialog-recorder will read first-class fields directly.
|
|
216
|
+
//
|
|
217
|
+
// No in-repo consumers currently read these fields (verified
|
|
218
|
+
// `grep -rn '_debug\?.attempts\|_debug.attempts\|_debug\?.warnings\|_debug.warnings'
|
|
219
|
+
// apps/ playgrounds/ catalog/ packages/` → 0 hits at v0.5.3 cut).
|
|
220
|
+
// External consumers should treat v0.6.0 as the migration window.
|
|
194
221
|
_debug: isRecording() ? {
|
|
195
222
|
systemPrompt: null,
|
|
196
223
|
rawLLMResponse: null,
|
|
@@ -101,7 +101,17 @@ async function _loadAllNode() {
|
|
|
101
101
|
|
|
102
102
|
function walk(dir, cb) {
|
|
103
103
|
if (!fs.existsSync(dir)) return;
|
|
104
|
-
|
|
104
|
+
// §160 (v0.5.3, F-S1 root cause): sort readdir output. fs.readdirSync
|
|
105
|
+
// returns entries in filesystem-defined order (variable across
|
|
106
|
+
// processes on APFS/ext4), which means chunk-loading order varied
|
|
107
|
+
// run-to-run. When two chunks tied on score for a given intent, the
|
|
108
|
+
// first-loaded won the tie-break — so the same intent could match
|
|
109
|
+
// chunk A in one process and chunk B in the next. Surfaced as
|
|
110
|
+
// ~40% probe-failure rate on admin-dashboard post-§143 (the new
|
|
111
|
+
// chunks compete with the original 4-row KPI composition).
|
|
112
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
113
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
114
|
+
for (const entry of entries) {
|
|
105
115
|
const p = path.join(dir, entry.name);
|
|
106
116
|
if (entry.isDirectory()) walk(p, cb);
|
|
107
117
|
else if (entry.name.endsWith('.json') && !entry.name.startsWith('_')) cb(p);
|
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 ──
|