@adia-ai/a2ui-compose 0.7.26 → 0.7.28
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 +93 -4
- package/package.json +1 -1
- package/strategies/free-form-composer/free-form-composer.test.js +23 -1
- package/strategies/free-form-composer/index.js +24 -3
- package/strategies/free-form-composer/transpile.js +6 -1
- package/strategies/monolithic/generate-pro.js +21 -13
- package/strategies/monolithic/generate-thinking.js +26 -30
- package/strategies/registry.js +52 -6
- package/strategies/registry.test.js +82 -0
- package/strategies/zettel/chunk-synthesizer.js +30 -1
- package/strategies/zettel/generator-adapter.js +19 -6
- package/strategies/zettel/issue-reporter.js +2 -2
- package/transpiler/extract-props.test.js +35 -0
- package/transpiler/transpiler-maps.js +53 -1
- package/transpiler/transpiler.js +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,93 @@
|
|
|
1
1
|
# Changelog — @adia-ai/a2ui-compose
|
|
2
|
+
## [0.7.28] — 2026-07-14
|
|
3
|
+
|
|
4
|
+
A2UI pipeline overhaul (comparing gen-ui-kit's system against a sibling
|
|
5
|
+
project's from-scratch A2UI implementation surfaced a concrete gap: only
|
|
6
|
+
one of six generation strategies ran a genuine closed-loop validate→retry
|
|
7
|
+
before returning; the rest either skipped it, did a single repair shot,
|
|
8
|
+
or validated a narrower plan/grounding contract).
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`packages/a2ui/compose/shared/validate-and-repair.js`** — a shared
|
|
13
|
+
closed-loop validate→repair helper, extracted from `generate-thinking`'s
|
|
14
|
+
original inline loop (schema + Ajv catalog + anti-pattern conformance,
|
|
15
|
+
bounded LLM-fed repair attempts). Every LLM-calling engine can now share
|
|
16
|
+
one implementation instead of hand-rolling its own; escalation policy
|
|
17
|
+
(retry vs. give up vs. escalate to a bigger tier) stays entirely with
|
|
18
|
+
the caller — the module only reports `{valid, exhausted, attempts,
|
|
19
|
+
failures}`.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **`generate-pro` — replaces its single hardcoded repair shot with the
|
|
24
|
+
shared closed loop** (`maxAttempts: 2`, reflecting pro's cheaper cost
|
|
25
|
+
tier vs. thinking's 3) and adds Ajv catalog-schema coverage it never had
|
|
26
|
+
before.
|
|
27
|
+
- **`free-form-composer` — now runs a real schema/catalog/anti-pattern
|
|
28
|
+
check (validate-only) on its transpiled output**, which it previously
|
|
29
|
+
never did at all (a hardcoded `score: 85` regardless of actual
|
|
30
|
+
validity). This surfaced a real, previously-invisible defect: the
|
|
31
|
+
synthesized root component used `id: 'free-form-root'` instead of the
|
|
32
|
+
`'root'` convention every other engine's root uses, which
|
|
33
|
+
`validator.js`'s `checkHasRootComponent` hard-fails on — fixed at the
|
|
34
|
+
source (`transpile.js`). `score` stays 85 on the success path
|
|
35
|
+
(unchanged), so the tuned `eval:diff` floors don't shift as a side
|
|
36
|
+
effect of adding a measurement; new `schemaValid`/`schemaValidation`
|
|
37
|
+
fields are additive.
|
|
38
|
+
- **`monolithic-instant` no longer silently returns a hard-fail's broken
|
|
39
|
+
output.** The dispatcher (`strategies/registry.js`'s
|
|
40
|
+
`generateInstantAdapter`) now escalates a hard-fail to `generate-pro`
|
|
41
|
+
exactly once, only when an `llmAdapter` is available — capped at one
|
|
42
|
+
hop by construction (a pro-mode failure returns straight to the
|
|
43
|
+
original caller, never chains to thinking). A counter +
|
|
44
|
+
`console.warn` ship from day one, since this silently converts a
|
|
45
|
+
0-cost request into a billed LLM call.
|
|
46
|
+
- **`chunk-zettel` now emits a real component graph and qualifies for
|
|
47
|
+
the shared closed loop (TKT-0009).** `composeFromIntent`
|
|
48
|
+
(`chunk-synthesizer.js`) previously returned only a raw HTML string,
|
|
49
|
+
which both callers wrapped into a single node with an unregistered
|
|
50
|
+
`component: 'article'` type — full schema validation could only ever
|
|
51
|
+
report `invalid`. Ratified direction: reuse `transpileHTML()` (the
|
|
52
|
+
same converter `harvest-chunks.mjs` already runs at corpus-admission
|
|
53
|
+
time) to produce a real `template` component array on every path (a
|
|
54
|
+
chunk's own precomputed template when available, else transpiled on
|
|
55
|
+
demand). Both callers (`registry.js`'s `generateChunkZettelAdapter`,
|
|
56
|
+
`generator-adapter.js`'s `bridgeToChunkSynthesis`) now prefer
|
|
57
|
+
`result.template` and route the final messages through
|
|
58
|
+
`validate-and-repair.js` (validate-only). Verified live: Tier-1
|
|
59
|
+
retrieval and Tier-2 synthesis intents both produce `valid:true`
|
|
60
|
+
component graphs (score 90/92); zero regression confirmed via a
|
|
61
|
+
stash-based before/after comparison of `eval:compose-from-chunks`
|
|
62
|
+
(the eval calls `composeFromIntent` directly and never touches the
|
|
63
|
+
caller code this change modified).
|
|
64
|
+
- **`transpiler/transpiler-maps.js` — dynamic array props (`Chart.data`,
|
|
65
|
+
`Table.data`) are now actually parsed, not copied through as raw
|
|
66
|
+
strings (TKT-0010).** Compiled dynamic props are a bare `$ref`
|
|
67
|
+
(`DynamicString`/`DynamicObjectList`/etc.) with no inline `type`, so
|
|
68
|
+
`CATALOG_PROPS`'s `pdef.type || 'string'` silently defaulted every
|
|
69
|
+
dynamic array prop to string extraction — a declarative
|
|
70
|
+
`data='[{...}]'` attribute's JSON text was copied through unparsed,
|
|
71
|
+
never `JSON.parse`d, so it could never satisfy the catalog's
|
|
72
|
+
array-shaped schema. Fixed by resolving `$ref` names to their
|
|
73
|
+
primitive kind (new `REF_TYPE_MAP`) and adding a `JSON.parse`-ing
|
|
74
|
+
array extraction branch (skipping silently on parse failure or a
|
|
75
|
+
non-array result, matching `chart.yaml`'s own documented "non-array
|
|
76
|
+
renders empty" contract). Root-caused via the corpus admission
|
|
77
|
+
validator surfacing the same `Chart`/`Table`.`data` `oneOf` failure
|
|
78
|
+
across 8+ unrelated chunks — a schema-generation/transpiler bug, not
|
|
79
|
+
independent content authoring mistakes. See `@adia-ai/a2ui-corpus`'s
|
|
80
|
+
CHANGELOG for the companion schema-side fix (`DynamicObjectList`).
|
|
81
|
+
|
|
82
|
+
## [0.7.27] — 2026-07-12
|
|
83
|
+
|
|
84
|
+
### Fixed
|
|
85
|
+
- **Transpiler — `[span]` is now universally preserved (TKT-0002).** `extractProps()` had a universal preserve for `[slot]` but not `[span]` — the same class of cross-cutting, layout-bearing attribute, consumed purely via `grid.css`'s `& > [span="N"]` child selectors and never declared in any component's own yaml `props:`. Every transpiled asymmetric grid collapsed to equal columns (the admin-dashboard's 3:2 Overview/Recent-Sales split was the operator-reported case). Regression tests cover `span="3"`, `span="full"`, and the absent case.
|
|
86
|
+
- **Transpiler — `Tag` is a leaf type; labels ride the `text` prop (TKT-0004 A).** `Tag` was missing from `LEAF_TYPES`, so `<tag-ui size="sm">label</tag-ui>` transpiled to `Tag > Text variant=body` — a full body-size paragraph inside the pill, inflating every converted chip (581 instances across 233 docs pages measured). Labels now ride tag.yaml's documented `attr(text)` contract, in both the pure-text and element-children extraction paths.
|
|
87
|
+
- **`strategies/zettel/issue-reporter.js` — issue storage repointed to `qa/findings/issues/`.** The previous path would have silently recreated the retired `.brain/` tree at runtime on the first `report_issue` call.
|
|
88
|
+
|
|
89
|
+
### Maintenance
|
|
90
|
+
- Transpiler test suite: `extract-props.test.js` 19 → 24 cases (span preservation ×3, Tag text contract ×2).
|
|
2
91
|
|
|
3
92
|
## [0.7.26] — 2026-07-04
|
|
4
93
|
|
|
@@ -916,7 +1005,7 @@ See root [CHANGELOG.md `[Unreleased]`](../../../CHANGELOG.md) for the v0.4.5 ove
|
|
|
916
1005
|
- **`compose/strategies/monolithic/generate-pro.js` + companion `_shared/fragment-expander.js` — fragment machinery removed (§37).** Fragments retired; 85 compositions inlined with previously-referenced fragment content. Expander deleted; its caller's try/catch in `generator-adapter.js` handles the absence gracefully.
|
|
917
1006
|
- **MCP server `mcp/server.js` (§37).** Removed `get_fragment` tool, simplified `zettel_stats`, updated imports + boot logs + engine description to reflect the post-fragment world.
|
|
918
1007
|
|
|
919
|
-
See root [CHANGELOG.md `[Unreleased]`](../../../CHANGELOG.md) for the cross-cutting arc narrative +
|
|
1008
|
+
See root [CHANGELOG.md `[Unreleased]`](../../../CHANGELOG.md) for the cross-cutting arc narrative + `.claude/docs/journal/2026/05/2026-05-12.md` §§ 37 / 38 / 41 / 47 for per-§ details.
|
|
920
1009
|
## [0.4.3] - 2026-05-11
|
|
921
1010
|
|
|
922
1011
|
### Fixed
|
|
@@ -1200,7 +1289,7 @@ packages now share one version, governed by
|
|
|
1200
1289
|
[`.claude/docs/specs/package-architecture.md` § 15 (Versioning Policy)](../../../.claude/docs/specs/package-architecture.md#15-versioning-policy).
|
|
1201
1290
|
This release also lands the `engine/` ↔ `engines/` collision fix from
|
|
1202
1291
|
T3 of the
|
|
1203
|
-
|
|
1292
|
+
`.claude/docs/plans/packages-architecture-fixes-2026-05-02.md`
|
|
1204
1293
|
plan.
|
|
1205
1294
|
|
|
1206
1295
|
### Changed
|
|
@@ -1255,8 +1344,8 @@ multi-turn surface, plus a first-class telemetry surface that auto-fires
|
|
|
1255
1344
|
issue tickets on engine failure paths.
|
|
1256
1345
|
|
|
1257
1346
|
Spec: [`.claude/docs/specs/genui-multiturn-architecture.md`](../../../.claude/docs/specs/genui-multiturn-architecture.md) (Active v0.1.0).
|
|
1258
|
-
Plan:
|
|
1259
|
-
ADR:
|
|
1347
|
+
Plan: `.claude/docs/plans/genui-multiturn-rollout-2026-04-28.md` (Phase A scoped).
|
|
1348
|
+
ADR: `0008-multiturn-genui-architecture.md`.
|
|
1260
1349
|
|
|
1261
1350
|
### Added (engine modules)
|
|
1262
1351
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/a2ui-compose",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.28",
|
|
4
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": {
|
|
@@ -293,7 +293,7 @@ describe('transpilePlan — root container (§103 auto-grouping)', () => {
|
|
|
293
293
|
}, lookup);
|
|
294
294
|
const root = result.messages[0].components[0];
|
|
295
295
|
expect(root.component).toBe('Column');
|
|
296
|
-
expect(root.id).toBe('
|
|
296
|
+
expect(root.id).toBe('root');
|
|
297
297
|
expect(root.wrap).toBeUndefined();
|
|
298
298
|
expect(root.columns).toBeUndefined();
|
|
299
299
|
});
|
|
@@ -490,3 +490,25 @@ describe('transpilePlan — multi-attribute substitutions (§105 v0.5.0)', () =>
|
|
|
490
490
|
expect(node.text).toBe('42');
|
|
491
491
|
});
|
|
492
492
|
});
|
|
493
|
+
|
|
494
|
+
// A2UI overhaul (Phase 3): full schema/anti-pattern/catalog check now runs
|
|
495
|
+
// on the transpiled result — previously this engine reported a hardcoded
|
|
496
|
+
// score=85 with NO real validation behind it at all.
|
|
497
|
+
describe('free-form-composer — schema validation (Phase 3 addition)', () => {
|
|
498
|
+
it('a validly-transpiled composition reports schemaValid: true, score stays 85 (unchanged eval-floor contract)', async () => {
|
|
499
|
+
const fakeLLM = {
|
|
500
|
+
complete: async () => ({
|
|
501
|
+
content: JSON.stringify({ ingredients: [{ name: 'demo-login' }], rationale: 'ok' }),
|
|
502
|
+
}),
|
|
503
|
+
};
|
|
504
|
+
const result = await generateFreeForm({
|
|
505
|
+
intent: 'sign me in',
|
|
506
|
+
llmAdapter: fakeLLM,
|
|
507
|
+
compositions: FIXTURE_VOCAB,
|
|
508
|
+
});
|
|
509
|
+
expect(result.strategy).toBe('free-form-composed');
|
|
510
|
+
expect(result.validation.score).toBe(85);
|
|
511
|
+
expect(result.schemaValid).toBe(true);
|
|
512
|
+
expect(result.schemaValidation).toBeTruthy();
|
|
513
|
+
});
|
|
514
|
+
});
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
buildFreeFormParaphraseRetry,
|
|
34
34
|
} from './system-prompt.js';
|
|
35
35
|
import { transpilePlan, parsePlan } from './transpile.js';
|
|
36
|
+
import { validateAndRepair } from '../../shared/validate-and-repair.js';
|
|
36
37
|
|
|
37
38
|
const MAX_ATTEMPTS = 2;
|
|
38
39
|
|
|
@@ -209,9 +210,27 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
|
|
|
209
210
|
};
|
|
210
211
|
}
|
|
211
212
|
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
213
|
+
// Full schema/catalog/anti-pattern conformance — the same final pass
|
|
214
|
+
// every other LLM-calling engine runs (see validate-and-repair.js) —
|
|
215
|
+
// ADDED here, not previously run at all; this engine used to report a
|
|
216
|
+
// hardcoded score=85 with no real validation behind it whatsoever.
|
|
217
|
+
// Validate-ONLY (no llmAdapter passed): a repair attempt here would mean
|
|
218
|
+
// re-authoring the ingredient PLAN, not the transpiled A2UI JSON
|
|
219
|
+
// directly — this engine's whole paradigm is ingredient selection +
|
|
220
|
+
// deterministic transpilation, not direct JSON authoring, so a schema-
|
|
221
|
+
// failure repair prompt has no natural target to hand back to the LLM.
|
|
222
|
+
// The existing hallucination-guard retry loop above (ingredient-name
|
|
223
|
+
// grounding) is this engine's own repair mechanism.
|
|
224
|
+
//
|
|
225
|
+
// score stays 85 on the success path — unchanged from before — so the
|
|
226
|
+
// eval:diff floors (cov/avg/F1) tuned against that number don't shift as
|
|
227
|
+
// a side effect of adding a measurement; schemaValid/schemaScore/
|
|
228
|
+
// antiPatternFailures are new, additive fields a future increment (or
|
|
229
|
+
// the eval baseline itself, deliberately) can act on.
|
|
230
|
+
const schemaCheck = await validateAndRepair({ messages });
|
|
231
|
+
if (!schemaCheck.valid) {
|
|
232
|
+
warnings = warnings.concat([`schema/anti-pattern check failed (score ${schemaCheck.validation.score}): ${schemaCheck.validation.checks?.filter(c => !c.passed).map(c => c.name).join(', ') || 'see schemaValidation'}`]);
|
|
233
|
+
}
|
|
215
234
|
const score = 85;
|
|
216
235
|
|
|
217
236
|
return {
|
|
@@ -224,5 +243,7 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
|
|
|
224
243
|
warnings,
|
|
225
244
|
rationale: plan.rationale || null,
|
|
226
245
|
emptyPlanRetryUsed,
|
|
246
|
+
schemaValid: schemaCheck.valid,
|
|
247
|
+
schemaValidation: schemaCheck.validation,
|
|
227
248
|
};
|
|
228
249
|
}
|
|
@@ -220,7 +220,12 @@ function buildRoot(plan, rootChildren, warnings) {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
const base = {
|
|
223
|
-
|
|
223
|
+
// 'root' — not 'free-form-root' — matches the convention every other
|
|
224
|
+
// engine's root component uses (validator.js's checkHasRootComponent
|
|
225
|
+
// hard-fails without it: "at least one component with id: 'root'").
|
|
226
|
+
// This engine never ran real schema validation before Phase 3 of the
|
|
227
|
+
// A2UI overhaul plan, so the mismatch went unnoticed until then.
|
|
228
|
+
id: 'root',
|
|
224
229
|
gap: '6',
|
|
225
230
|
children: rootChildren,
|
|
226
231
|
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* in strategies/monolithic/_shared.js.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { validateAndRepair } from '../../shared/validate-and-repair.js';
|
|
10
10
|
import { getContext, searchBlocks, lookupDomain } from '../../core/reference.js';
|
|
11
11
|
import { decomposeIntent } from '../../../retrieval/intent/decomposer.js';
|
|
12
12
|
import { assessClarity } from '../../../retrieval/intent/clarity.js';
|
|
@@ -403,18 +403,26 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
|
|
|
403
403
|
});
|
|
404
404
|
|
|
405
405
|
// ── Stage 5: Validate + repair ──
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
406
|
+
// Shared with every other LLM-calling engine — see validate-and-repair.js.
|
|
407
|
+
// maxAttempts: 2 (vs. thinking's 3) reflects pro's cheaper cost tier.
|
|
408
|
+
// runCatalogCheck adds Ajv catalog-schema coverage pro never had before —
|
|
409
|
+
// buildRepairPrompt's catalogFailures param already defaults to [] so
|
|
410
|
+
// this is additive, not a signature break.
|
|
411
|
+
const repairResult = await validateAndRepair({
|
|
412
|
+
messages,
|
|
413
|
+
llmAdapter,
|
|
414
|
+
buildRepairPrompt,
|
|
415
|
+
parseResponse: (repairResponse) => parseA2UIResponse(
|
|
416
|
+
repairResponse.content,
|
|
417
|
+
{ executionId: execId, intent, mode: 'pro', stopReason: repairResponse.stopReason },
|
|
418
|
+
),
|
|
419
|
+
systemPrompt,
|
|
420
|
+
validateContext: { intent, context },
|
|
421
|
+
foldInAntiPatterns,
|
|
422
|
+
maxAttempts: 2,
|
|
423
|
+
});
|
|
424
|
+
messages = repairResult.messages;
|
|
425
|
+
const validation = repairResult.validation;
|
|
418
426
|
engine.submitStage(execId, 'validate', { ...validation, confidence: validation.score / 100 });
|
|
419
427
|
|
|
420
428
|
// ── Stage 6: Store ──
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
* in strategies/monolithic/_shared.js.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
import { validateMessages as validateCatalog } from '../../../validator/catalog-validator.js';
|
|
9
|
+
import { validateAndRepair } from '../../shared/validate-and-repair.js';
|
|
11
10
|
import { getContext, searchBlocksSemantic, lookupDomain } from '../../core/reference.js';
|
|
12
11
|
import { assessClarity } from '../../../retrieval/intent/clarity.js';
|
|
13
12
|
import { feedbackStore } from '../../../retrieval/feedback/feedback-store.js';
|
|
@@ -122,35 +121,32 @@ export async function generateThinking({ intent, executionId, storeId, llmAdapte
|
|
|
122
121
|
});
|
|
123
122
|
|
|
124
123
|
// ── Stage 5: Validate + repair loop ──
|
|
125
|
-
// Two orthogonal validators run
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
});
|
|
146
|
-
messages = parseA2UIResponse(repairResponse.content, { executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason });
|
|
124
|
+
// Two orthogonal validators run: scored (weighted heuristic checks —
|
|
125
|
+
// intent alignment, card model, etc.) and catalog (AJV against v0.9
|
|
126
|
+
// catalog schema — strict structural correctness). Either failing
|
|
127
|
+
// triggers a repair attempt. Catalog failures are typically small
|
|
128
|
+
// (unknown prop, wrong enum, missing required id) and fix cleanly in one
|
|
129
|
+
// repair round. Three-attempt cap preserved from the original loop.
|
|
130
|
+
// Shared with every other LLM-calling engine — see validate-and-repair.js.
|
|
131
|
+
const repairResult = await validateAndRepair({
|
|
132
|
+
messages,
|
|
133
|
+
llmAdapter,
|
|
134
|
+
buildRepairPrompt,
|
|
135
|
+
parseResponse: (repairResponse) => parseA2UIResponse(
|
|
136
|
+
repairResponse.content,
|
|
137
|
+
{ executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason },
|
|
138
|
+
),
|
|
139
|
+
systemPrompt,
|
|
140
|
+
validateContext: { intent, context },
|
|
141
|
+
foldInAntiPatterns,
|
|
142
|
+
maxAttempts: 3,
|
|
143
|
+
onAttempt: (repairResponse) => {
|
|
147
144
|
if (isRecording()) { lastRawResponse = repairResponse.content; lastTokens = repairResponse.usage || lastTokens; }
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
messages = repairResult.messages;
|
|
148
|
+
const validation = repairResult.validation;
|
|
149
|
+
const attempts = repairResult.attempts;
|
|
154
150
|
|
|
155
151
|
engine.submitStage(execId, 'validate', {
|
|
156
152
|
...validation,
|
package/strategies/registry.js
CHANGED
|
@@ -52,11 +52,42 @@ export function registerMonolithicEngines({ instant, pro, thinking }) {
|
|
|
52
52
|
_monolithicThinking = thinking;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// A2UI overhaul plan, Phase 4: instant mode never repairs (it makes no LLM
|
|
56
|
+
// calls at all — that's its whole cost/latency identity) and previously
|
|
57
|
+
// returned a hard-fail's broken output silently. Escalation to pro lives
|
|
58
|
+
// HERE, at the dispatcher, not inside generate-instant.js itself — it's the
|
|
59
|
+
// caller's policy decision, matching validate-and-repair.js's "escalation
|
|
60
|
+
// stays with the caller" contract, and this is the one place that already
|
|
61
|
+
// holds both `ctx.llmAdapter` and `_monolithicPro` in scope.
|
|
62
|
+
//
|
|
63
|
+
// Capped at exactly one hop by construction: generateProAdapter has no
|
|
64
|
+
// escalation logic of its own, so a pro-mode failure after an instant
|
|
65
|
+
// escalation returns straight to the original caller — it can never chain
|
|
66
|
+
// into thinking. Only escalates when an llmAdapter is actually available;
|
|
67
|
+
// many instant-mode callers pass none (that's the 0-cost tier's point), and
|
|
68
|
+
// validate-and-repair.js's own contract for that case is "report the
|
|
69
|
+
// failure, don't invent a repair path" — same discipline here.
|
|
70
|
+
//
|
|
71
|
+
// This silently converts a 0-cost request into a billed LLM call, which is
|
|
72
|
+
// a product/cost-visibility concern, not just an engineering one — hence
|
|
73
|
+
// the counter + the warn log, from day one, not added later once someone
|
|
74
|
+
// asks "why did our LLM spend spike".
|
|
75
|
+
let _instantEscalationCount = 0;
|
|
76
|
+
export function getInstantEscalationCount() {
|
|
77
|
+
return _instantEscalationCount;
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
// Engine adapters — uniform call signature `(ctx) => Promise<result>` where
|
|
56
81
|
// ctx is prepared by the public generateUI() orchestrator.
|
|
57
82
|
async function generateInstantAdapter(ctx) {
|
|
58
83
|
if (!_monolithicInstant) throw new Error('monolithic-instant engine not registered');
|
|
59
|
-
|
|
84
|
+
const result = await _monolithicInstant(ctx);
|
|
85
|
+
if (!result.validation?.valid && ctx.llmAdapter && _monolithicPro) {
|
|
86
|
+
_instantEscalationCount++;
|
|
87
|
+
console.warn(`[a2ui] monolithic-instant hard-failed validation (score=${result.validation?.score}) for intent "${ctx.intent}" — escalating to monolithic-pro (escalation #${_instantEscalationCount})`);
|
|
88
|
+
return _monolithicPro(ctx);
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
60
91
|
}
|
|
61
92
|
async function generateProAdapter(ctx) {
|
|
62
93
|
if (!_monolithicPro) throw new Error('monolithic-pro engine not registered');
|
|
@@ -230,27 +261,42 @@ async function generateFreeFormAdapter(ctx) {
|
|
|
230
261
|
|
|
231
262
|
async function generateChunkZettelAdapter(ctx) {
|
|
232
263
|
const { composeFromIntent } = await import('./zettel/chunk-synthesizer.js');
|
|
264
|
+
const { validateAndRepair } = await import('../shared/validate-and-repair.js');
|
|
233
265
|
const result = await composeFromIntent({
|
|
234
266
|
intent: ctx.intent,
|
|
235
267
|
llmAdapter: ctx.llmAdapter || null,
|
|
236
268
|
maxAttempts: 2,
|
|
237
269
|
});
|
|
238
270
|
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
271
|
+
// TKT-0009: composeFromIntent now returns a real component array
|
|
272
|
+
// (`result.template`) whenever it can, instead of only an HTML string —
|
|
273
|
+
// use it directly; the pre-fix fallback (a single node carrying an
|
|
274
|
+
// UNREGISTERED `component: 'article'` type) only fires if transpilation
|
|
275
|
+
// itself somehow produced nothing, which should not happen in practice.
|
|
276
|
+
const messages = result.template
|
|
277
|
+
? [{ type: 'updateComponents', components: result.template }]
|
|
278
|
+
: result.html
|
|
279
|
+
? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
|
|
280
|
+
: [];
|
|
281
|
+
|
|
282
|
+
// Full schema/catalog/anti-pattern conformance — the same final pass
|
|
283
|
+
// every other engine runs (see validate-and-repair.js). Validate-only:
|
|
284
|
+
// no natural repair target here (the LLM authored a slot-binding PLAN,
|
|
285
|
+
// not this A2UI JSON directly) — chunk-composer's own `validatePlan`
|
|
286
|
+
// is this engine's repair-relevant check and already ran upstream.
|
|
287
|
+
const schemaCheck = messages.length ? await validateAndRepair({ messages }) : { valid: false, validation: { score: 0 } };
|
|
243
288
|
|
|
244
289
|
return {
|
|
245
290
|
executionId: ctx.executionId,
|
|
246
291
|
messages,
|
|
247
|
-
validation:
|
|
292
|
+
validation: schemaCheck.validation,
|
|
248
293
|
strategy: result.source === 'retrieval' ? 'chunk-retrieval' : 'chunk-synthesis',
|
|
249
294
|
engine: 'chunk-zettel',
|
|
250
295
|
_debug: {
|
|
251
296
|
plan: result.plan || null,
|
|
252
297
|
warnings: result.warnings || [],
|
|
253
298
|
scopeDrift: result.scopeDrift || null,
|
|
299
|
+
schemaValid: schemaCheck.valid,
|
|
254
300
|
},
|
|
255
301
|
};
|
|
256
302
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry.js — instant→pro escalation (A2UI overhaul plan, Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* Instant mode makes zero LLM calls and previously returned a hard-fail's
|
|
5
|
+
* broken output silently. The dispatcher now escalates to pro exactly once
|
|
6
|
+
* when an llmAdapter is available; escalation policy lives here, not inside
|
|
7
|
+
* generate-instant.js itself (see registry.js's comment above
|
|
8
|
+
* generateInstantAdapter).
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
11
|
+
import { registerMonolithicEngines, ENGINES, getInstantEscalationCount } from './registry.js';
|
|
12
|
+
|
|
13
|
+
const HARD_FAIL_RESULT = { messages: [], validation: { valid: false, score: 0 }, strategy: 'fallback' };
|
|
14
|
+
const VALID_RESULT = { messages: [{ type: 'updateComponents', components: [{ id: 'root', component: 'Card' }] }], validation: { valid: true, score: 90 }, strategy: 'pattern-match' };
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
// Reset to known fakes before every case — registerMonolithicEngines is
|
|
18
|
+
// the same public injection API core/generator.js uses at boot.
|
|
19
|
+
registerMonolithicEngines({ instant: null, pro: null, thinking: null });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('generateInstantAdapter — escalation to pro on hard-fail', () => {
|
|
23
|
+
it('does NOT escalate when instant mode already returns a valid result', async () => {
|
|
24
|
+
let proCalls = 0;
|
|
25
|
+
registerMonolithicEngines({
|
|
26
|
+
instant: async () => VALID_RESULT,
|
|
27
|
+
pro: async () => { proCalls++; return VALID_RESULT; },
|
|
28
|
+
thinking: async () => VALID_RESULT,
|
|
29
|
+
});
|
|
30
|
+
const before = getInstantEscalationCount();
|
|
31
|
+
const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: { complete: async () => ({}) } });
|
|
32
|
+
expect(result).toBe(VALID_RESULT);
|
|
33
|
+
expect(proCalls).toBe(0);
|
|
34
|
+
expect(getInstantEscalationCount()).toBe(before);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('escalates to pro exactly once when instant hard-fails AND an llmAdapter is available', async () => {
|
|
38
|
+
let proCalls = 0;
|
|
39
|
+
registerMonolithicEngines({
|
|
40
|
+
instant: async () => HARD_FAIL_RESULT,
|
|
41
|
+
pro: async (ctx) => { proCalls++; return { ...VALID_RESULT, receivedCtx: ctx }; },
|
|
42
|
+
thinking: async () => VALID_RESULT,
|
|
43
|
+
});
|
|
44
|
+
const before = getInstantEscalationCount();
|
|
45
|
+
const fakeAdapter = { complete: async () => ({}) };
|
|
46
|
+
const result = await ENGINES['monolithic-instant']({ intent: 'x', executionId: 'exec-1', storeId: 'store-1', llmAdapter: fakeAdapter });
|
|
47
|
+
expect(proCalls).toBe(1);
|
|
48
|
+
expect(result.validation.valid).toBe(true);
|
|
49
|
+
expect(getInstantEscalationCount()).toBe(before + 1);
|
|
50
|
+
// Same ctx (storeId/executionId/llmAdapter) threaded through unmodified —
|
|
51
|
+
// no session/store desync risk from a reconstructed context.
|
|
52
|
+
expect(result.receivedCtx.executionId).toBe('exec-1');
|
|
53
|
+
expect(result.receivedCtx.storeId).toBe('store-1');
|
|
54
|
+
expect(result.receivedCtx.llmAdapter).toBe(fakeAdapter);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('does NOT escalate when instant hard-fails but no llmAdapter is available — reports the failure as-is', async () => {
|
|
58
|
+
let proCalls = 0;
|
|
59
|
+
registerMonolithicEngines({
|
|
60
|
+
instant: async () => HARD_FAIL_RESULT,
|
|
61
|
+
pro: async () => { proCalls++; return VALID_RESULT; },
|
|
62
|
+
thinking: async () => VALID_RESULT,
|
|
63
|
+
});
|
|
64
|
+
const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: null });
|
|
65
|
+
expect(proCalls).toBe(0);
|
|
66
|
+
expect(result).toBe(HARD_FAIL_RESULT);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('caps escalation at exactly one hop — a pro-mode failure never chains to thinking', async () => {
|
|
70
|
+
let proCalls = 0;
|
|
71
|
+
let thinkingCalls = 0;
|
|
72
|
+
registerMonolithicEngines({
|
|
73
|
+
instant: async () => HARD_FAIL_RESULT,
|
|
74
|
+
pro: async () => { proCalls++; return HARD_FAIL_RESULT; }, // pro ALSO fails
|
|
75
|
+
thinking: async () => { thinkingCalls++; return VALID_RESULT; },
|
|
76
|
+
});
|
|
77
|
+
const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: { complete: async () => ({}) } });
|
|
78
|
+
expect(proCalls).toBe(1);
|
|
79
|
+
expect(thinkingCalls).toBe(0); // never chains further — pro's own failure returns to the original caller
|
|
80
|
+
expect(result.validation.valid).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -27,6 +27,29 @@ import {
|
|
|
27
27
|
getAllChunks,
|
|
28
28
|
} from '../../../corpus/scripts/chunk-library.js';
|
|
29
29
|
import { composeFromPlan, validatePlan } from './chunk-composer.js';
|
|
30
|
+
import { transpileHTML } from '../../transpiler/transpiler.js';
|
|
31
|
+
|
|
32
|
+
// TKT-0009: composeFromIntent used to return only a raw HTML string,
|
|
33
|
+
// which every caller wrapped in a single node carrying an UNREGISTERED
|
|
34
|
+
// A2UI type (`component: 'article'`) — meaning full schema/catalog
|
|
35
|
+
// validation against it always reported invalid, regardless of the
|
|
36
|
+
// composed content's actual quality (registry.has('article') is false).
|
|
37
|
+
// Ratified direction: reuse the SAME transpileHTML() infrastructure the
|
|
38
|
+
// docs-transpiler and harvest-chunks.mjs already run at scale, rather
|
|
39
|
+
// than inventing a new passthrough construct — this makes every
|
|
40
|
+
// composeFromIntent() result a REAL component graph, so it qualifies for
|
|
41
|
+
// the shared validate-and-repair loop like every other engine. A chunk's
|
|
42
|
+
// own precomputed `.template` (from harvest-chunks.mjs) is used directly
|
|
43
|
+
// when available (accurate, no re-transpile cost); multi-instance chunk
|
|
44
|
+
// records don't carry one per-instance, so this falls back to
|
|
45
|
+
// transpiling the HTML on demand either way — every return path leaves
|
|
46
|
+
// with a real template, never just a string.
|
|
47
|
+
async function ensureTemplate(html, precomputedTemplate) {
|
|
48
|
+
if (Array.isArray(precomputedTemplate) && precomputedTemplate.length) return precomputedTemplate;
|
|
49
|
+
if (!html) return null;
|
|
50
|
+
const { messages } = await transpileHTML(html);
|
|
51
|
+
return messages?.[0]?.components ?? null;
|
|
52
|
+
}
|
|
30
53
|
|
|
31
54
|
const STRONG_RETRIEVAL_SCORE = 8; // search-score threshold for fast path
|
|
32
55
|
const PRE_SEARCH_LIMIT = 30; // chunks shown to the LLM in the prompt
|
|
@@ -199,7 +222,11 @@ function extractJSON(raw) {
|
|
|
199
222
|
* @param {string} opts.intent
|
|
200
223
|
* @param {object} opts.llmAdapter — { complete: async ({ messages, systemPrompt }) => { content|text } }
|
|
201
224
|
* @param {number} [opts.maxAttempts=2]
|
|
202
|
-
* @returns {Promise<{ html: string, plan: object|null, source: 'retrieval'|'synthesis', score?: number, warnings: string[], synthesis?: object }>}
|
|
225
|
+
* @returns {Promise<{ html: string, template: object[]|null, plan: object|null, source: 'retrieval'|'synthesis', score?: number, warnings: string[], synthesis?: object }>}
|
|
226
|
+
* `template` (TKT-0009) is a real A2UI component array — derived from the
|
|
227
|
+
* chunk's own precomputed template when available, else transpiled from
|
|
228
|
+
* `html` on demand — so callers get a validatable component graph, not
|
|
229
|
+
* just an HTML string to wrap opaquely.
|
|
203
230
|
*/
|
|
204
231
|
export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFAULT_MAX_ATTEMPTS }) {
|
|
205
232
|
// Tier 1 — retrieval. Try semantic-blended hit first; fall back to keyword
|
|
@@ -228,6 +255,7 @@ export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFA
|
|
|
228
255
|
const scopeDrift = computeScopeDrift(html, [top]);
|
|
229
256
|
return {
|
|
230
257
|
html,
|
|
258
|
+
template: await ensureTemplate(html, top.template),
|
|
231
259
|
plan: null,
|
|
232
260
|
source: 'retrieval',
|
|
233
261
|
score: hit.score,
|
|
@@ -339,6 +367,7 @@ export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFA
|
|
|
339
367
|
|
|
340
368
|
return {
|
|
341
369
|
html: composed.html,
|
|
370
|
+
template: await ensureTemplate(composed.html, null),
|
|
342
371
|
plan,
|
|
343
372
|
source: 'synthesis',
|
|
344
373
|
warnings: [...composed.warnings, ...driftWarnings],
|
|
@@ -47,11 +47,18 @@ import { validateSchema } from '../../../validator/validator.js';
|
|
|
47
47
|
async function bridgeToChunkSynthesis({ intent, llmAdapter }) {
|
|
48
48
|
const { composeFromIntent } = await import('./chunk-synthesizer.js');
|
|
49
49
|
const result = await composeFromIntent({ intent, llmAdapter, maxAttempts: 2 });
|
|
50
|
-
// Shape-adapt chunk-synthesizer's `{ html, plan, source, ... }`
|
|
51
|
-
// synthesis-branch's prior `{ messages, template, synthesis }`
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
// Shape-adapt chunk-synthesizer's `{ html, template, plan, source, ... }`
|
|
51
|
+
// to the synthesis-branch's prior `{ messages, template, synthesis }`
|
|
52
|
+
// contract. TKT-0009: prefer the real component array (`result.template`)
|
|
53
|
+
// over the pre-fix fallback of wrapping raw HTML in a single node with an
|
|
54
|
+
// UNREGISTERED `component: 'article'` type — that fallback made this
|
|
55
|
+
// caller's validate-and-repair pass below structurally unable to ever
|
|
56
|
+
// report a clean pass.
|
|
57
|
+
const messages = result.template
|
|
58
|
+
? [{ type: 'updateComponents', components: result.template }]
|
|
59
|
+
: result.html
|
|
60
|
+
? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
|
|
61
|
+
: [];
|
|
55
62
|
return {
|
|
56
63
|
messages,
|
|
57
64
|
template: result.plan ? [{ $chunk: 'composeFromIntent', plan: result.plan }] : [],
|
|
@@ -172,7 +179,13 @@ export async function generateZettel({ intent, mode = 'instant', llmAdapter = nu
|
|
|
172
179
|
if (llmAdapter && mode !== 'instant-only') {
|
|
173
180
|
try {
|
|
174
181
|
const synth = await bridgeToChunkSynthesis({ intent, llmAdapter });
|
|
175
|
-
|
|
182
|
+
// Full schema/catalog/anti-pattern conformance (validate-only — see
|
|
183
|
+
// validate-and-repair.js; TKT-0009 made this meaningful for the first
|
|
184
|
+
// time by giving synth.messages a real component graph instead of an
|
|
185
|
+
// opaque HTML wrapper).
|
|
186
|
+
const { validateAndRepair } = await import('../../shared/validate-and-repair.js');
|
|
187
|
+
const schemaCheck = synth.messages.length ? await validateAndRepair({ messages: synth.messages }) : { valid: false, validation: { score: 0 } };
|
|
188
|
+
const validation = schemaCheck.validation;
|
|
176
189
|
// Chunk-bridge produces an html-bearing single component, not
|
|
177
190
|
// resolved fragment instances. `fragments_used` is empty by design;
|
|
178
191
|
// consumers tracking fragment usage should switch to the
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 3. Engine auto-fire — internal failure paths trigger `autoReport(reason)`.
|
|
8
8
|
* ctx.reporter = 'auto'. Suppressed when ctx.evalMode is true.
|
|
9
9
|
*
|
|
10
|
-
* Every issue lands as immutable JSON under
|
|
10
|
+
* Every issue lands as immutable JSON under `qa/findings/issues/`.
|
|
11
11
|
* Traces > 200KB spill to a sidecar `.trace.json` next to the issue.
|
|
12
12
|
*
|
|
13
13
|
* Spec: .claude/docs/specs/genui-multiturn-architecture.md §3.5 + §4.6 + §11.
|
|
@@ -23,7 +23,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
23
23
|
|
|
24
24
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
25
|
const REPO_ROOT = resolve(__dirname, '../../../../..');
|
|
26
|
-
const DEFAULT_STORAGE_ROOT = resolve(REPO_ROOT, '
|
|
26
|
+
const DEFAULT_STORAGE_ROOT = resolve(REPO_ROOT, 'qa/findings/issues');
|
|
27
27
|
const TRACE_INLINE_THRESHOLD_BYTES = 200 * 1024;
|
|
28
28
|
|
|
29
29
|
const VALID_TYPES = new Set(['bug', 'training-gap', 'protocol-gap', 'ux-feedback']);
|
|
@@ -225,4 +225,39 @@ describe('§163 extractProps — catalog-driven prop fidelity', () => {
|
|
|
225
225
|
expect(props.variant).toBe('outline');
|
|
226
226
|
});
|
|
227
227
|
});
|
|
228
|
+
|
|
229
|
+
describe('TKT-0004 — Tag label via text prop (attr(text) contract)', () => {
|
|
230
|
+
it('falls back to light-DOM textContent when no text= attribute', () => {
|
|
231
|
+
const el = elem({ size: 'sm', variant: 'success' }, { text: '~1 min · no tools' });
|
|
232
|
+
const props = extractProps(el, 'Tag');
|
|
233
|
+
expect(props.text).toBe('~1 min · no tools');
|
|
234
|
+
expect(props.size).toBe('sm');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('an explicit text= attribute wins over textContent', () => {
|
|
238
|
+
const el = elem({ text: 'explicit' }, { text: 'ignored light-dom' });
|
|
239
|
+
const props = extractProps(el, 'Tag');
|
|
240
|
+
expect(props.text).toBe('explicit');
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('TKT-0002 — [span] universal preservation', () => {
|
|
245
|
+
it('preserves span on a Card (grid-ui child), same as slot', () => {
|
|
246
|
+
const el = elem({ span: '3' });
|
|
247
|
+
const props = extractProps(el, 'Card');
|
|
248
|
+
expect(props.span).toBe('3');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('preserves span="full" verbatim (not coerced to a number)', () => {
|
|
252
|
+
const el = elem({ span: 'full' });
|
|
253
|
+
const props = extractProps(el, 'Card');
|
|
254
|
+
expect(props.span).toBe('full');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('omits span when absent — no stray empty prop', () => {
|
|
258
|
+
const el = elem({});
|
|
259
|
+
const props = extractProps(el, 'Card');
|
|
260
|
+
expect(props.span).toBeUndefined();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
228
263
|
});
|
|
@@ -39,6 +39,21 @@ import { registry } from '@adia-ai/a2ui-runtime';
|
|
|
39
39
|
// alias map below so `extractProps(el, 'CheckBox')` resolves to the
|
|
40
40
|
// `Check` catalog entry.
|
|
41
41
|
|
|
42
|
+
// TKT-0010: dynamic props are compiled to a bare `$ref` (DynamicString /
|
|
43
|
+
// DynamicNumber / DynamicBoolean / DynamicStringList / DynamicObjectList),
|
|
44
|
+
// which carries no inline `type` — so `pdef.type || 'string'` below silently
|
|
45
|
+
// treated EVERY dynamic array prop (Chart.data, Table.data) as a string,
|
|
46
|
+
// copying the raw `data='[...]'` attribute text through unparsed instead of
|
|
47
|
+
// JSON-parsing it into a real array. Resolve the ref name to its primitive
|
|
48
|
+
// kind first so extractCatalogProps' array branch actually fires.
|
|
49
|
+
const REF_TYPE_MAP = {
|
|
50
|
+
DynamicString: 'string',
|
|
51
|
+
DynamicNumber: 'number',
|
|
52
|
+
DynamicBoolean: 'boolean',
|
|
53
|
+
DynamicStringList: 'array',
|
|
54
|
+
DynamicObjectList: 'array',
|
|
55
|
+
};
|
|
56
|
+
|
|
42
57
|
/** @type {Map<string, {props: Map<string, {type: string, enum?: string[]}>, canonical: string}>} */
|
|
43
58
|
const CATALOG_PROPS = (() => {
|
|
44
59
|
const map = new Map();
|
|
@@ -50,8 +65,13 @@ const CATALOG_PROPS = (() => {
|
|
|
50
65
|
// Skip internal/computed props that aren't HTML attributes
|
|
51
66
|
if (pname === 'component' || pname === 'textContent') continue;
|
|
52
67
|
if (typeof pdef !== 'object' || !pdef) continue;
|
|
68
|
+
let type = pdef.type;
|
|
69
|
+
if (!type && typeof pdef.$ref === 'string') {
|
|
70
|
+
const refName = pdef.$ref.split('/').pop();
|
|
71
|
+
type = REF_TYPE_MAP[refName];
|
|
72
|
+
}
|
|
53
73
|
propMap.set(pname, {
|
|
54
|
-
type:
|
|
74
|
+
type: type || 'string',
|
|
55
75
|
enum: pdef.enum || null,
|
|
56
76
|
});
|
|
57
77
|
}
|
|
@@ -153,6 +173,18 @@ function extractCatalogProps(el, a2uiType) {
|
|
|
153
173
|
if (raw === '') continue;
|
|
154
174
|
const n = Number(raw);
|
|
155
175
|
props[pname] = Number.isFinite(n) ? n : raw;
|
|
176
|
+
} else if (pdef.type === 'array') {
|
|
177
|
+
// TKT-0010: declarative JSON-array attribute (e.g. chart-ui/table-ui's
|
|
178
|
+
// data='[{...}]'), hydrated once at connect per those components' own
|
|
179
|
+
// yaml docs. Parse it — the raw string never satisfies the catalog's
|
|
180
|
+
// array schema, and per chart.yaml's own contract a non-array value
|
|
181
|
+
// is meant to render empty, so a parse failure/non-array is skipped,
|
|
182
|
+
// not passed through.
|
|
183
|
+
if (raw === '') continue;
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(raw);
|
|
186
|
+
if (Array.isArray(parsed)) props[pname] = parsed;
|
|
187
|
+
} catch { /* not valid JSON — skip */ }
|
|
156
188
|
} else {
|
|
157
189
|
// string (incl. enum) — skip empty
|
|
158
190
|
if (raw === '') continue;
|
|
@@ -317,6 +349,17 @@ export function extractProps(el, a2uiType) {
|
|
|
317
349
|
const slotAttr = attr('slot');
|
|
318
350
|
if (slotAttr) props.slot = slotAttr;
|
|
319
351
|
|
|
352
|
+
// Universal: preserve [span] for every element. Span is a layout-bearing
|
|
353
|
+
// attribute grid-ui's child reads via a pure CSS attribute selector
|
|
354
|
+
// (grid.css: `& > [span="3"] { grid-column: span 3; }`) — it belongs to
|
|
355
|
+
// "whatever this grid child happens to be," never to a specific
|
|
356
|
+
// component's own prop schema, so no .yaml declares it and the
|
|
357
|
+
// catalog-driven pass below silently drops it (TKT-0002: collapsed the
|
|
358
|
+
// admin-dashboard Overview/Recent Sales 3:2 split to two equal, unspanned
|
|
359
|
+
// columns).
|
|
360
|
+
const spanAttr = attr('span');
|
|
361
|
+
if (spanAttr) props.span = spanAttr;
|
|
362
|
+
|
|
320
363
|
// §163 (v0.5.4) — Catalog-driven generic extraction FIRST. Fills in
|
|
321
364
|
// every prop the v0.9 catalog declares for this type (label, icon,
|
|
322
365
|
// variant=, etc.). Legacy per-type blocks below run AFTER and can
|
|
@@ -351,6 +394,15 @@ export function extractProps(el, a2uiType) {
|
|
|
351
394
|
}
|
|
352
395
|
}
|
|
353
396
|
|
|
397
|
+
// ── Tag ── (TKT-0004: tag-ui renders its label via CSS attr(text) —
|
|
398
|
+
// tag.yaml documents textContent as routing to the text attribute. The
|
|
399
|
+
// authored form is light-DOM content (<tag-ui size="sm">label</tag-ui>),
|
|
400
|
+
// so fall back to textContent when no text= attribute is set.)
|
|
401
|
+
if (a2uiType === 'Tag' && props.text === undefined) {
|
|
402
|
+
const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
403
|
+
if (text) props.text = text;
|
|
404
|
+
}
|
|
405
|
+
|
|
354
406
|
// ── Link ── (§47 — <a> tags map to Link, not Button; carries href, target, rel)
|
|
355
407
|
if (a2uiType === 'Link') {
|
|
356
408
|
const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
|
package/transpiler/transpiler.js
CHANGED
|
@@ -201,9 +201,13 @@ function walkNode(el, context) {
|
|
|
201
201
|
return id;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
// Leaf types: these render their own text, don't recurse into children
|
|
204
|
+
// Leaf types: these render their own text, don't recurse into children.
|
|
205
|
+
// Tag joined 2026-07-12 (TKT-0004): tag-ui renders its label via CSS
|
|
206
|
+
// attr(text) — as a container, its light-DOM label became a child Text
|
|
207
|
+
// at body size, inflating every converted chip site-wide (581 instances
|
|
208
|
+
// across 233 pages measured).
|
|
205
209
|
const LEAF_TYPES = new Set([
|
|
206
|
-
'Text', 'Button', 'Kbd', 'Code', 'Badge', 'Image', 'Divider',
|
|
210
|
+
'Text', 'Button', 'Kbd', 'Code', 'Badge', 'Tag', 'Image', 'Divider',
|
|
207
211
|
'TextField', 'CheckBox', 'Toggle', 'Radio', 'Slider', 'Search',
|
|
208
212
|
'Upload', 'ColorPicker', 'DateTimeInput', 'TextArea', 'OtpInput',
|
|
209
213
|
]);
|
|
@@ -244,7 +248,7 @@ function walkNode(el, context) {
|
|
|
244
248
|
if (directText) {
|
|
245
249
|
// Preserve the direct text on whichever prop the type uses
|
|
246
250
|
if (a2uiType === 'Text' || a2uiType === 'Kbd' || a2uiType === 'Code') containerProps.textContent = directText;
|
|
247
|
-
else if (a2uiType === 'Button' || a2uiType === 'Badge') {
|
|
251
|
+
else if (a2uiType === 'Button' || a2uiType === 'Badge' || a2uiType === 'Tag') {
|
|
248
252
|
if (containerProps.text === undefined) containerProps.text = directText;
|
|
249
253
|
}
|
|
250
254
|
} else {
|