eva4j 1.0.13 → 1.0.14

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.
Files changed (44) hide show
  1. package/AGENTS.md +51 -9
  2. package/DOMAIN_YAML_GUIDE.md +150 -0
  3. package/bin/eva4j.js +31 -1
  4. package/design-system.md +797 -0
  5. package/docs/commands/EVALUATE_SYSTEM.md +542 -0
  6. package/docs/commands/GENERATE_ENTITIES.md +196 -0
  7. package/docs/commands/INDEX.md +10 -1
  8. package/examples/domain-endpoints-relations.yaml +353 -0
  9. package/examples/domain-endpoints-versioned.yaml +144 -0
  10. package/examples/domain-endpoints.yaml +135 -0
  11. package/examples/system.yaml +289 -0
  12. package/package.json +1 -1
  13. package/src/commands/create.js +6 -3
  14. package/src/commands/evaluate-system.js +384 -0
  15. package/src/commands/generate-entities.js +677 -14
  16. package/src/commands/generate-kafka-event.js +59 -5
  17. package/src/commands/generate-system.js +243 -0
  18. package/src/generators/base-generator.js +9 -1
  19. package/src/utils/naming.js +3 -2
  20. package/src/utils/system-validator.js +314 -0
  21. package/src/utils/yaml-to-entity.js +31 -2
  22. package/templates/aggregate/AggregateRepository.java.ejs +5 -0
  23. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +9 -0
  24. package/templates/aggregate/DomainEventHandler.java.ejs +24 -20
  25. package/templates/aggregate/JpaRepository.java.ejs +5 -0
  26. package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1103 -0
  27. package/templates/base/root/skill-build-domain-yaml.ejs +292 -0
  28. package/templates/base/root/skill-build-system-yaml.ejs +252 -0
  29. package/templates/base/root/system.yaml.ejs +97 -0
  30. package/templates/crud/EndpointsController.java.ejs +178 -0
  31. package/templates/crud/FindByQuery.java.ejs +17 -0
  32. package/templates/crud/FindByQueryHandler.java.ejs +57 -0
  33. package/templates/crud/ScaffoldCommand.java.ejs +12 -0
  34. package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
  35. package/templates/crud/ScaffoldQuery.java.ejs +12 -0
  36. package/templates/crud/ScaffoldQueryHandler.java.ejs +40 -0
  37. package/templates/crud/SubEntityAddCommand.java.ejs +17 -0
  38. package/templates/crud/SubEntityAddCommandHandler.java.ejs +43 -0
  39. package/templates/crud/SubEntityRemoveCommand.java.ejs +9 -0
  40. package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +42 -0
  41. package/templates/crud/TransitionCommand.java.ejs +9 -0
  42. package/templates/crud/TransitionCommandHandler.java.ejs +39 -0
  43. package/templates/evaluate/report.html.ejs +971 -0
  44. package/templates/kafka-event/Event.java.ejs +7 -0
@@ -0,0 +1,971 @@
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title><%= data.systemName %> — eva4j Architecture Validator</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet" />
10
+ <script src="https://unpkg.com/react@18/umd/react.development.js" crossorigin></script>
11
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js" crossorigin></script>
12
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
13
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
14
+ <style>
15
+ * { box-sizing: border-box; margin: 0; padding: 0; }
16
+ body { background: #0a0a0f; color: #e8e8f0; font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; }
17
+ ::-webkit-scrollbar { width: 6px; }
18
+ ::-webkit-scrollbar-track { background: #0a0a0f; }
19
+ ::-webkit-scrollbar-thumb { background: #2e2e50; border-radius: 3px; }
20
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
21
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
22
+ @keyframes shimmer { 0% { box-shadow: 0 0 0 0 rgba(230,57,80,0.4); } 70% { box-shadow: 0 0 0 8px rgba(230,57,80,0); } 100% { box-shadow: 0 0 0 0 rgba(230,57,80,0); } }
23
+ </style>
24
+ </head>
25
+ <body>
26
+ <div id="root"></div>
27
+
28
+ <script>
29
+ (function () {
30
+ var _d = '<%- Buffer.from(JSON.stringify(data)).toString("base64") %>';
31
+ var bytes = Uint8Array.from(atob(_d), function(c) { return c.charCodeAt(0); });
32
+ window.__EVA_DATA__ = JSON.parse(new TextDecoder('utf-8').decode(bytes));
33
+ })();
34
+ </script>
35
+
36
+ <script type="text/babel">
37
+ const { useState, useRef, useEffect } = React;
38
+
39
+ // ─── Injected data ──────────────────────────────────────────────────────
40
+ const {
41
+ systemName,
42
+ modules: MODULES_LIST,
43
+ events: EVENTS,
44
+ syncIntegrations: SYNC_INTEGRATIONS,
45
+ endpoints: ENDPOINTS,
46
+ flows: FLOWS_LIST,
47
+ validation: VALIDATION,
48
+ generatedAt,
49
+ } = window.__EVA_DATA__;
50
+
51
+ // Convert arrays to maps for fast lookup
52
+ const MODULES = Object.fromEntries(MODULES_LIST.map(m => [m.id, m]));
53
+ const FLOWS = Object.fromEntries(FLOWS_LIST.map(f => [f.id, f]));
54
+
55
+ // ─── Design system ──────────────────────────────────────────────────────
56
+ const C = {
57
+ bg: "#0a0a0f",
58
+ surface: "#12121a",
59
+ surfaceHover: "#1a1a28",
60
+ border: "#1e1e30",
61
+ borderBright: "#2e2e50",
62
+ accent: "#e63950",
63
+ gold: "#f5c842",
64
+ green: "#2dcc8f",
65
+ blue: "#4a9eff",
66
+ purple: "#9b6dff",
67
+ orange: "#ff8c42",
68
+ text: "#e8e8f0",
69
+ textMuted: "#8c8caa",
70
+ textDim: "#b4b4cc",
71
+ };
72
+
73
+ // ─── Primitive components ───────────────────────────────────────────────
74
+ const Tag = ({ color, children }) => (
75
+ <span style={{
76
+ background: color + "22", color, border: `1px solid ${color}44`,
77
+ borderRadius: 4, padding: "1px 8px", fontSize: 11, fontWeight: 600,
78
+ fontFamily: "'JetBrains Mono', 'Courier New', monospace", letterSpacing: 0.3, display: "inline-block",
79
+ }}>
80
+ {children}
81
+ </span>
82
+ );
83
+
84
+ const Badge = ({ color, children }) => (
85
+ <span style={{
86
+ background: color + "33", color, borderRadius: 20,
87
+ padding: "2px 10px", fontSize: 11, fontWeight: 700,
88
+ }}>
89
+ {children}
90
+ </span>
91
+ );
92
+
93
+ // ─── ModuleCard ─────────────────────────────────────────────────────────
94
+ function ModuleCard({ id, selected, onClick }) {
95
+ const mod = MODULES[id];
96
+ if (!mod) return null;
97
+ return (
98
+ <div onClick={onClick} style={{
99
+ background: selected ? mod.color + "22" : C.surface,
100
+ border: `1px solid ${selected ? mod.color : C.border}`,
101
+ borderRadius: 10, padding: "10px 14px", cursor: "pointer",
102
+ transition: "all 0.18s", display: "flex", alignItems: "center", gap: 10,
103
+ boxShadow: selected ? `0 0 14px ${mod.color}33` : "none",
104
+ }}>
105
+ <span style={{ fontSize: 20 }}>{mod.icon}</span>
106
+ <div>
107
+ <div style={{ fontWeight: 700, color: selected ? mod.color : C.text, fontSize: 13 }}>{mod.label}</div>
108
+ <div style={{ color: C.textMuted, fontSize: 10, marginTop: 1 }}>{mod.desc}</div>
109
+ </div>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ // ─── ValidationTab ──────────────────────────────────────────────────────
115
+ function ValidationTab() {
116
+ const [expanded, setExpanded] = useState({ errors: true, warnings: true, ok: false });
117
+
118
+ const score = VALIDATION.score;
119
+ const scoreColor = score > 80 ? C.green : score > 60 ? C.gold : C.accent;
120
+
121
+ function Section({ title, items, color, icon, sectionKey }) {
122
+ const isOpen = expanded[sectionKey];
123
+ return (
124
+ <div style={{ marginBottom: 16 }}>
125
+ <div
126
+ onClick={() => setExpanded(e => ({ ...e, [sectionKey]: !e[sectionKey] }))}
127
+ style={{
128
+ display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
129
+ padding: "10px 16px", background: C.surface, border: `1px solid ${C.border}`,
130
+ borderRadius: isOpen ? "8px 8px 0 0" : 8, userSelect: "none",
131
+ }}
132
+ >
133
+ <span style={{ fontSize: 18 }}>{icon}</span>
134
+ <span style={{ fontWeight: 700, color, flex: 1 }}>{title}</span>
135
+ <Badge color={color}>{items.length}</Badge>
136
+ <span style={{ color: C.textMuted, fontSize: 12 }}>{isOpen ? "▲" : "▼"}</span>
137
+ </div>
138
+ {isOpen && items.length > 0 && (
139
+ <div style={{ border: `1px solid ${C.border}`, borderTop: "none", borderRadius: "0 0 8px 8px", overflow: "hidden" }}>
140
+ {items.map((item, i) => (
141
+ <div key={i} style={{
142
+ padding: "10px 16px",
143
+ borderBottom: i < items.length - 1 ? `1px solid ${C.border}` : "none",
144
+ display: "flex", alignItems: "flex-start", gap: 10,
145
+ background: i % 2 === 0 ? C.bg : C.surface,
146
+ }}>
147
+ <span style={{ color, marginTop: 1, flexShrink: 0 }}>•</span>
148
+ <span style={{ color: C.textDim, fontSize: 13, lineHeight: 1.6 }}>
149
+ {typeof item === "string" ? item : item.msg}
150
+ </span>
151
+ </div>
152
+ ))}
153
+ </div>
154
+ )}
155
+ {isOpen && items.length === 0 && (
156
+ <div style={{ border: `1px solid ${C.border}`, borderTop: "none", borderRadius: "0 0 8px 8px", padding: "12px 16px", background: C.bg }}>
157
+ <span style={{ color: C.textMuted, fontSize: 12 }}>— ninguno —</span>
158
+ </div>
159
+ )}
160
+ </div>
161
+ );
162
+ }
163
+
164
+ return (
165
+ <div>
166
+ {/* Score cards */}
167
+ <div style={{ display: "flex", gap: 16, marginBottom: 24, flexWrap: "wrap" }}>
168
+ <div style={{
169
+ flex: 1, minWidth: 160, background: C.surface, border: `1px solid ${scoreColor}44`,
170
+ borderRadius: 12, padding: 20, textAlign: "center",
171
+ boxShadow: `0 0 24px ${scoreColor}22`,
172
+ }}>
173
+ <div style={{ fontSize: 52, fontWeight: 900, color: scoreColor, fontFamily: "monospace", lineHeight: 1 }}>
174
+ {score}%
175
+ </div>
176
+ <div style={{ color: C.textMuted, fontSize: 11, marginTop: 6, letterSpacing: 1 }}>SCORE DE CALIDAD</div>
177
+ </div>
178
+ {[
179
+ { label: "Errores", count: VALIDATION.errors.length, color: C.accent, icon: "🔴" },
180
+ { label: "Advertencias", count: VALIDATION.warnings.length, color: C.gold, icon: "🟡" },
181
+ { label: "Validados", count: VALIDATION.ok.length, color: C.green, icon: "🟢" },
182
+ ].map(s => (
183
+ <div key={s.label} style={{
184
+ flex: 1, minWidth: 130, background: C.surface, border: `1px solid ${C.border}`,
185
+ borderRadius: 12, padding: 20, textAlign: "center",
186
+ }}>
187
+ <div style={{ fontSize: 38, fontWeight: 900, color: s.color, fontFamily: "monospace" }}>{s.count}</div>
188
+ <div style={{ color: C.textMuted, fontSize: 11, marginTop: 6, letterSpacing: 0.5 }}>
189
+ {s.icon} {s.label.toUpperCase()}
190
+ </div>
191
+ </div>
192
+ ))}
193
+ </div>
194
+
195
+ <Section title="Errores críticos" items={VALIDATION.errors} color={C.accent} icon="🔴" sectionKey="errors" />
196
+ <Section title="Advertencias" items={VALIDATION.warnings} color={C.gold} icon="🟡" sectionKey="warnings" />
197
+ <Section title="Validaciones pasadas" items={VALIDATION.ok} color={C.green} icon="🟢" sectionKey="ok" />
198
+ </div>
199
+ );
200
+ }
201
+
202
+ // ─── FlowSimulator ──────────────────────────────────────────────────────
203
+ function FlowSimulator() {
204
+ const defaultFlow = FLOWS_LIST[0]?.id || null;
205
+ const [selectedFlowId, setSelectedFlowId] = useState(defaultFlow);
206
+ const [currentStep, setCurrentStep] = useState(-1);
207
+ const [running, setRunning] = useState(false);
208
+ const [completed, setCompleted] = useState(false);
209
+ const intervalRef = useRef(null);
210
+
211
+ const flow = FLOWS[selectedFlowId] || FLOWS_LIST[0];
212
+
213
+ if (!flow) {
214
+ return (
215
+ <div style={{ color: C.textMuted, padding: 40, textAlign: "center" }}>
216
+ No hay flujos de eventos declarados en integrations.async
217
+ </div>
218
+ );
219
+ }
220
+
221
+ const reset = () => {
222
+ clearInterval(intervalRef.current);
223
+ setCurrentStep(-1);
224
+ setRunning(false);
225
+ setCompleted(false);
226
+ };
227
+
228
+ const selectFlow = (id) => {
229
+ reset();
230
+ setSelectedFlowId(id);
231
+ };
232
+
233
+ const run = () => {
234
+ if (running) return;
235
+ reset();
236
+ setRunning(true);
237
+ let step = 0;
238
+ setCurrentStep(0);
239
+ intervalRef.current = setInterval(() => {
240
+ step++;
241
+ if (step >= flow.steps.length) {
242
+ clearInterval(intervalRef.current);
243
+ setRunning(false);
244
+ setCompleted(true);
245
+ setCurrentStep(flow.steps.length);
246
+ } else {
247
+ setCurrentStep(step);
248
+ }
249
+ }, 1100);
250
+ };
251
+
252
+ useEffect(() => () => clearInterval(intervalRef.current), []);
253
+
254
+ const getStepIcon = (step) => {
255
+ if (step.type === "event") return "⚡";
256
+ if (step.type === "external") return "🌐";
257
+ if (step.type === "action") return "📤";
258
+ return "→";
259
+ };
260
+
261
+ const getStepColor = (step) => {
262
+ if (step.type === "event") return C.gold;
263
+ if (step.type === "external") return C.purple;
264
+ if (step.type === "action") return C.green;
265
+ return C.blue;
266
+ };
267
+
268
+ const getActorColor = (actor) => {
269
+ if (MODULES[actor]) return MODULES[actor].color;
270
+ if (actor === "client" || actor === "organizer") return C.green;
271
+ if (actor === "admin") return C.purple;
272
+ if (actor === "gateway") return C.textDim;
273
+ return C.textMuted;
274
+ };
275
+
276
+ return (
277
+ <div>
278
+ {/* Flow selector tabs */}
279
+ <div style={{ display: "flex", gap: 8, marginBottom: 20, flexWrap: "wrap" }}>
280
+ {FLOWS_LIST.map(f => (
281
+ <button key={f.id} onClick={() => selectFlow(f.id)} style={{
282
+ background: selectedFlowId === f.id ? f.color + "22" : C.surface,
283
+ border: `1px solid ${selectedFlowId === f.id ? f.color : C.border}`,
284
+ color: selectedFlowId === f.id ? f.color : C.textMuted,
285
+ borderRadius: 8, padding: "7px 12px", cursor: "pointer",
286
+ fontWeight: selectedFlowId === f.id ? 700 : 400, fontSize: 12,
287
+ transition: "all 0.15s", display: "flex", alignItems: "center", gap: 6,
288
+ boxShadow: selectedFlowId === f.id ? `0 0 12px ${f.color}33` : "none",
289
+ fontFamily: "inherit",
290
+ }}>
291
+ <span>{f.icon}</span>{f.label}
292
+ </button>
293
+ ))}
294
+ </div>
295
+
296
+ {/* Flow header */}
297
+ <div style={{
298
+ background: C.surface, border: `1px solid ${flow.color}44`, borderRadius: 10,
299
+ padding: 16, marginBottom: 20, display: "flex", alignItems: "center", gap: 16,
300
+ }}>
301
+ <span style={{ fontSize: 32 }}>{flow.icon}</span>
302
+ <div style={{ flex: 1 }}>
303
+ <div style={{ fontWeight: 700, color: flow.color, fontSize: 16 }}>{flow.label}</div>
304
+ <div style={{ color: C.textDim, fontSize: 12, marginTop: 3 }}>{flow.description}</div>
305
+ </div>
306
+ <div style={{ display: "flex", gap: 8 }}>
307
+ <button onClick={run} disabled={running} style={{
308
+ background: running ? C.border : flow.color, color: "#fff",
309
+ border: "none", borderRadius: 8, padding: "10px 20px",
310
+ cursor: running ? "not-allowed" : "pointer", fontWeight: 700, fontSize: 14,
311
+ transition: "all 0.15s", opacity: running ? 0.6 : 1, fontFamily: "inherit",
312
+ }}>
313
+ {running ? "⏳ Ejecutando..." : completed ? "▶ Re-ejecutar" : "▶ Simular"}
314
+ </button>
315
+ <button onClick={reset} style={{
316
+ background: "transparent", color: C.textMuted,
317
+ border: `1px solid ${C.border}`, borderRadius: 8,
318
+ padding: "10px 14px", cursor: "pointer", fontFamily: "inherit",
319
+ }}>⟳</button>
320
+ </div>
321
+ </div>
322
+
323
+ {/* Steps timeline */}
324
+ <div style={{ position: "relative" }}>
325
+ {flow.steps.map((step, i) => {
326
+ const isActive = currentStep === i;
327
+ const isDone = currentStep > i;
328
+ const stepColor = getStepColor(step);
329
+
330
+ return (
331
+ <div key={step.id} style={{ display: "flex", gap: 16, marginBottom: 8, alignItems: "flex-start" }}>
332
+ {/* Timeline dot */}
333
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 36, flexShrink: 0 }}>
334
+ <div style={{
335
+ width: 36, height: 36, borderRadius: "50%",
336
+ display: "flex", alignItems: "center", justifyContent: "center",
337
+ background: isDone ? C.green + "22" : isActive ? stepColor + "33" : C.surface,
338
+ border: `2px solid ${isDone ? C.green : isActive ? stepColor : C.border}`,
339
+ color: isDone ? C.green : isActive ? stepColor : C.textMuted,
340
+ fontWeight: 700, fontSize: 13, transition: "all 0.3s",
341
+ boxShadow: isActive ? `0 0 16px ${stepColor}44` : "none",
342
+ }}>
343
+ {isDone ? "✓" : step.id}
344
+ </div>
345
+ {i < flow.steps.length - 1 && (
346
+ <div style={{
347
+ width: 2, flex: 1, minHeight: 24,
348
+ background: isDone ? C.green + "44" : C.border,
349
+ marginTop: 4,
350
+ }} />
351
+ )}
352
+ </div>
353
+
354
+ {/* Step card */}
355
+ <div style={{
356
+ flex: 1,
357
+ background: isActive ? stepColor + "11" : isDone ? C.green + "08" : C.surface,
358
+ border: `1px solid ${isActive ? stepColor + "66" : isDone ? C.green + "33" : C.border}`,
359
+ borderRadius: 10, padding: "12px 16px", marginBottom: 4,
360
+ transition: "all 0.3s",
361
+ opacity: currentStep === -1 ? 0.55 : isDone || isActive ? 1 : 0.4,
362
+ }}>
363
+ {/* Step header */}
364
+ <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
365
+ <span style={{ fontSize: 16 }}>{getStepIcon(step)}</span>
366
+ {step.from && (
367
+ <>
368
+ <Tag color={getActorColor(step.from)}>{step.from}</Tag>
369
+ <span style={{ color: C.textMuted, fontSize: 12 }}>→</span>
370
+ </>
371
+ )}
372
+ {step.to && !Array.isArray(step.to) && (
373
+ <Tag color={getActorColor(step.to)}>{step.to}</Tag>
374
+ )}
375
+ {Array.isArray(step.to) && step.to.map(t => (
376
+ <Tag key={t} color={getActorColor(t)}>{t}</Tag>
377
+ ))}
378
+ {step.label && (
379
+ <code style={{
380
+ background: C.bg, color: stepColor,
381
+ padding: "2px 8px", borderRadius: 4, fontSize: 12,
382
+ border: `1px solid ${C.border}`,
383
+ }}>
384
+ {step.label}
385
+ </code>
386
+ )}
387
+ {step.event && <Tag color={C.gold}>⚡ {step.event}</Tag>}
388
+ {step.topic && <Tag color={C.purple}>kafka:{step.topic}</Tag>}
389
+ </div>
390
+
391
+ <div style={{ color: C.textDim, fontSize: 12, marginTop: 6, lineHeight: 1.6 }}>
392
+ {step.desc}
393
+ </div>
394
+
395
+ {/* Sync sub-calls (shown when step is active) */}
396
+ {step.syncCalls && step.syncCalls.length > 0 && isActive && (
397
+ <div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px dashed ${C.border}` }}>
398
+ <div style={{ color: C.textMuted, fontSize: 10, marginBottom: 6, fontWeight: 700, letterSpacing: 1 }}>
399
+ LLAMADAS SÍNCRONAS:
400
+ </div>
401
+ {step.syncCalls.map((sc, j) => (
402
+ <div key={j} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
403
+ <span style={{ color: C.blue, fontSize: 11 }}>⟶</span>
404
+ <Tag color={getActorColor(sc.to)}>{sc.to}</Tag>
405
+ <code style={{
406
+ background: C.bg, color: C.blue,
407
+ padding: "1px 6px", borderRadius: 3, fontSize: 11,
408
+ }}>{sc.label}</code>
409
+ <span style={{ color: C.textMuted, fontSize: 10 }}>via {sc.port}</span>
410
+ </div>
411
+ ))}
412
+ </div>
413
+ )}
414
+ </div>
415
+ </div>
416
+ );
417
+ })}
418
+ </div>
419
+
420
+ {completed && (
421
+ <div style={{
422
+ background: C.green + "15", border: `1px solid ${C.green}44`,
423
+ borderRadius: 10, padding: 16, textAlign: "center", marginTop: 8,
424
+ animation: "fadeIn 0.3s",
425
+ }}>
426
+ <span style={{ fontSize: 24 }}>✅</span>
427
+ <div style={{ color: C.green, fontWeight: 700, marginTop: 4 }}>Flujo completado exitosamente</div>
428
+ <div style={{ color: C.textMuted, fontSize: 12, marginTop: 2 }}>
429
+ {flow.steps.length} pasos · {flow.label}
430
+ </div>
431
+ </div>
432
+ )}
433
+ </div>
434
+ );
435
+ }
436
+
437
+ // ─── ArchitectureTab ────────────────────────────────────────────────────
438
+ function ArchitectureTab() {
439
+ const [selected, setSelected] = useState(null);
440
+
441
+ const moduleInfo = (id) => ({
442
+ produces: EVENTS.filter(e => e.producer === id),
443
+ consumes: EVENTS.filter(e => (e.consumers || []).includes(id)),
444
+ callsSync: SYNC_INTEGRATIONS.filter(s => s.caller === id),
445
+ calledBy: SYNC_INTEGRATIONS.filter(s => s.calls === id),
446
+ endpoints: ENDPOINTS[id] || [],
447
+ });
448
+
449
+ return (
450
+ <div>
451
+ {/* Module grid */}
452
+ <div style={{ marginBottom: 20 }}>
453
+ <div style={{ color: C.textMuted, fontSize: 11, marginBottom: 10, fontWeight: 700, letterSpacing: 1 }}>
454
+ MÓDULOS — clic para explorar dependencias
455
+ </div>
456
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 10 }}>
457
+ {MODULES_LIST.map(m => (
458
+ <ModuleCard
459
+ key={m.id}
460
+ id={m.id}
461
+ selected={selected === m.id}
462
+ onClick={() => setSelected(selected === m.id ? null : m.id)}
463
+ />
464
+ ))}
465
+ </div>
466
+ </div>
467
+
468
+ {/* Selected module detail */}
469
+ {selected && (() => {
470
+ const mod = MODULES[selected];
471
+ const info = moduleInfo(selected);
472
+ if (!mod) return null;
473
+ return (
474
+ <div style={{
475
+ background: C.surface, border: `1px solid ${mod.color}44`,
476
+ borderRadius: 12, padding: 20, marginBottom: 20,
477
+ animation: "fadeIn 0.2s",
478
+ }}>
479
+ <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16 }}>
480
+ <span style={{ fontSize: 28 }}>{mod.icon}</span>
481
+ <div>
482
+ <div style={{ fontWeight: 800, color: mod.color, fontSize: 18 }}>{mod.label}</div>
483
+ <div style={{ color: C.textMuted, fontSize: 12 }}>{mod.desc}</div>
484
+ </div>
485
+ </div>
486
+
487
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))", gap: 16 }}>
488
+ {[
489
+ { title: "Produce eventos", items: info.produces, color: C.gold, icon: "📤", render: e => e.event },
490
+ { title: "Consume eventos", items: info.consumes, color: C.blue, icon: "📥", render: e => e.event },
491
+ { title: "Llama síncronamente", items: info.callsSync, color: C.purple, icon: "⟶", render: s => `→ ${s.calls} (${s.port})` },
492
+ { title: "Es llamado por", items: info.calledBy, color: C.green, icon: "⟵", render: s => `← ${s.caller} (${s.port})` },
493
+ { title: "Endpoints expuestos", items: info.endpoints, color: mod.color, icon: "🔌", render: e => e },
494
+ ].filter(sec => sec.items.length > 0).map(section => (
495
+ <div key={section.title}>
496
+ <div style={{ color: section.color, fontSize: 10, fontWeight: 700, marginBottom: 8, letterSpacing: 0.5 }}>
497
+ {section.icon} {section.title.toUpperCase()}
498
+ </div>
499
+ {section.items.map((item, i) => (
500
+ <div key={i} style={{
501
+ color: C.textDim, fontSize: 12, padding: "5px 0",
502
+ borderBottom: `1px solid ${C.border}`, fontFamily: "monospace",
503
+ }}>
504
+ {section.render(item)}
505
+ </div>
506
+ ))}
507
+ </div>
508
+ ))}
509
+ </div>
510
+ </div>
511
+ );
512
+ })()}
513
+
514
+ {/* Sync integrations graph */}
515
+ {SYNC_INTEGRATIONS.length > 0 && (
516
+ <div style={{ marginBottom: 24 }}>
517
+ <div style={{ color: C.textMuted, fontSize: 11, marginBottom: 10, fontWeight: 700, letterSpacing: 1 }}>
518
+ DEPENDENCIAS SÍNCRONAS ({SYNC_INTEGRATIONS.length} puertos)
519
+ </div>
520
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))", gap: 10 }}>
521
+ {SYNC_INTEGRATIONS.map((s, i) => {
522
+ const callerMod = MODULES[s.caller];
523
+ const calleeMod = MODULES[s.calls];
524
+ return (
525
+ <div key={i} style={{
526
+ background: C.surface, border: `1px solid ${C.border}`,
527
+ borderRadius: 10, padding: "12px 16px",
528
+ }}>
529
+ <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 6, marginBottom: 8 }}>
530
+ <Tag color={callerMod?.color || C.textMuted}>{s.caller}</Tag>
531
+ <span style={{ color: C.textMuted, fontSize: 14, fontWeight: 700 }}>→</span>
532
+ <Tag color={calleeMod?.color || C.textMuted}>{s.calls}</Tag>
533
+ <Tag color={C.purple}>{s.port}</Tag>
534
+ </div>
535
+ <div style={{ marginLeft: 4 }}>
536
+ {(s.endpoints || []).map((ep, j) => (
537
+ <div key={j} style={{ color: C.textDim, fontSize: 11, fontFamily: "monospace", padding: "2px 0" }}>
538
+ · {ep}
539
+ </div>
540
+ ))}
541
+ </div>
542
+ </div>
543
+ );
544
+ })}
545
+ </div>
546
+ </div>
547
+ )}
548
+
549
+ {/* Kafka topics */}
550
+ <div>
551
+ <div style={{ color: C.textMuted, fontSize: 11, marginBottom: 10, fontWeight: 700, letterSpacing: 1 }}>
552
+ KAFKA TOPICS ({EVENTS.length} eventos)
553
+ </div>
554
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 8 }}>
555
+ {EVENTS.map(e => (
556
+ <div key={e.event} style={{
557
+ background: C.surface, border: `1px solid ${C.border}`,
558
+ borderRadius: 8, padding: "10px 14px",
559
+ }}>
560
+ <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 6 }}>
561
+ <Tag color={C.gold}>{e.topic}</Tag>
562
+ </div>
563
+ <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
564
+ <Tag color={MODULES[e.producer]?.color || C.textMuted}>{e.producer}</Tag>
565
+ <span style={{ color: C.textMuted, fontSize: 11 }}>→</span>
566
+ {(e.consumers || []).map(c => (
567
+ <Tag key={c} color={MODULES[c]?.color || C.textMuted}>{c}</Tag>
568
+ ))}
569
+ </div>
570
+ </div>
571
+ ))}
572
+ </div>
573
+ </div>
574
+ </div>
575
+ );
576
+ }
577
+
578
+ // ─── DiagramTab ──────────────────────────────────────────────────────────
579
+ function DiagramTab() {
580
+ const containerRef = useRef(null);
581
+ const networkRef = useRef(null);
582
+ const edgesDataRef = useRef(null);
583
+ const edgeGroupMapRef = useRef({});
584
+ const eventEdgesMapRef = useRef({});
585
+ const edgeLabelMapRef = useRef({});
586
+ const [physicsOn, setPhysicsOn] = useState(true);
587
+ const [hoveredNode, setHoveredNode] = useState(null);
588
+ const [hoveredEvent, setHoveredEvent] = useState(null); // { name, producer, consumers[] }
589
+
590
+ // Build vis datasets from injected data
591
+ function buildDatasets() {
592
+ const visNodes = MODULES_LIST.map(mod => ({
593
+ id: mod.id,
594
+ label: mod.icon + "\n" + mod.label,
595
+ title: mod.desc,
596
+ color: {
597
+ background: mod.color + "22",
598
+ border: mod.color,
599
+ highlight: { background: mod.color + "44", border: mod.color },
600
+ hover: { background: mod.color + "33", border: mod.color },
601
+ },
602
+ font: { color: C.text, size: 13, face: "'Plus Jakarta Sans', sans-serif", multi: false },
603
+ shape: "box",
604
+ borderWidth: 2,
605
+ borderWidthSelected: 3,
606
+ margin: 10,
607
+ }));
608
+
609
+ const visEdges = [];
610
+
611
+ // Sync edges — solid blue
612
+ SYNC_INTEGRATIONS.forEach((s, i) => {
613
+ visEdges.push({
614
+ id: "sync-" + i,
615
+ from: s.caller,
616
+ to: s.calls,
617
+ label: s.port,
618
+ dashes: false,
619
+ color: { color: C.blue, highlight: C.blue, hover: C.blue },
620
+ font: { color: C.blue, size: 10, face: "'JetBrains Mono', monospace",
621
+ background: C.bg, strokeWidth: 0, align: "middle" },
622
+ arrows: { to: { enabled: true, scaleFactor: 0.7 } },
623
+ width: 2,
624
+ smooth: { enabled: true, type: "curvedCW", roundness: 0.15 },
625
+ });
626
+ });
627
+
628
+ // Broker node — shown only if there are async events
629
+ if (EVENTS.length > 0) {
630
+ const brokerLabel = ((window.__EVA_DATA__.brokerName || "Kafka") + "\nBroker");
631
+ visNodes.push({
632
+ id: "__broker__",
633
+ label: "⚡ " + brokerLabel,
634
+ title: "Message broker — retransmite eventos asíncronos entre módulos",
635
+ color: {
636
+ background: C.gold, border: "#c49a00",
637
+ highlight: { background: "#ffd84d", border: "#c49a00" },
638
+ hover: { background: "#ffd84d", border: "#c49a00" },
639
+ },
640
+ font: { color: "#0d1f3c", size: 13, face: "'Plus Jakarta Sans', sans-serif", bold: true },
641
+ shape: "box", borderWidth: 2, borderWidthSelected: 3, margin: 12,
642
+ });
643
+ }
644
+
645
+ // Async edges — routed through broker + group maps for hover highlighting
646
+ const edgeGroupMap = {}; // edgeId → eventIndex
647
+ const eventEdgesMap = {}; // eventIndex → [edgeIds]
648
+ const edgeLabelMap = {}; // edgeId → original label
649
+ EVENTS.forEach((ev, i) => {
650
+ const shortLabel = ev.event.replace(/Event$/, "");
651
+ eventEdgesMap[i] = [];
652
+ // producer → broker
653
+ const pubId = "async-pub-" + i;
654
+ edgeGroupMap[pubId] = i;
655
+ eventEdgesMap[i].push(pubId);
656
+ edgeLabelMap[pubId] = shortLabel;
657
+ visEdges.push({
658
+ id: pubId,
659
+ from: ev.producer,
660
+ to: "__broker__",
661
+ label: shortLabel,
662
+ dashes: [6, 4],
663
+ color: { color: C.gold + "cc", highlight: C.gold, hover: C.gold },
664
+ font: { color: C.gold, size: 10, face: "'JetBrains Mono', monospace",
665
+ background: C.bg, strokeWidth: 0, align: "middle" },
666
+ arrows: { to: { enabled: true, scaleFactor: 0.6 } },
667
+ width: 1.5,
668
+ smooth: { enabled: true, type: "dynamic" },
669
+ });
670
+ // broker → each consumer
671
+ (ev.consumers || []).forEach((consumer, j) => {
672
+ const subId = "async-sub-" + i + "-" + j;
673
+ edgeGroupMap[subId] = i;
674
+ eventEdgesMap[i].push(subId);
675
+ edgeLabelMap[subId] = "";
676
+ visEdges.push({
677
+ id: subId,
678
+ from: "__broker__",
679
+ to: consumer,
680
+ label: "",
681
+ dashes: [4, 4],
682
+ color: { color: C.gold + "77", highlight: C.gold, hover: C.gold },
683
+ font: { color: "rgba(0,0,0,0)", strokeWidth: 0 },
684
+ arrows: { to: { enabled: true, scaleFactor: 0.5 } },
685
+ width: 1.5,
686
+ smooth: { enabled: true, type: "dynamic" },
687
+ });
688
+ });
689
+ });
690
+
691
+ return { visNodes, visEdges, edgeGroupMap, eventEdgesMap, edgeLabelMap };
692
+ }
693
+
694
+ function initNetwork(phys) {
695
+ if (!containerRef.current) return;
696
+ if (networkRef.current) { networkRef.current.destroy(); }
697
+
698
+ const { visNodes, visEdges, edgeGroupMap, eventEdgesMap, edgeLabelMap } = buildDatasets();
699
+ edgeGroupMapRef.current = edgeGroupMap;
700
+ eventEdgesMapRef.current = eventEdgesMap;
701
+ edgeLabelMapRef.current = edgeLabelMap;
702
+ const edgesDS = new vis.DataSet(visEdges);
703
+ edgesDataRef.current = edgesDS;
704
+ const data = {
705
+ nodes: new vis.DataSet(visNodes),
706
+ edges: edgesDS,
707
+ };
708
+ const options = {
709
+ physics: {
710
+ enabled: phys,
711
+ solver: "forceAtlas2Based",
712
+ forceAtlas2Based: { gravitationalConstant: -60, springLength: 160, springConstant: 0.05, damping: 0.5 },
713
+ stabilization: { iterations: 400, updateInterval: 25 },
714
+ },
715
+ interaction: { dragNodes: true, zoomView: true, hover: true, tooltipDelay: 150 },
716
+ edges: { selectionWidth: 2 },
717
+ nodes: { chosen: true },
718
+ layout: { randomSeed: 42 },
719
+ };
720
+
721
+ const net = new vis.Network(containerRef.current, data, options);
722
+ net.once("stabilizationIterationsDone", () => {
723
+ net.setOptions({ physics: { enabled: false } });
724
+ setPhysicsOn(false);
725
+ });
726
+ net.on("hoverNode", (p) => setHoveredNode(p.node));
727
+ net.on("blurNode", () => setHoveredNode(null));
728
+ net.on("hoverEdge", (p) => {
729
+ const groupIdx = edgeGroupMapRef.current[p.edge];
730
+ if (groupIdx === undefined || !edgesDataRef.current) return;
731
+ const groupIds = eventEdgesMapRef.current[groupIdx] || [];
732
+ const allIds = Object.keys(edgeGroupMapRef.current);
733
+ const RED = "#ff6b6b";
734
+ const GREEN = "#4ade80";
735
+ const FONT_PUB = { color: C.gold, size: 10, face: "'JetBrains Mono', monospace", background: C.bg, strokeWidth: 0, align: "middle" };
736
+ const FONT_HID = { color: "rgba(0,0,0,0)", background: "rgba(0,0,0,0)", strokeWidth: 0 };
737
+ edgesDataRef.current.update(allIds.map(id => {
738
+ const inGroup = groupIds.includes(id);
739
+ const isPub = id.startsWith("async-pub-");
740
+ return inGroup
741
+ ? { id,
742
+ color: { color: RED, highlight: RED, hover: RED },
743
+ font: isPub ? { ...FONT_PUB, color: GREEN, background: "rgba(0,0,0,0)", vadjust: -14 } : FONT_HID,
744
+ label: edgeLabelMapRef.current[id],
745
+ width: 1.5 }
746
+ : { id,
747
+ color: { color: C.gold + "18", highlight: C.gold + "22", hover: C.gold + "22" },
748
+ font: FONT_HID,
749
+ label: "",
750
+ width: 1.5 };
751
+ }));
752
+ // update event tooltip
753
+ const ev = EVENTS[groupIdx];
754
+ if (ev) setHoveredEvent({ name: ev.event, producer: ev.producer, consumers: ev.consumers || [] });
755
+ });
756
+ net.on("blurEdge", () => {
757
+ setHoveredEvent(null);
758
+ if (!edgesDataRef.current) return;
759
+ const allIds = Object.keys(edgeGroupMapRef.current);
760
+ const FONT_PUB = { color: C.gold, size: 10, face: "'JetBrains Mono', monospace", background: C.bg, strokeWidth: 0, align: "middle" };
761
+ const FONT_SUB = { color: "rgba(0,0,0,0)", strokeWidth: 0 };
762
+ edgesDataRef.current.update(allIds.map(id => {
763
+ const isPub = id.startsWith("async-pub-");
764
+ return {
765
+ id,
766
+ color: isPub
767
+ ? { color: C.gold + "cc", highlight: C.gold, hover: C.gold }
768
+ : { color: C.gold + "77", highlight: C.gold, hover: C.gold },
769
+ font: isPub ? FONT_PUB : FONT_SUB,
770
+ label: edgeLabelMapRef.current[id],
771
+ width: 1.5,
772
+ };
773
+ }));
774
+ });
775
+ networkRef.current = net;
776
+ }
777
+
778
+ useEffect(() => {
779
+ initNetwork(true);
780
+ return () => { if (networkRef.current) networkRef.current.destroy(); };
781
+ }, []);
782
+
783
+ const togglePhysics = () => {
784
+ const next = !physicsOn;
785
+ setPhysicsOn(next);
786
+ if (networkRef.current) networkRef.current.setOptions({ physics: { enabled: next } });
787
+ };
788
+
789
+ const fitView = () => networkRef.current && networkRef.current.fit({ animation: { duration: 400, easingFunction: "easeInOutQuad" } });
790
+ const resetNet = () => initNetwork(true);
791
+
792
+ const hoveredMod = (hoveredNode && hoveredNode !== "__broker__") ? MODULES[hoveredNode] : null;
793
+ const hoveredBroker = hoveredNode === "__broker__";
794
+ // overlay priority: hoveredEvent > node tooltips
795
+ const showOverlay = hoveredEvent || hoveredMod || hoveredBroker;
796
+
797
+ return (
798
+ <div>
799
+ {/* Toolbar */}
800
+ <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 14, flexWrap: "wrap" }}>
801
+ <button onClick={togglePhysics} style={{
802
+ background: physicsOn ? C.blue + "22" : C.surface,
803
+ border: `1px solid ${physicsOn ? C.blue : C.border}`,
804
+ color: physicsOn ? C.blue : C.textMuted,
805
+ borderRadius: 8, padding: "7px 14px", cursor: "pointer",
806
+ fontFamily: "inherit", fontSize: 12, fontWeight: 600,
807
+ transition: "all 0.15s",
808
+ }}>
809
+ {physicsOn ? "⏸ Detener física" : "▶ Activar física"}
810
+ </button>
811
+ <button onClick={fitView} style={{
812
+ background: C.surface, border: `1px solid ${C.border}`,
813
+ color: C.textMuted, borderRadius: 8, padding: "7px 14px",
814
+ cursor: "pointer", fontFamily: "inherit", fontSize: 12,
815
+ }}>⊞ Ajustar vista</button>
816
+ <button onClick={resetNet} style={{
817
+ background: C.surface, border: `1px solid ${C.border}`,
818
+ color: C.textMuted, borderRadius: 8, padding: "7px 14px",
819
+ cursor: "pointer", fontFamily: "inherit", fontSize: 12,
820
+ }}>⟳ Reiniciar</button>
821
+ <div style={{ flex: 1 }} />
822
+ </div>
823
+
824
+ {/* Canvas */}
825
+ <div style={{
826
+ position: "relative",
827
+ background: C.surface, border: `1px solid ${C.border}`,
828
+ borderRadius: 12, overflow: "hidden",
829
+ }}>
830
+ <div ref={containerRef} style={{ width: "100%", height: 520 }} />
831
+ {showOverlay && (
832
+ <div style={{
833
+ position: "absolute", bottom: 12, left: 12,
834
+ background: C.bg + "ee",
835
+ border: `1px solid ${
836
+ hoveredEvent ? "#4ade80"
837
+ : hoveredBroker ? C.gold
838
+ : hoveredMod.color}66`,
839
+ borderRadius: 8, padding: "6px 12px", backdropFilter: "blur(4px)",
840
+ fontSize: 12, fontWeight: 600,
841
+ animation: "fadeIn 0.15s", pointerEvents: "none",
842
+ maxWidth: "80%",
843
+ }}>
844
+ {hoveredEvent
845
+ ? <span style={{ color: "#4ade80" }}>
846
+ ⬡ {hoveredEvent.name}
847
+ <span style={{ color: C.textMuted, fontWeight: 400 }}> — </span>
848
+ <span style={{ color: C.textDim, fontWeight: 400 }}>
849
+ {hoveredEvent.producer} → [{hoveredEvent.consumers.join(", ")}]
850
+ </span>
851
+ </span>
852
+ : hoveredBroker
853
+ ? <span style={{ color: C.gold }}>⚡ Kafka Broker — <span style={{ color: C.textDim, fontWeight: 400 }}>Retransmite eventos asíncronos entre módulos</span></span>
854
+ : <span style={{ color: hoveredMod.color }}>{hoveredMod.icon} {hoveredMod.label} — <span style={{ color: C.textDim, fontWeight: 400 }}>{hoveredMod.desc}</span></span>
855
+ }
856
+ </div>
857
+ )}
858
+ </div>
859
+
860
+ {/* Legend */}
861
+ <div style={{
862
+ display: "flex", gap: 24, marginTop: 14, flexWrap: "wrap",
863
+ alignItems: "center", paddingLeft: 4,
864
+ }}>
865
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
866
+ <svg width="36" height="12">
867
+ <line x1="0" y1="6" x2="36" y2="6"
868
+ stroke={C.blue} strokeWidth="2" strokeLinecap="round" />
869
+ <polygon points="32,3 36,6 32,9" fill={C.blue} />
870
+ </svg>
871
+ <span style={{ color: C.textMuted, fontSize: 12 }}>Síncrono (HTTP)</span>
872
+ </div>
873
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
874
+ <svg width="36" height="12">
875
+ <line x1="0" y1="6" x2="36" y2="6"
876
+ stroke={C.gold} strokeWidth="1.5" strokeDasharray="6 4" strokeLinecap="round" />
877
+ <polygon points="32,3 36,6 32,9" fill={C.gold} />
878
+ </svg>
879
+ <span style={{ color: C.textMuted, fontSize: 12 }}>Asíncrono (Kafka)</span>
880
+ </div>
881
+ <div style={{ color: C.textMuted, fontSize: 11, marginLeft: "auto" }}>
882
+ {MODULES_LIST.length} módulos · {SYNC_INTEGRATIONS.length} puertos sync · {EVENTS.length} eventos
883
+ </div>
884
+ </div>
885
+ </div>
886
+ );
887
+ }
888
+
889
+ // ─── App root ───────────────────────────────────────────────────────────
890
+ function App() {
891
+ const [tab, setTab] = useState("validation");
892
+
893
+ const tabs = [
894
+ { id: "validation", label: "Validación", icon: "🔍" },
895
+ { id: "flows", label: "Simulador de flujos", icon: "▶" },
896
+ { id: "architecture", label: "Arquitectura", icon: "🗺️" },
897
+ { id: "diagram", label: "Diagrama", icon: "◈" },
898
+ ];
899
+
900
+ const sys = window.__EVA_DATA__;
901
+ const tech = [];
902
+ // Detect tech from systemConfig embedded in data
903
+ if (sys.events && sys.events.length > 0) tech.push({ label: "Kafka", color: C.gold });
904
+
905
+ return (
906
+ <div style={{ background: C.bg, minHeight: "100vh", color: C.text, fontFamily: "'Plus Jakarta Sans', system-ui, -apple-system, sans-serif" }}>
907
+
908
+ {/* Header */}
909
+ <div style={{ borderBottom: `1px solid ${C.border}`, padding: "0 24px" }}>
910
+ <div style={{ maxWidth: 1100, margin: "0 auto", display: "flex", alignItems: "center", gap: 16, height: 64 }}>
911
+ <div>
912
+ <span style={{ fontWeight: 900, fontSize: 18, color: C.accent, letterSpacing: -0.5 }}>eva4j</span>
913
+ <span style={{ fontWeight: 400, fontSize: 14, color: C.textMuted, marginLeft: 8 }}>/ architecture validator</span>
914
+ </div>
915
+ <div style={{ height: 20, width: 1, background: C.border }} />
916
+ <div style={{ fontWeight: 700, fontSize: 16, color: C.text }}>{systemName}</div>
917
+ <div style={{ flex: 1 }} />
918
+ <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
919
+ {sys.events && sys.events.length > 0 && (
920
+ <Tag color={C.gold}>Kafka · {sys.events.length} events</Tag>
921
+ )}
922
+ {sys.syncIntegrations && sys.syncIntegrations.length > 0 && (
923
+ <Tag color={C.blue}>Sync · {sys.syncIntegrations.length} ports</Tag>
924
+ )}
925
+ <Tag color={C.purple}>{sys.modules.length} modules</Tag>
926
+ </div>
927
+ </div>
928
+ </div>
929
+
930
+ {/* Tabs */}
931
+ <div style={{ borderBottom: `1px solid ${C.border}`, padding: "0 24px" }}>
932
+ <div style={{ maxWidth: 1100, margin: "0 auto", display: "flex", gap: 4 }}>
933
+ {tabs.map(t => (
934
+ <button key={t.id} onClick={() => setTab(t.id)} style={{
935
+ background: "transparent", border: "none",
936
+ color: tab === t.id ? C.text : C.textMuted,
937
+ padding: "16px 20px", cursor: "pointer",
938
+ fontWeight: tab === t.id ? 700 : 400,
939
+ borderBottom: `2px solid ${tab === t.id ? C.accent : "transparent"}`,
940
+ fontSize: 13, transition: "all 0.15s", fontFamily: "inherit",
941
+ display: "flex", alignItems: "center", gap: 8,
942
+ }}>
943
+ <span>{t.icon}</span> {t.label}
944
+ </button>
945
+ ))}
946
+ <div style={{ flex: 1 }} />
947
+ <div style={{ display: "flex", alignItems: "center" }}>
948
+ <span style={{ color: C.textMuted, fontSize: 10 }}>
949
+ generated {new Date(generatedAt).toLocaleString()}
950
+ </span>
951
+ </div>
952
+ </div>
953
+ </div>
954
+
955
+ {/* Tab content */}
956
+ <div style={{ maxWidth: 1100, margin: "0 auto", padding: "28px 24px" }}>
957
+ {tab === "validation" && <ValidationTab />}
958
+ {tab === "flows" && <FlowSimulator />}
959
+ {tab === "architecture" && <ArchitectureTab />}
960
+ {tab === "diagram" && <DiagramTab />}
961
+ </div>
962
+ </div>
963
+ );
964
+ }
965
+
966
+ // Mount
967
+ const root = ReactDOM.createRoot(document.getElementById("root"));
968
+ root.render(React.createElement(App, null));
969
+ </script>
970
+ </body>
971
+ </html>