@adia-ai/a2ui-mcp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * test-a2ui.mjs — Smoke test for the A2UI generation pipeline.
5
+ *
6
+ * Tests:
7
+ * 1. Env loading & LLM adapter detection
8
+ * 2. Pattern library health (search, count, domains)
9
+ * 3. Instant mode gate (strong/weak/rejected matching)
10
+ * 4. Instant mode generation (pattern-matched)
11
+ * 5. Thinking mode generation (LLM-powered, optional)
12
+ * 6. Training data ingestion
13
+ *
14
+ * Usage:
15
+ * node packages/a2ui/mcp/scripts/test-a2ui.mjs # run all (skip thinking if slow)
16
+ * node packages/a2ui/mcp/scripts/test-a2ui.mjs --thinking # include thinking mode (calls LLM API)
17
+ * node packages/a2ui/mcp/scripts/test-a2ui.mjs --verbose # show component details
18
+ */
19
+
20
+ import '../../../../scripts/load-env.mjs';
21
+
22
+ const args = new Set(process.argv.slice(2));
23
+ const THINKING = args.has('--thinking');
24
+ const VERBOSE = args.has('--verbose');
25
+
26
+ let pass = 0;
27
+ let fail = 0;
28
+ let skip = 0;
29
+
30
+ function ok(name, detail = '') {
31
+ pass++;
32
+ console.log(` ✓ ${name}${detail ? ` — ${detail}` : ''}`);
33
+ }
34
+ function bad(name, detail = '') {
35
+ fail++;
36
+ console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`);
37
+ }
38
+ function skipped(name, reason = '') {
39
+ skip++;
40
+ console.log(` ○ ${name}${reason ? ` — ${reason}` : ''}`);
41
+ }
42
+
43
+ // ── Test 1: Env & LLM adapter ──────────────────────────────────────
44
+
45
+ console.log('\n1. Environment & LLM adapter');
46
+
47
+ const hasAnthropicKey = !!(process.env.ANTHROPIC_API_KEY || process.env.VITE_ANTHROPIC_API_KEY);
48
+ const hasOpenAIKey = !!(process.env.OPENAI_API_KEY || process.env.VITE_OPENAI_API_KEY);
49
+ const hasGeminiKey = !!(process.env.GEMINI_API_KEY || process.env.VITE_GEMINI_API_KEY);
50
+
51
+ if (hasAnthropicKey || hasOpenAIKey || hasGeminiKey) {
52
+ ok('API keys loaded', [
53
+ hasAnthropicKey && 'anthropic',
54
+ hasOpenAIKey && 'openai',
55
+ hasGeminiKey && 'gemini',
56
+ ].filter(Boolean).join(', '));
57
+ } else {
58
+ bad('No API keys found', 'check .env file');
59
+ }
60
+
61
+ let adapterType = 'unknown';
62
+ try {
63
+ const { createAdapter } = await import('../../compose/llm/llm-bridge.js');
64
+ const adapter = await createAdapter();
65
+ adapterType = adapter.constructor.name;
66
+ if (adapterType === 'AdiaUILLMBridge') {
67
+ ok('LLM adapter', `${adapterType} (provider: ${adapter.provider})`);
68
+ } else {
69
+ bad('LLM adapter', `got ${adapterType} (expected AdiaUILLMBridge)`);
70
+ }
71
+ } catch (e) {
72
+ bad('LLM adapter', e.message);
73
+ }
74
+
75
+ // ── Test 2: Pattern library ─────────────────────────────────────────
76
+
77
+ console.log('\n2. Pattern library');
78
+
79
+ const { searchBlocks, listPatterns, lookupDomain } = await import('../../compose/engine/reference.js');
80
+
81
+ const allPatterns = listPatterns();
82
+ const withTemplates = allPatterns.filter(p => p.template && Array.isArray(p.template));
83
+ const domains = [...new Set(allPatterns.map(p => p.domain).filter(Boolean))];
84
+
85
+ if (allPatterns.length >= 70) {
86
+ ok('Pattern count', `${allPatterns.length} total (${withTemplates.length} with templates)`);
87
+ } else {
88
+ bad('Pattern count', `only ${allPatterns.length} (expected 70+)`);
89
+ }
90
+
91
+ if (domains.length >= 3) {
92
+ ok('Domains', domains.join(', '));
93
+ } else {
94
+ bad('Domains', `only ${domains.length}: ${domains.join(', ')}`);
95
+ }
96
+
97
+ // Spot-check known patterns
98
+ const spotChecks = ['login-form', 'dashboard', 'data-table-view', 'user-profile'];
99
+ const foundAll = spotChecks.every(name => allPatterns.some(p => p.name === name));
100
+ if (foundAll) {
101
+ ok('Known patterns', spotChecks.join(', '));
102
+ } else {
103
+ const missing = spotChecks.filter(name => !allPatterns.some(p => p.name === name));
104
+ bad('Known patterns', `missing: ${missing.join(', ')}`);
105
+ }
106
+
107
+ // ── Test 3: Instant mode gate ───────────────────────────────────────
108
+
109
+ console.log('\n3. Instant mode gate');
110
+
111
+ const GATE_STOPS = new Set(['the','and','with','for','from','that','this','its','are','all','can','has','each','show','using','based','into','like','make','your','type','just','only','also','more','most','some','very','much','many','will','about','been','when','they','them','what','would','could','should','different','simple','basic','custom','display','controls','group','selection','content','state']);
112
+
113
+ function testGate(intent) {
114
+ const patterns = searchBlocks(intent);
115
+ const best = patterns[0] || null;
116
+ if (!best) return { gate: 'NO_RESULTS', pattern: null };
117
+
118
+ const intentWords = intent.toLowerCase().split(/\s+/).filter(w => w.length > 2 && !GATE_STOPS.has(w));
119
+ const nameWords = best.name.toLowerCase().split(/[-_\s]+/);
120
+ const matchTags = (best.tags || []).map(t => t.toLowerCase());
121
+ const matchDomain = (best.domain || '').toLowerCase();
122
+
123
+ const hasStrongHit = intentWords.some(w => {
124
+ if (w.length < 3) return false;
125
+ if (nameWords.includes(w) || matchTags.includes(w)) return true;
126
+ if (w.length >= 4) {
127
+ return nameWords.some(n => n.length >= 3 && (w.startsWith(n) || n.startsWith(w))) ||
128
+ matchTags.some(t => t.length >= 3 && (w.startsWith(t) || t.startsWith(w)));
129
+ }
130
+ return false;
131
+ });
132
+
133
+ const hasWeakHit = !hasStrongHit && intentWords.some(w => {
134
+ return nameWords.some(n => n.length >= 3 && (n.includes(w) || w.includes(n))) ||
135
+ matchTags.some(t => t.length >= 3 && (t.includes(w) || w.includes(t))) ||
136
+ matchDomain.includes(w);
137
+ });
138
+
139
+ return { gate: hasStrongHit ? 'STRONG' : hasWeakHit ? 'WEAK' : 'REJECTED', pattern: best.name };
140
+ }
141
+
142
+ // Should STRONG match
143
+ const strongTests = [
144
+ ['login form', 'login-form'],
145
+ ['nav bar', null], // any match is fine
146
+ ['dashboard stats', null],
147
+ ['pricing table', null],
148
+ ['chat interface', null],
149
+ ];
150
+ for (const [intent, expected] of strongTests) {
151
+ const { gate, pattern } = testGate(intent);
152
+ if (gate === 'STRONG') {
153
+ ok(`"${intent}"`, `STRONG → ${pattern}`);
154
+ } else {
155
+ bad(`"${intent}"`, `expected STRONG, got ${gate} → ${pattern}`);
156
+ }
157
+ }
158
+
159
+ // Should NOT be REJECTED (STRONG or WEAK both acceptable)
160
+ const passTests = [
161
+ 'show me a table',
162
+ 'create a todo list',
163
+ 'user profile card',
164
+ 'settings page',
165
+ ];
166
+ for (const intent of passTests) {
167
+ const { gate, pattern } = testGate(intent);
168
+ if (gate !== 'REJECTED' && gate !== 'NO_RESULTS') {
169
+ ok(`"${intent}"`, `${gate} → ${pattern}`);
170
+ } else {
171
+ bad(`"${intent}"`, `expected pass, got ${gate}`);
172
+ }
173
+ }
174
+
175
+ // ── Test 4: Instant mode generation ─────────────────────────────────
176
+
177
+ console.log('\n4. Instant mode generation');
178
+
179
+ const { generateUI } = await import('../../compose/engine/generator.js');
180
+
181
+ const instantTests = [
182
+ { intent: 'login form', minComponents: 3 },
183
+ { intent: 'dashboard with stats', minComponents: 3 },
184
+ { intent: 'user settings page', minComponents: 3 },
185
+ ];
186
+
187
+ for (const { intent, minComponents } of instantTests) {
188
+ try {
189
+ const result = await generateUI({ intent, mode: 'instant' });
190
+ const comps = result.messages?.[0]?.components || [];
191
+ if (comps.length >= minComponents) {
192
+ ok(`"${intent}"`, `${comps.length} components`);
193
+ if (VERBOSE) {
194
+ console.log(` types: ${comps.slice(0, 6).map(c => c.component).join(', ')}${comps.length > 6 ? '...' : ''}`);
195
+ }
196
+ } else {
197
+ bad(`"${intent}"`, `only ${comps.length} components (expected ${minComponents}+)`);
198
+ }
199
+ } catch (e) {
200
+ bad(`"${intent}"`, e.message);
201
+ }
202
+ }
203
+
204
+ // ── Test 5: Thinking mode generation ────────────────────────────────
205
+
206
+ console.log('\n5. Thinking mode generation');
207
+
208
+ if (!THINKING) {
209
+ skipped('Thinking mode', 'pass --thinking to test (calls LLM API, ~10s per intent)');
210
+ } else if (adapterType !== 'AdiaUILLMBridge') {
211
+ skipped('Thinking mode', 'no real LLM adapter available');
212
+ } else {
213
+ const thinkingTests = [
214
+ { intent: 'a user settings page with profile and notifications tabs', minComponents: 10 },
215
+ { intent: 'an e-commerce product detail page with reviews', minComponents: 8 },
216
+ ];
217
+
218
+ for (const { intent, minComponents } of thinkingTests) {
219
+ try {
220
+ const start = Date.now();
221
+ const result = await generateUI({ intent, mode: 'thinking' });
222
+ const elapsed = ((Date.now() - start) / 1000).toFixed(1);
223
+ const comps = result.messages?.[0]?.components || [];
224
+ if (comps.length >= minComponents) {
225
+ ok(`"${intent.slice(0, 50)}..."`, `${comps.length} components in ${elapsed}s`);
226
+ if (VERBOSE) {
227
+ console.log(` types: ${comps.slice(0, 8).map(c => c.component).join(', ')}${comps.length > 8 ? '...' : ''}`);
228
+ console.log(` suggestions: ${(result.suggestions || []).join('; ')}`);
229
+ }
230
+ } else {
231
+ bad(`"${intent.slice(0, 50)}..."`, `only ${comps.length} components in ${elapsed}s`);
232
+ }
233
+ } catch (e) {
234
+ bad(`"${intent.slice(0, 50)}..."`, e.message);
235
+ }
236
+ }
237
+ }
238
+
239
+ // ── Test 6: Training data ingestion ─────────────────────────────────
240
+
241
+ console.log('\n6. Training data ingestion');
242
+
243
+ try {
244
+ const { ingestAll } = await import('../../corpus/scripts/ingest.js');
245
+ const result = await ingestAll();
246
+ if (result.registered >= 0 && result.pages > 0) {
247
+ ok('Ingestion', `${result.pages} pages → ${result.registered} new, ${result.replaced} replaced, ${result.skipped} skipped`);
248
+ } else {
249
+ bad('Ingestion', `unexpected result: ${JSON.stringify(result)}`);
250
+ }
251
+ } catch (e) {
252
+ bad('Ingestion', e.message);
253
+ }
254
+
255
+ // Final check: pattern count after ingestion
256
+ const afterPatterns = listPatterns();
257
+ if (afterPatterns.length >= 200) {
258
+ ok('Post-ingest count', `${afterPatterns.length} patterns`);
259
+ } else {
260
+ bad('Post-ingest count', `only ${afterPatterns.length} (expected 200+)`);
261
+ }
262
+
263
+ // ── Summary ─────────────────────────────────────────────────────────
264
+
265
+ console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
266
+ console.log(` ${pass} passed ${fail} failed ${skip} skipped`);
267
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
268
+
269
+ process.exit(fail > 0 ? 1 : 0);
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * test-evals.mjs — Quality evaluation suite for A2UI generation.
5
+ *
6
+ * Scores generated output on 5 dimensions:
7
+ * 1. structural_validity — schema validation score (0-100)
8
+ * 2. intent_alignment — required components present (F1 score)
9
+ * 3. component_coverage — uses right component types
10
+ * 4. card_model_compliance — header/section/footer structure
11
+ * 5. anti_pattern_count — inverse of anti-pattern violations
12
+ *
13
+ * Usage:
14
+ * node packages/a2ui/mcp/scripts/test-evals.mjs # run all evals (instant mode)
15
+ * node packages/a2ui/mcp/scripts/test-evals.mjs --mode=thinking # thinking mode (calls LLM)
16
+ * node packages/a2ui/mcp/scripts/test-evals.mjs --save-baseline # save current scores as baseline
17
+ * node packages/a2ui/mcp/scripts/test-evals.mjs --json # machine-readable output
18
+ * node packages/a2ui/mcp/scripts/test-evals.mjs --only=2 # run single eval by ID
19
+ */
20
+
21
+ import '../../../../scripts/load-env.mjs';
22
+ import { readFile, writeFile } from 'node:fs/promises';
23
+ import { dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const REPO_ROOT = join(__dirname, '..', '..', '..', '..');
28
+ const EVALS_PATH = join(REPO_ROOT, '.claude', 'skills', 'adia-ui-kit', 'evals', 'evals.json');
29
+ const BASELINE_PATH = join(REPO_ROOT, 'scripts', 'eval-baseline.json');
30
+
31
+ const args = new Set(process.argv.slice(2));
32
+ const MODE = [...args].find(a => a.startsWith('--mode='))?.split('=')[1] || 'instant';
33
+ const ONLY = [...args].find(a => a.startsWith('--only='))?.split('=')[1];
34
+ const SAVE_BASELINE = args.has('--save-baseline');
35
+ const JSON_OUT = args.has('--json');
36
+
37
+ // ── Load evals ──
38
+
39
+ const evalsData = JSON.parse(await readFile(EVALS_PATH, 'utf8'));
40
+ let evalCases = evalsData.evals;
41
+ if (ONLY) evalCases = evalCases.filter(e => String(e.id) === ONLY);
42
+
43
+ // ── Load generator ──
44
+
45
+ const { generateUI } = await import('../../compose/engine/generator.js');
46
+ const { validateSchema } = await import('../../validator/validator.js');
47
+
48
+ // ── Scoring functions ──
49
+
50
+ function scoreStructural(messages) {
51
+ try {
52
+ const result = validateSchema(messages);
53
+ return result.score ?? 0;
54
+ } catch { return 0; }
55
+ }
56
+
57
+ function scoreIntentAlignment(components, evalCase) {
58
+ const required = evalCase.required_components || [];
59
+ if (!required.length) return 100; // No requirements specified
60
+
61
+ const present = new Set(components.map(c => c.component));
62
+ const tp = required.filter(r => present.has(r)).length;
63
+ const precision = required.length ? tp / required.length : 1;
64
+ const recall = required.length ? tp / required.length : 1;
65
+ const f1 = precision + recall > 0 ? 2 * (precision * recall) / (precision + recall) : 0;
66
+ return Math.round(f1 * 100);
67
+ }
68
+
69
+ function scoreComponentCoverage(components, evalCase) {
70
+ const forbidden = evalCase.forbidden_patterns || [];
71
+ if (!forbidden.length) return 100;
72
+
73
+ const types = components.map(c => c.component);
74
+ const violations = forbidden.filter(f => types.includes(f)).length;
75
+ return Math.max(0, Math.round((1 - violations / forbidden.length) * 100));
76
+ }
77
+
78
+ function scoreCardModel(components) {
79
+ const cards = components.filter(c => c.component === 'Card');
80
+ if (!cards.length) return 100; // No cards to check
81
+
82
+ let compliant = 0;
83
+ for (const card of cards) {
84
+ const childIds = card.children || [];
85
+ const children = childIds.map(id => components.find(c => c.id === id)).filter(Boolean);
86
+ const types = children.map(c => c.component);
87
+ // Card should have Header and/or Section
88
+ const hasStructure = types.includes('Header') || types.includes('Section');
89
+ if (hasStructure) compliant++;
90
+ }
91
+ return Math.round((compliant / cards.length) * 100);
92
+ }
93
+
94
+ function scoreAntiPatterns(components) {
95
+ let violations = 0;
96
+
97
+ for (const c of components) {
98
+ // Text without variant
99
+ if (c.component === 'Text' && !c.variant) violations++;
100
+ // Header children without slot
101
+ const parent = components.find(p => p.children?.includes(c.id));
102
+ if (parent?.component === 'Header' && !c.slot && c.component === 'Text') violations++;
103
+ // Button without text
104
+ if (c.component === 'Button' && !c.text) violations++;
105
+ }
106
+
107
+ const maxViolations = Math.max(components.length * 0.3, 5);
108
+ return Math.max(0, Math.round((1 - violations / maxViolations) * 100));
109
+ }
110
+
111
+ // ── Run evals ──
112
+
113
+ const WEIGHTS = { structural: 0.30, intent: 0.25, coverage: 0.20, card_model: 0.15, anti_pattern: 0.10 };
114
+
115
+ const results = [];
116
+
117
+ for (const evalCase of evalCases) {
118
+ const start = Date.now();
119
+ let scores = { structural: 0, intent: 0, coverage: 0, card_model: 0, anti_pattern: 0 };
120
+ let error = null;
121
+
122
+ try {
123
+ const result = await generateUI({ intent: evalCase.prompt, mode: MODE });
124
+ const components = result.messages?.[0]?.components || [];
125
+
126
+ scores.structural = scoreStructural(result.messages);
127
+ scores.intent = scoreIntentAlignment(components, evalCase);
128
+ scores.coverage = scoreComponentCoverage(components, evalCase);
129
+ scores.card_model = scoreCardModel(components);
130
+ scores.anti_pattern = scoreAntiPatterns(components);
131
+ } catch (e) {
132
+ error = e.message;
133
+ }
134
+
135
+ const aggregate = Math.round(
136
+ scores.structural * WEIGHTS.structural +
137
+ scores.intent * WEIGHTS.intent +
138
+ scores.coverage * WEIGHTS.coverage +
139
+ scores.card_model * WEIGHTS.card_model +
140
+ scores.anti_pattern * WEIGHTS.anti_pattern
141
+ );
142
+
143
+ const thresholds = evalCase.thresholds || { aggregate: 50 };
144
+ const failures = [];
145
+ if (aggregate < (thresholds.aggregate || 50)) failures.push(`aggregate ${aggregate} < ${thresholds.aggregate || 50}`);
146
+ for (const [dim, threshold] of Object.entries(thresholds)) {
147
+ if (dim === 'aggregate') continue;
148
+ if (scores[dim] != null && scores[dim] < threshold) {
149
+ failures.push(`${dim} ${scores[dim]} < ${threshold}`);
150
+ }
151
+ }
152
+
153
+ results.push({
154
+ id: evalCase.id,
155
+ prompt: evalCase.prompt.slice(0, 60) + (evalCase.prompt.length > 60 ? '...' : ''),
156
+ scores,
157
+ aggregate,
158
+ pass: failures.length === 0 && !error,
159
+ failures,
160
+ error,
161
+ elapsed: Date.now() - start,
162
+ });
163
+ }
164
+
165
+ // ── Regression detection ──
166
+
167
+ let regressions = [];
168
+ try {
169
+ const baseline = JSON.parse(await readFile(BASELINE_PATH, 'utf8'));
170
+ for (const result of results) {
171
+ const base = baseline.scores?.[result.id];
172
+ if (!base) continue;
173
+ if (result.aggregate < base.aggregate - 3) {
174
+ regressions.push(`#${result.id}: aggregate ${result.aggregate} vs baseline ${base.aggregate}`);
175
+ }
176
+ for (const dim of Object.keys(WEIGHTS)) {
177
+ if (result.scores[dim] < (base[dim] || 0) - 5) {
178
+ regressions.push(`#${result.id}: ${dim} ${result.scores[dim]} vs baseline ${base[dim]}`);
179
+ }
180
+ }
181
+ }
182
+ } catch { /* no baseline file */ }
183
+
184
+ // ── Save baseline ──
185
+
186
+ if (SAVE_BASELINE) {
187
+ const baselineData = {
188
+ generated: new Date().toISOString(),
189
+ mode: MODE,
190
+ scores: {},
191
+ };
192
+ for (const r of results) {
193
+ baselineData.scores[r.id] = { aggregate: r.aggregate, ...r.scores };
194
+ }
195
+ await writeFile(BASELINE_PATH, JSON.stringify(baselineData, null, 2) + '\n');
196
+ if (!JSON_OUT) console.log(`Baseline saved to ${BASELINE_PATH}`);
197
+ }
198
+
199
+ // ── Output ──
200
+
201
+ const passed = results.filter(r => r.pass).length;
202
+ const avgAggregate = Math.round(results.reduce((s, r) => s + r.aggregate, 0) / (results.length || 1));
203
+
204
+ if (JSON_OUT) {
205
+ console.log(JSON.stringify({
206
+ timestamp: new Date().toISOString(),
207
+ mode: MODE,
208
+ results,
209
+ summary: { total: results.length, passed, avg_aggregate: avgAggregate, regressions },
210
+ }, null, 2));
211
+ } else {
212
+ console.log(`\nA2UI Eval Suite (mode: ${MODE})`);
213
+ console.log('━'.repeat(70));
214
+
215
+ for (const r of results) {
216
+ const status = r.error ? '✗' : r.pass ? '✓' : '~';
217
+ const dims = Object.entries(r.scores).map(([k, v]) => `${k.slice(0, 4)}:${v}`).join(' ');
218
+ console.log(` ${status} #${r.id} [${r.aggregate}] ${dims} ${r.elapsed}ms`);
219
+ console.log(` ${r.prompt}`);
220
+ if (r.failures.length) console.log(` FAIL: ${r.failures.join(', ')}`);
221
+ if (r.error) console.log(` ERROR: ${r.error}`);
222
+ }
223
+
224
+ if (regressions.length) {
225
+ console.log('\n⚠ REGRESSIONS:');
226
+ for (const r of regressions) console.log(` ${r}`);
227
+ }
228
+
229
+ console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
230
+ console.log(` ${passed}/${results.length} passed avg: ${avgAggregate} regressions: ${regressions.length}`);
231
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
232
+ }
233
+
234
+ // ── Exit code ──
235
+
236
+ if (regressions.length) process.exit(2);
237
+ if (passed < results.length) process.exit(1);
238
+ process.exit(0);
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * visual-validate.mjs — Generate UI from test intents and produce an HTML
5
+ * preview page for visual inspection. Works in both instant and pro modes.
6
+ *
7
+ * Usage:
8
+ * node packages/a2ui/mcp/scripts/visual-validate.mjs # instant mode
9
+ * node packages/a2ui/mcp/scripts/visual-validate.mjs --pro # pro mode (calls LLM)
10
+ * node packages/a2ui/mcp/scripts/visual-validate.mjs --open # open in browser after
11
+ */
12
+
13
+ import '../../../../scripts/load-env.mjs';
14
+ import { writeFile } from 'node:fs/promises';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { execSync } from 'node:child_process';
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const args = new Set(process.argv.slice(2));
21
+ const PRO = args.has('--pro');
22
+ const OPEN = args.has('--open');
23
+ const OUTPUT = join(__dirname, '..', 'visual-validation.html');
24
+
25
+ const { generateUI } = await import('../../compose/engine/generator.js');
26
+ const { validateSchema } = await import('../../validator/validator.js');
27
+
28
+ const TEST_INTENTS = [
29
+ 'login form with email and password',
30
+ 'dashboard with 4 KPI stat cards',
31
+ 'user profile card with avatar and bio',
32
+ 'pricing table with 3 tiers',
33
+ 'chat interface with message history',
34
+ 'data table with sorting and pagination',
35
+ 'file upload form with drag and drop',
36
+ 'notification toast stack',
37
+ 'settings page with toggle switches',
38
+ 'kanban board for project tasks',
39
+ 'team activity feed',
40
+ 'monitor server health dashboard',
41
+ ];
42
+
43
+ console.log(`\nVisual Validation (mode: ${PRO ? 'pro' : 'instant'})`);
44
+ console.log('━'.repeat(60));
45
+
46
+ const results = [];
47
+
48
+ for (const intent of TEST_INTENTS) {
49
+ const start = Date.now();
50
+ try {
51
+ const result = await generateUI({
52
+ intent,
53
+ mode: PRO ? 'pro' : undefined,
54
+ });
55
+ const elapsed = Date.now() - start;
56
+ const components = result.messages?.flatMap(m => m.components || []) || [];
57
+ const validation = validateSchema(result.messages || []);
58
+
59
+ results.push({
60
+ intent,
61
+ components,
62
+ messages: result.messages || [],
63
+ score: validation.score,
64
+ valid: validation.valid,
65
+ elapsed,
66
+ error: null,
67
+ });
68
+
69
+ const icon = validation.score >= 80 ? '✓' : validation.score >= 50 ? '△' : '✗';
70
+ console.log(` ${icon} [${validation.score}] ${components.length} comps ${elapsed}ms ${intent}`);
71
+ } catch (e) {
72
+ const elapsed = Date.now() - start;
73
+ results.push({ intent, components: [], messages: [], score: 0, valid: false, elapsed, error: e.message });
74
+ console.log(` ✗ ERROR ${elapsed}ms ${intent}: ${e.message}`);
75
+ }
76
+ }
77
+
78
+ // Generate preview HTML
79
+ const avgScore = Math.round(results.reduce((s, r) => s + r.score, 0) / results.length);
80
+ const totalComps = results.reduce((s, r) => s + r.components.length, 0);
81
+
82
+ const cards = results.map((r, i) => {
83
+ const componentsJson = JSON.stringify(r.messages, null, 2)
84
+ .replace(/</g, '&lt;')
85
+ .replace(/>/g, '&gt;');
86
+
87
+ const tree = r.components.map(c => {
88
+ const indent = ' '.repeat((c.children ? 0 : 1));
89
+ const props = Object.entries(c)
90
+ .filter(([k]) => !['id', 'component', 'children'].includes(k))
91
+ .map(([k, v]) => `${k}="${v}"`)
92
+ .join(' ');
93
+ return `<div style="margin-left: ${12}px; font-family: monospace; font-size: 12px; color: #ccc;">
94
+ <span style="color: #7dd3fc;">${c.component || '?'}</span>
95
+ <span style="color: #666;">#${c.id}</span>
96
+ ${props ? `<span style="color: #a78bfa; font-size: 11px;"> ${props.slice(0, 80)}${props.length > 80 ? '…' : ''}</span>` : ''}
97
+ </div>`;
98
+ }).join('');
99
+
100
+ const bg = r.score >= 80 ? '#1a2e1a' : r.score >= 50 ? '#2e2a1a' : '#2e1a1a';
101
+ const border = r.score >= 80 ? '#2d5a2d' : r.score >= 50 ? '#5a4a2d' : '#5a2d2d';
102
+
103
+ return `
104
+ <div style="background: ${bg}; border: 1px solid ${border}; border-radius: 8px; padding: 16px; margin-bottom: 12px;">
105
+ <div style="display: flex; justify-content: space-between; margin-bottom: 8px;">
106
+ <strong style="color: #e2e8f0; font-size: 14px;">${r.intent}</strong>
107
+ <span style="color: ${r.score >= 80 ? '#4ade80' : r.score >= 50 ? '#fbbf24' : '#f87171'}; font-weight: bold;">${r.score}/100</span>
108
+ </div>
109
+ <div style="display: flex; gap: 16px; font-size: 12px; color: #94a3b8; margin-bottom: 8px;">
110
+ <span>${r.components.length} components</span>
111
+ <span>${r.elapsed}ms</span>
112
+ <span>${[...new Set(r.components.map(c => c.component))].length} types</span>
113
+ </div>
114
+ <details>
115
+ <summary style="color: #7dd3fc; cursor: pointer; font-size: 12px;">Component tree</summary>
116
+ <div style="background: #0f172a; border-radius: 4px; padding: 8px; margin-top: 4px; max-height: 300px; overflow-y: auto;">
117
+ ${tree || '<em style="color:#666">No components</em>'}
118
+ </div>
119
+ </details>
120
+ <details style="margin-top: 4px;">
121
+ <summary style="color: #a78bfa; cursor: pointer; font-size: 12px;">Raw JSON</summary>
122
+ <pre style="background: #0f172a; border-radius: 4px; padding: 8px; margin-top: 4px; max-height: 300px; overflow-y: auto; font-size: 11px; color: #94a3b8;">${componentsJson}</pre>
123
+ </details>
124
+ </div>`;
125
+ }).join('');
126
+
127
+ const html = `<!DOCTYPE html>
128
+ <html lang="en">
129
+ <head>
130
+ <meta charset="UTF-8">
131
+ <title>A2UI Visual Validation — ${PRO ? 'Pro' : 'Instant'} Mode</title>
132
+ <style>
133
+ body { background: #0f172a; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 24px; }
134
+ h1 { font-size: 20px; margin-bottom: 4px; }
135
+ .stats { display: flex; gap: 24px; color: #94a3b8; font-size: 14px; margin-bottom: 24px; }
136
+ .stats span { background: #1e293b; padding: 4px 12px; border-radius: 4px; }
137
+ </style>
138
+ </head>
139
+ <body>
140
+ <h1>A2UI Visual Validation — ${PRO ? 'Pro' : 'Instant'} Mode</h1>
141
+ <div class="stats">
142
+ <span>${results.length} intents</span>
143
+ <span>avg score: ${avgScore}/100</span>
144
+ <span>${totalComps} total components</span>
145
+ <span>${results.filter(r => r.score >= 80).length}/${results.length} passing</span>
146
+ </div>
147
+ ${cards}
148
+ </body>
149
+ </html>`;
150
+
151
+ await writeFile(OUTPUT, html);
152
+ console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
153
+ console.log(` ${results.length} intents avg: ${avgScore} components: ${totalComps}`);
154
+ console.log(` Report: ${OUTPUT}`);
155
+
156
+ if (OPEN) {
157
+ try { execSync(`open "${OUTPUT}"`); } catch {}
158
+ }