eva4j 1.0.13 → 1.0.15

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 (106) hide show
  1. package/AGENTS.md +314 -10
  2. package/COMMAND_EVALUATION.md +15 -16
  3. package/DOMAIN_YAML_GUIDE.md +576 -10
  4. package/FUTURE_FEATURES.md +1627 -1168
  5. package/README.md +318 -13
  6. package/bin/eva4j.js +34 -0
  7. package/config/defaults.json +1 -0
  8. package/design-system.md +797 -0
  9. package/docs/commands/EVALUATE_SYSTEM.md +994 -0
  10. package/docs/commands/GENERATE_ENTITIES.md +795 -6
  11. package/docs/commands/INDEX.md +10 -1
  12. package/examples/domain-endpoints-relations.yaml +353 -0
  13. package/examples/domain-endpoints-versioned.yaml +144 -0
  14. package/examples/domain-endpoints.yaml +135 -0
  15. package/examples/domain-events.yaml +166 -20
  16. package/examples/domain-listeners.yaml +212 -0
  17. package/examples/domain-one-to-many.yaml +1 -0
  18. package/examples/domain-one-to-one.yaml +1 -0
  19. package/examples/domain-ports.yaml +414 -0
  20. package/examples/domain-soft-delete.yaml +47 -44
  21. package/examples/system/notification.yaml +147 -0
  22. package/examples/system/product.yaml +185 -0
  23. package/examples/system/system.yaml +112 -0
  24. package/examples/system-report.html +971 -0
  25. package/examples/system.yaml +332 -0
  26. package/package.json +2 -1
  27. package/src/commands/build.js +714 -0
  28. package/src/commands/create.js +7 -3
  29. package/src/commands/detach.js +1 -0
  30. package/src/commands/evaluate-system.js +610 -0
  31. package/src/commands/generate-entities.js +1331 -49
  32. package/src/commands/generate-http-exchange.js +2 -0
  33. package/src/commands/generate-kafka-event.js +98 -11
  34. package/src/generators/base-generator.js +8 -1
  35. package/src/generators/postman-generator.js +188 -0
  36. package/src/generators/shared-generator.js +10 -0
  37. package/src/utils/config-manager.js +54 -0
  38. package/src/utils/context-builder.js +1 -0
  39. package/src/utils/domain-diagram.js +192 -0
  40. package/src/utils/domain-validator.js +970 -0
  41. package/src/utils/fake-data.js +376 -0
  42. package/src/utils/naming.js +3 -2
  43. package/src/utils/system-validator.js +434 -0
  44. package/src/utils/yaml-to-entity.js +302 -8
  45. package/templates/aggregate/AggregateMapper.java.ejs +3 -2
  46. package/templates/aggregate/AggregateRepository.java.ejs +8 -2
  47. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +13 -3
  48. package/templates/aggregate/AggregateRoot.java.ejs +60 -2
  49. package/templates/aggregate/DomainEventHandler.java.ejs +27 -20
  50. package/templates/aggregate/DomainEventRecord.java.ejs +24 -8
  51. package/templates/aggregate/DomainEventSnapshot.java.ejs +46 -0
  52. package/templates/aggregate/JpaAggregateRoot.java.ejs +6 -0
  53. package/templates/aggregate/JpaRepository.java.ejs +5 -0
  54. package/templates/base/gradle/build.gradle.ejs +3 -2
  55. package/templates/base/root/AGENTS.md.ejs +306 -45
  56. package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1663 -0
  57. package/templates/base/root/skill-build-system-yaml.ejs +1446 -0
  58. package/templates/base/root/system.yaml.ejs +97 -0
  59. package/templates/crud/ApplicationMapper.java.ejs +4 -0
  60. package/templates/crud/Controller.java.ejs +4 -4
  61. package/templates/crud/CreateCommand.java.ejs +4 -0
  62. package/templates/crud/CreateItemDto.java.ejs +4 -0
  63. package/templates/crud/CreateValueObjectDto.java.ejs +4 -0
  64. package/templates/crud/DeleteCommandHandler.java.ejs +10 -2
  65. package/templates/crud/EndpointsController.java.ejs +178 -0
  66. package/templates/crud/FindByQuery.java.ejs +17 -0
  67. package/templates/crud/FindByQueryHandler.java.ejs +57 -0
  68. package/templates/crud/ListQuery.java.ejs +1 -1
  69. package/templates/crud/ListQueryHandler.java.ejs +8 -8
  70. package/templates/crud/ScaffoldCommand.java.ejs +12 -0
  71. package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
  72. package/templates/crud/ScaffoldQuery.java.ejs +13 -0
  73. package/templates/crud/ScaffoldQueryHandler.java.ejs +41 -0
  74. package/templates/crud/SubEntityAddCommand.java.ejs +21 -0
  75. package/templates/crud/SubEntityAddCommandHandler.java.ejs +43 -0
  76. package/templates/crud/SubEntityRemoveCommand.java.ejs +9 -0
  77. package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +42 -0
  78. package/templates/crud/TransitionCommand.java.ejs +9 -0
  79. package/templates/crud/TransitionCommandHandler.java.ejs +39 -0
  80. package/templates/crud/UpdateCommand.java.ejs +4 -0
  81. package/templates/evaluate/report.html.ejs +1363 -0
  82. package/templates/kafka-event/DomainEventHandlerMethod.ejs +3 -1
  83. package/templates/kafka-event/Event.java.ejs +16 -0
  84. package/templates/kafka-listener/KafkaController.java.ejs +1 -1
  85. package/templates/kafka-listener/KafkaListenerClass.java.ejs +1 -1
  86. package/templates/kafka-listener/ListenerClass.java.ejs +65 -0
  87. package/templates/kafka-listener/ListenerCommand.java.ejs +31 -0
  88. package/templates/kafka-listener/ListenerCommandHandler.java.ejs +23 -0
  89. package/templates/kafka-listener/ListenerIntegrationEvent.java.ejs +37 -0
  90. package/templates/kafka-listener/ListenerMethod.java.ejs +1 -1
  91. package/templates/kafka-listener/ListenerNestedType.java.ejs +28 -0
  92. package/templates/mock/MockEvent.java.ejs +10 -0
  93. package/templates/mock/MockMessageBrokerImpl.java.ejs +35 -0
  94. package/templates/mock/MockMessageBrokerImplMethod.java.ejs +6 -0
  95. package/templates/mock/SpringEventListener.java.ejs +61 -0
  96. package/templates/ports/PortDomainModel.java.ejs +35 -0
  97. package/templates/ports/PortFeignAdapter.java.ejs +67 -0
  98. package/templates/ports/PortFeignClient.java.ejs +45 -0
  99. package/templates/ports/PortFeignConfig.java.ejs +24 -0
  100. package/templates/ports/PortInterface.java.ejs +45 -0
  101. package/templates/ports/PortNestedType.java.ejs +28 -0
  102. package/templates/ports/PortRequestDto.java.ejs +30 -0
  103. package/templates/ports/PortResponseDto.java.ejs +28 -0
  104. package/templates/postman/Collection.json.ejs +1 -1
  105. package/templates/postman/UnifiedCollection.json.ejs +185 -0
  106. package/templates/shared/configurations/eventPublicationConfig/EventPublicationSchemaConfig.java.ejs +109 -0
@@ -0,0 +1,1363 @@
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
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js" defer></script>
15
+ <style>
16
+ * { box-sizing: border-box; margin: 0; padding: 0; }
17
+ body { background: #0a0a0f; color: #e8e8f0; font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; }
18
+ ::-webkit-scrollbar { width: 6px; }
19
+ ::-webkit-scrollbar-track { background: #0a0a0f; }
20
+ ::-webkit-scrollbar-thumb { background: #2e2e50; border-radius: 3px; }
21
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
22
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
23
+ @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); } }
24
+ </style>
25
+ </head>
26
+ <body>
27
+ <div id="root"></div>
28
+
29
+ <script>
30
+ (function () {
31
+ var _d = '<%- Buffer.from(JSON.stringify(data)).toString("base64") %>';
32
+ var bytes = Uint8Array.from(atob(_d), function(c) { return c.charCodeAt(0); });
33
+ window.__EVA_DATA__ = JSON.parse(new TextDecoder('utf-8').decode(bytes));
34
+ })();
35
+ </script>
36
+
37
+ <script type="text/babel">
38
+ const { useState, useRef, useEffect } = React;
39
+
40
+ // ─── Injected data ──────────────────────────────────────────────────────
41
+ const {
42
+ systemName,
43
+ modules: MODULES_LIST,
44
+ events: EVENTS,
45
+ syncIntegrations: SYNC_INTEGRATIONS,
46
+ endpoints: ENDPOINTS,
47
+ flows: FLOWS_LIST,
48
+ validation: VALIDATION,
49
+ domainValidation: DOMAIN_VALIDATION,
50
+ generatedAt,
51
+ } = window.__EVA_DATA__;
52
+
53
+ // Convert arrays to maps for fast lookup
54
+ const MODULES = Object.fromEntries(MODULES_LIST.map(m => [m.id, m]));
55
+ const FLOWS = Object.fromEntries(FLOWS_LIST.map(f => [f.id, f]));
56
+
57
+ // ─── Design system ──────────────────────────────────────────────────────
58
+ const C = {
59
+ bg: "#0a0a0f",
60
+ surface: "#12121a",
61
+ surfaceHover: "#1a1a28",
62
+ border: "#1e1e30",
63
+ borderBright: "#2e2e50",
64
+ accent: "#e63950",
65
+ gold: "#f5c842",
66
+ green: "#2dcc8f",
67
+ blue: "#4a9eff",
68
+ purple: "#9b6dff",
69
+ orange: "#ff8c42",
70
+ text: "#e8e8f0",
71
+ textMuted: "#8c8caa",
72
+ textDim: "#b4b4cc",
73
+ };
74
+
75
+ // ─── Primitive components ───────────────────────────────────────────────
76
+ const Tag = ({ color, children }) => (
77
+ <span style={{
78
+ background: color + "22", color, border: `1px solid ${color}44`,
79
+ borderRadius: 4, padding: "1px 8px", fontSize: 11, fontWeight: 600,
80
+ fontFamily: "'JetBrains Mono', 'Courier New', monospace", letterSpacing: 0.3, display: "inline-block",
81
+ }}>
82
+ {children}
83
+ </span>
84
+ );
85
+
86
+ const Badge = ({ color, children }) => (
87
+ <span style={{
88
+ background: color + "33", color, borderRadius: 20,
89
+ padding: "2px 10px", fontSize: 11, fontWeight: 700,
90
+ }}>
91
+ {children}
92
+ </span>
93
+ );
94
+
95
+ // ─── ModuleCard ─────────────────────────────────────────────────────────
96
+ function ModuleCard({ id, selected, onClick }) {
97
+ const mod = MODULES[id];
98
+ if (!mod) return null;
99
+ return (
100
+ <div onClick={onClick} style={{
101
+ background: selected ? mod.color + "22" : C.surface,
102
+ border: `1px solid ${selected ? mod.color : C.border}`,
103
+ borderRadius: 10, padding: "10px 14px", cursor: "pointer",
104
+ transition: "all 0.18s", display: "flex", alignItems: "center", gap: 10,
105
+ boxShadow: selected ? `0 0 14px ${mod.color}33` : "none",
106
+ }}>
107
+ <span style={{ fontSize: 20 }}>{mod.icon}</span>
108
+ <div>
109
+ <div style={{ fontWeight: 700, color: selected ? mod.color : C.text, fontSize: 13 }}>{mod.label}</div>
110
+ <div style={{ color: C.textMuted, fontSize: 10, marginTop: 1 }}>{mod.desc}</div>
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ // ─── ValidationTab ──────────────────────────────────────────────────────
117
+ function ValidationTab() {
118
+ const [expanded, setExpanded] = useState({ errors: true, warnings: true, info: false, ok: false });
119
+
120
+ const score = VALIDATION.score;
121
+ const scoreColor = score > 80 ? C.green : score > 60 ? C.gold : C.accent;
122
+
123
+ function Section({ title, items, color, icon, sectionKey }) {
124
+ const isOpen = expanded[sectionKey];
125
+ return (
126
+ <div style={{ marginBottom: 16 }}>
127
+ <div
128
+ onClick={() => setExpanded(e => ({ ...e, [sectionKey]: !e[sectionKey] }))}
129
+ style={{
130
+ display: "flex", alignItems: "center", gap: 10, cursor: "pointer",
131
+ padding: "10px 16px", background: C.surface, border: `1px solid ${C.border}`,
132
+ borderRadius: isOpen ? "8px 8px 0 0" : 8, userSelect: "none",
133
+ }}
134
+ >
135
+ <span style={{ fontSize: 18 }}>{icon}</span>
136
+ <span style={{ fontWeight: 700, color, flex: 1 }}>{title}</span>
137
+ <Badge color={color}>{items.length}</Badge>
138
+ <span style={{ color: C.textMuted, fontSize: 12 }}>{isOpen ? "▲" : "▼"}</span>
139
+ </div>
140
+ {isOpen && items.length > 0 && (
141
+ <div style={{ border: `1px solid ${C.border}`, borderTop: "none", borderRadius: "0 0 8px 8px", overflow: "hidden" }}>
142
+ {items.map((item, i) => (
143
+ <div key={i} style={{
144
+ padding: "10px 16px",
145
+ borderBottom: i < items.length - 1 ? `1px solid ${C.border}` : "none",
146
+ display: "flex", alignItems: "flex-start", gap: 10,
147
+ background: i % 2 === 0 ? C.bg : C.surface,
148
+ }}>
149
+ <span style={{ color, marginTop: 1, flexShrink: 0 }}>•</span>
150
+ <span style={{ color: C.textDim, fontSize: 13, lineHeight: 1.6 }}>
151
+ {typeof item === "string" ? item : item.msg}
152
+ </span>
153
+ </div>
154
+ ))}
155
+ </div>
156
+ )}
157
+ {isOpen && items.length === 0 && (
158
+ <div style={{ border: `1px solid ${C.border}`, borderTop: "none", borderRadius: "0 0 8px 8px", padding: "12px 16px", background: C.bg }}>
159
+ <span style={{ color: C.textMuted, fontSize: 12 }}>— ninguno —</span>
160
+ </div>
161
+ )}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ return (
167
+ <div>
168
+ {/* Score cards */}
169
+ <div style={{ display: "flex", gap: 16, marginBottom: 24, flexWrap: "wrap" }}>
170
+ <div style={{
171
+ flex: 1, minWidth: 160, background: C.surface, border: `1px solid ${scoreColor}44`,
172
+ borderRadius: 12, padding: 20, textAlign: "center",
173
+ boxShadow: `0 0 24px ${scoreColor}22`,
174
+ }}>
175
+ <div style={{ fontSize: 52, fontWeight: 900, color: scoreColor, fontFamily: "monospace", lineHeight: 1 }}>
176
+ {score}%
177
+ </div>
178
+ <div style={{ color: C.textMuted, fontSize: 11, marginTop: 6, letterSpacing: 1 }}>SCORE DE CALIDAD</div>
179
+ </div>
180
+ {[
181
+ { label: "Errores", count: VALIDATION.errors.length, color: C.accent, icon: "🔴" },
182
+ { label: "Advertencias", count: VALIDATION.warnings.length, color: C.gold, icon: "🟡" },
183
+ { label: "Info", count: (VALIDATION.info || []).length, color: C.blue, icon: "🔵" },
184
+ { label: "Validados", count: VALIDATION.ok.length, color: C.green, icon: "🟢" },
185
+ ].map(s => (
186
+ <div key={s.label} style={{
187
+ flex: 1, minWidth: 130, background: C.surface, border: `1px solid ${C.border}`,
188
+ borderRadius: 12, padding: 20, textAlign: "center",
189
+ }}>
190
+ <div style={{ fontSize: 38, fontWeight: 900, color: s.color, fontFamily: "monospace" }}>{s.count}</div>
191
+ <div style={{ color: C.textMuted, fontSize: 11, marginTop: 6, letterSpacing: 0.5 }}>
192
+ {s.icon} {s.label.toUpperCase()}
193
+ </div>
194
+ </div>
195
+ ))}
196
+ </div>
197
+
198
+ <Section title="Errores críticos" items={VALIDATION.errors} color={C.accent} icon="🔴" sectionKey="errors" />
199
+ <Section title="Advertencias" items={VALIDATION.warnings} color={C.gold} icon="🟡" sectionKey="warnings" />
200
+ <Section title="Notas informativas" items={VALIDATION.info || []} color={C.blue} icon="🔵" sectionKey="info" />
201
+ <Section title="Validaciones pasadas" items={VALIDATION.ok} color={C.green} icon="🟢" sectionKey="ok" />
202
+ </div>
203
+ );
204
+ }
205
+
206
+ // ─── FlowSimulator ──────────────────────────────────────────────────────
207
+ function FlowSimulator() {
208
+ const defaultFlow = FLOWS_LIST[0]?.id || null;
209
+ const [selectedFlowId, setSelectedFlowId] = useState(defaultFlow);
210
+ const [currentStep, setCurrentStep] = useState(-1);
211
+ const [running, setRunning] = useState(false);
212
+ const [completed, setCompleted] = useState(false);
213
+ const intervalRef = useRef(null);
214
+
215
+ const flow = FLOWS[selectedFlowId] || FLOWS_LIST[0];
216
+
217
+ if (!flow) {
218
+ return (
219
+ <div style={{ color: C.textMuted, padding: 40, textAlign: "center" }}>
220
+ No hay flujos de eventos declarados en integrations.async
221
+ </div>
222
+ );
223
+ }
224
+
225
+ const reset = () => {
226
+ clearInterval(intervalRef.current);
227
+ setCurrentStep(-1);
228
+ setRunning(false);
229
+ setCompleted(false);
230
+ };
231
+
232
+ const selectFlow = (id) => {
233
+ reset();
234
+ setSelectedFlowId(id);
235
+ };
236
+
237
+ const run = () => {
238
+ if (running) return;
239
+ reset();
240
+ setRunning(true);
241
+ let step = 0;
242
+ setCurrentStep(0);
243
+ intervalRef.current = setInterval(() => {
244
+ step++;
245
+ if (step >= flow.steps.length) {
246
+ clearInterval(intervalRef.current);
247
+ setRunning(false);
248
+ setCompleted(true);
249
+ setCurrentStep(flow.steps.length);
250
+ } else {
251
+ setCurrentStep(step);
252
+ }
253
+ }, 1100);
254
+ };
255
+
256
+ useEffect(() => () => clearInterval(intervalRef.current), []);
257
+
258
+ const getStepIcon = (step) => {
259
+ if (step.type === "event") return "⚡";
260
+ if (step.type === "external") return "🌐";
261
+ if (step.type === "action") return "📤";
262
+ return "→";
263
+ };
264
+
265
+ const getStepColor = (step) => {
266
+ if (step.type === "event") return C.gold;
267
+ if (step.type === "external") return C.purple;
268
+ if (step.type === "action") return C.green;
269
+ return C.blue;
270
+ };
271
+
272
+ const getActorColor = (actor) => {
273
+ if (MODULES[actor]) return MODULES[actor].color;
274
+ if (actor === "client" || actor === "organizer") return C.green;
275
+ if (actor === "admin") return C.purple;
276
+ if (actor === "gateway") return C.textDim;
277
+ return C.textMuted;
278
+ };
279
+
280
+ return (
281
+ <div>
282
+ {/* Flow selector tabs */}
283
+ <div style={{ display: "flex", gap: 8, marginBottom: 20, flexWrap: "wrap" }}>
284
+ {FLOWS_LIST.map(f => (
285
+ <button key={f.id} onClick={() => selectFlow(f.id)} style={{
286
+ background: selectedFlowId === f.id ? f.color + "22" : C.surface,
287
+ border: `1px solid ${selectedFlowId === f.id ? f.color : C.border}`,
288
+ color: selectedFlowId === f.id ? f.color : C.textMuted,
289
+ borderRadius: 8, padding: "7px 12px", cursor: "pointer",
290
+ fontWeight: selectedFlowId === f.id ? 700 : 400, fontSize: 12,
291
+ transition: "all 0.15s", display: "flex", alignItems: "center", gap: 6,
292
+ boxShadow: selectedFlowId === f.id ? `0 0 12px ${f.color}33` : "none",
293
+ fontFamily: "inherit",
294
+ }}>
295
+ <span>{f.icon}</span>{f.label}
296
+ </button>
297
+ ))}
298
+ </div>
299
+
300
+ {/* Flow header */}
301
+ <div style={{
302
+ background: C.surface, border: `1px solid ${flow.color}44`, borderRadius: 10,
303
+ padding: 16, marginBottom: 20, display: "flex", alignItems: "center", gap: 16,
304
+ }}>
305
+ <span style={{ fontSize: 32 }}>{flow.icon}</span>
306
+ <div style={{ flex: 1 }}>
307
+ <div style={{ fontWeight: 700, color: flow.color, fontSize: 16 }}>{flow.label}</div>
308
+ <div style={{ color: C.textDim, fontSize: 12, marginTop: 3 }}>{flow.description}</div>
309
+ </div>
310
+ <div style={{ display: "flex", gap: 8 }}>
311
+ <button onClick={run} disabled={running} style={{
312
+ background: running ? C.border : flow.color, color: "#fff",
313
+ border: "none", borderRadius: 8, padding: "10px 20px",
314
+ cursor: running ? "not-allowed" : "pointer", fontWeight: 700, fontSize: 14,
315
+ transition: "all 0.15s", opacity: running ? 0.6 : 1, fontFamily: "inherit",
316
+ }}>
317
+ {running ? "⏳ Ejecutando..." : completed ? "▶ Re-ejecutar" : "▶ Simular"}
318
+ </button>
319
+ <button onClick={reset} style={{
320
+ background: "transparent", color: C.textMuted,
321
+ border: `1px solid ${C.border}`, borderRadius: 8,
322
+ padding: "10px 14px", cursor: "pointer", fontFamily: "inherit",
323
+ }}>⟳</button>
324
+ </div>
325
+ </div>
326
+
327
+ {/* Steps timeline */}
328
+ <div style={{ position: "relative" }}>
329
+ {flow.steps.map((step, i) => {
330
+ const isActive = currentStep === i;
331
+ const isDone = currentStep > i;
332
+ const stepColor = getStepColor(step);
333
+
334
+ return (
335
+ <div key={step.id} style={{ display: "flex", gap: 16, marginBottom: 8, alignItems: "flex-start" }}>
336
+ {/* Timeline dot */}
337
+ <div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 36, flexShrink: 0 }}>
338
+ <div style={{
339
+ width: 36, height: 36, borderRadius: "50%",
340
+ display: "flex", alignItems: "center", justifyContent: "center",
341
+ background: isDone ? C.green + "22" : isActive ? stepColor + "33" : C.surface,
342
+ border: `2px solid ${isDone ? C.green : isActive ? stepColor : C.border}`,
343
+ color: isDone ? C.green : isActive ? stepColor : C.textMuted,
344
+ fontWeight: 700, fontSize: 13, transition: "all 0.3s",
345
+ boxShadow: isActive ? `0 0 16px ${stepColor}44` : "none",
346
+ }}>
347
+ {isDone ? "✓" : step.id}
348
+ </div>
349
+ {i < flow.steps.length - 1 && (
350
+ <div style={{
351
+ width: 2, flex: 1, minHeight: 24,
352
+ background: isDone ? C.green + "44" : C.border,
353
+ marginTop: 4,
354
+ }} />
355
+ )}
356
+ </div>
357
+
358
+ {/* Step card */}
359
+ <div style={{
360
+ flex: 1,
361
+ background: isActive ? stepColor + "11" : isDone ? C.green + "08" : C.surface,
362
+ border: `1px solid ${isActive ? stepColor + "66" : isDone ? C.green + "33" : C.border}`,
363
+ borderRadius: 10, padding: "12px 16px", marginBottom: 4,
364
+ transition: "all 0.3s",
365
+ opacity: currentStep === -1 ? 0.55 : isDone || isActive ? 1 : 0.4,
366
+ }}>
367
+ {/* Step header */}
368
+ <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
369
+ <span style={{ fontSize: 16 }}>{getStepIcon(step)}</span>
370
+ {step.from && (
371
+ <>
372
+ <Tag color={getActorColor(step.from)}>{step.from}</Tag>
373
+ <span style={{ color: C.textMuted, fontSize: 12 }}>→</span>
374
+ </>
375
+ )}
376
+ {step.to && !Array.isArray(step.to) && (
377
+ <Tag color={getActorColor(step.to)}>{step.to}</Tag>
378
+ )}
379
+ {Array.isArray(step.to) && step.to.map(t => (
380
+ <Tag key={t} color={getActorColor(t)}>{t}</Tag>
381
+ ))}
382
+ {step.label && (
383
+ <code style={{
384
+ background: C.bg, color: stepColor,
385
+ padding: "2px 8px", borderRadius: 4, fontSize: 12,
386
+ border: `1px solid ${C.border}`,
387
+ }}>
388
+ {step.label}
389
+ </code>
390
+ )}
391
+ {step.event && <Tag color={C.gold}>⚡ {step.event}</Tag>}
392
+ {step.topic && <Tag color={C.purple}>kafka:{step.topic}</Tag>}
393
+ </div>
394
+
395
+ <div style={{ color: C.textDim, fontSize: 12, marginTop: 6, lineHeight: 1.6 }}>
396
+ {step.desc}
397
+ </div>
398
+
399
+ {/* Sync sub-calls (shown when step is active) */}
400
+ {step.syncCalls && step.syncCalls.length > 0 && isActive && (
401
+ <div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px dashed ${C.border}` }}>
402
+ <div style={{ color: C.textMuted, fontSize: 10, marginBottom: 6, fontWeight: 700, letterSpacing: 1 }}>
403
+ LLAMADAS SÍNCRONAS:
404
+ </div>
405
+ {step.syncCalls.map((sc, j) => (
406
+ <div key={j} style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
407
+ <span style={{ color: C.blue, fontSize: 11 }}>⟶</span>
408
+ <Tag color={getActorColor(sc.to)}>{sc.to}</Tag>
409
+ <code style={{
410
+ background: C.bg, color: C.blue,
411
+ padding: "1px 6px", borderRadius: 3, fontSize: 11,
412
+ }}>{sc.label}</code>
413
+ <span style={{ color: C.textMuted, fontSize: 10 }}>via {sc.port}</span>
414
+ </div>
415
+ ))}
416
+ </div>
417
+ )}
418
+ </div>
419
+ </div>
420
+ );
421
+ })}
422
+ </div>
423
+
424
+ {completed && (
425
+ <div style={{
426
+ background: C.green + "15", border: `1px solid ${C.green}44`,
427
+ borderRadius: 10, padding: 16, textAlign: "center", marginTop: 8,
428
+ animation: "fadeIn 0.3s",
429
+ }}>
430
+ <span style={{ fontSize: 24 }}>✅</span>
431
+ <div style={{ color: C.green, fontWeight: 700, marginTop: 4 }}>Flujo completado exitosamente</div>
432
+ <div style={{ color: C.textMuted, fontSize: 12, marginTop: 2 }}>
433
+ {flow.steps.length} pasos · {flow.label}
434
+ </div>
435
+ </div>
436
+ )}
437
+ </div>
438
+ );
439
+ }
440
+
441
+ // ─── ArchitectureTab ────────────────────────────────────────────────────
442
+ function ArchitectureTab() {
443
+ const [selected, setSelected] = useState(null);
444
+
445
+ const moduleInfo = (id) => ({
446
+ produces: EVENTS.filter(e => e.producer === id),
447
+ consumes: EVENTS.filter(e => (e.consumers || []).includes(id)),
448
+ callsSync: SYNC_INTEGRATIONS.filter(s => s.caller === id),
449
+ calledBy: SYNC_INTEGRATIONS.filter(s => s.calls === id),
450
+ endpoints: ENDPOINTS[id] || [],
451
+ });
452
+
453
+ return (
454
+ <div>
455
+ {/* Module grid */}
456
+ <div style={{ marginBottom: 20 }}>
457
+ <div style={{ color: C.textMuted, fontSize: 11, marginBottom: 10, fontWeight: 700, letterSpacing: 1 }}>
458
+ MÓDULOS — clic para explorar dependencias
459
+ </div>
460
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 10 }}>
461
+ {MODULES_LIST.map(m => (
462
+ <ModuleCard
463
+ key={m.id}
464
+ id={m.id}
465
+ selected={selected === m.id}
466
+ onClick={() => setSelected(selected === m.id ? null : m.id)}
467
+ />
468
+ ))}
469
+ </div>
470
+ </div>
471
+
472
+ {/* Selected module detail */}
473
+ {selected && (() => {
474
+ const mod = MODULES[selected];
475
+ const info = moduleInfo(selected);
476
+ if (!mod) return null;
477
+ return (
478
+ <div style={{
479
+ background: C.surface, border: `1px solid ${mod.color}44`,
480
+ borderRadius: 12, padding: 20, marginBottom: 20,
481
+ animation: "fadeIn 0.2s",
482
+ }}>
483
+ <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 16 }}>
484
+ <span style={{ fontSize: 28 }}>{mod.icon}</span>
485
+ <div>
486
+ <div style={{ fontWeight: 800, color: mod.color, fontSize: 18 }}>{mod.label}</div>
487
+ <div style={{ color: C.textMuted, fontSize: 12 }}>{mod.desc}</div>
488
+ </div>
489
+ </div>
490
+
491
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))", gap: 16 }}>
492
+ {[
493
+ { title: "Produce eventos", items: info.produces, color: C.gold, icon: "📤", render: e => e.event },
494
+ { title: "Consume eventos", items: info.consumes, color: C.blue, icon: "📥", render: e => e.event },
495
+ { title: "Llama síncronamente", items: info.callsSync, color: C.purple, icon: "⟶", render: s => `→ ${s.calls} (${s.port})` },
496
+ { title: "Es llamado por", items: info.calledBy, color: C.green, icon: "⟵", render: s => `← ${s.caller} (${s.port})` },
497
+ { title: "Endpoints expuestos", items: info.endpoints, color: mod.color, icon: "🔌", render: e => e },
498
+ ].filter(sec => sec.items.length > 0).map(section => (
499
+ <div key={section.title}>
500
+ <div style={{ color: section.color, fontSize: 10, fontWeight: 700, marginBottom: 8, letterSpacing: 0.5 }}>
501
+ {section.icon} {section.title.toUpperCase()}
502
+ </div>
503
+ {section.items.map((item, i) => (
504
+ <div key={i} style={{
505
+ color: C.textDim, fontSize: 12, padding: "5px 0",
506
+ borderBottom: `1px solid ${C.border}`, fontFamily: "monospace",
507
+ }}>
508
+ {section.render(item)}
509
+ </div>
510
+ ))}
511
+ </div>
512
+ ))}
513
+ </div>
514
+ </div>
515
+ );
516
+ })()}
517
+
518
+ {/* Sync integrations graph */}
519
+ {SYNC_INTEGRATIONS.length > 0 && (
520
+ <div style={{ marginBottom: 24 }}>
521
+ <div style={{ color: C.textMuted, fontSize: 11, marginBottom: 10, fontWeight: 700, letterSpacing: 1 }}>
522
+ DEPENDENCIAS SÍNCRONAS ({SYNC_INTEGRATIONS.length} puertos)
523
+ </div>
524
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))", gap: 10 }}>
525
+ {SYNC_INTEGRATIONS.map((s, i) => {
526
+ const callerMod = MODULES[s.caller];
527
+ const calleeMod = MODULES[s.calls];
528
+ return (
529
+ <div key={i} style={{
530
+ background: C.surface, border: `1px solid ${C.border}`,
531
+ borderRadius: 10, padding: "12px 16px",
532
+ }}>
533
+ <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 6, marginBottom: 8 }}>
534
+ <Tag color={callerMod?.color || C.textMuted}>{s.caller}</Tag>
535
+ <span style={{ color: C.textMuted, fontSize: 14, fontWeight: 700 }}>→</span>
536
+ <Tag color={calleeMod?.color || C.textMuted}>{s.calls}</Tag>
537
+ <Tag color={C.purple}>{s.port}</Tag>
538
+ </div>
539
+ <div style={{ marginLeft: 4 }}>
540
+ {(s.endpoints || []).map((ep, j) => (
541
+ <div key={j} style={{ color: C.textDim, fontSize: 11, fontFamily: "monospace", padding: "2px 0" }}>
542
+ · {ep}
543
+ </div>
544
+ ))}
545
+ </div>
546
+ </div>
547
+ );
548
+ })}
549
+ </div>
550
+ </div>
551
+ )}
552
+
553
+ {/* Kafka topics */}
554
+ <div>
555
+ <div style={{ color: C.textMuted, fontSize: 11, marginBottom: 10, fontWeight: 700, letterSpacing: 1 }}>
556
+ KAFKA TOPICS ({EVENTS.length} eventos)
557
+ </div>
558
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 8 }}>
559
+ {EVENTS.map(e => (
560
+ <div key={e.event} style={{
561
+ background: C.surface, border: `1px solid ${C.border}`,
562
+ borderRadius: 8, padding: "10px 14px",
563
+ }}>
564
+ <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 6 }}>
565
+ <Tag color={C.gold}>{e.topic}</Tag>
566
+ </div>
567
+ <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
568
+ <Tag color={MODULES[e.producer]?.color || C.textMuted}>{e.producer}</Tag>
569
+ <span style={{ color: C.textMuted, fontSize: 11 }}>→</span>
570
+ {(e.consumers || []).map(c => (
571
+ <Tag key={c} color={MODULES[c]?.color || C.textMuted}>{c}</Tag>
572
+ ))}
573
+ </div>
574
+ </div>
575
+ ))}
576
+ </div>
577
+ </div>
578
+ </div>
579
+ );
580
+ }
581
+
582
+ // ─── DiagramTab ──────────────────────────────────────────────────────────
583
+ function DiagramTab() {
584
+ const containerRef = useRef(null);
585
+ const networkRef = useRef(null);
586
+ const edgesDataRef = useRef(null);
587
+ const edgeGroupMapRef = useRef({});
588
+ const eventEdgesMapRef = useRef({});
589
+ const edgeLabelMapRef = useRef({});
590
+ const [physicsOn, setPhysicsOn] = useState(true);
591
+ const [hoveredNode, setHoveredNode] = useState(null);
592
+ const [hoveredEvent, setHoveredEvent] = useState(null); // { name, producer, consumers[] }
593
+
594
+ // Build vis datasets from injected data
595
+ function buildDatasets() {
596
+ const visNodes = MODULES_LIST.map(mod => ({
597
+ id: mod.id,
598
+ label: mod.icon + "\n" + mod.label,
599
+ title: mod.desc,
600
+ color: {
601
+ background: mod.color + "22",
602
+ border: mod.color,
603
+ highlight: { background: mod.color + "44", border: mod.color },
604
+ hover: { background: mod.color + "33", border: mod.color },
605
+ },
606
+ font: { color: C.text, size: 13, face: "'Plus Jakarta Sans', sans-serif", multi: false },
607
+ shape: "box",
608
+ borderWidth: 2,
609
+ borderWidthSelected: 3,
610
+ margin: 10,
611
+ }));
612
+
613
+ const visEdges = [];
614
+
615
+ // Sync edges — solid blue
616
+ SYNC_INTEGRATIONS.forEach((s, i) => {
617
+ visEdges.push({
618
+ id: "sync-" + i,
619
+ from: s.caller,
620
+ to: s.calls,
621
+ label: s.port,
622
+ dashes: false,
623
+ color: { color: C.blue, highlight: C.blue, hover: C.blue },
624
+ font: { color: C.blue, size: 10, face: "'JetBrains Mono', monospace",
625
+ background: C.bg, strokeWidth: 0, align: "middle" },
626
+ arrows: { to: { enabled: true, scaleFactor: 0.7 } },
627
+ width: 2,
628
+ smooth: { enabled: true, type: "curvedCW", roundness: 0.15 },
629
+ });
630
+ });
631
+
632
+ // Broker node — shown only if there are async events
633
+ if (EVENTS.length > 0) {
634
+ const brokerLabel = ((window.__EVA_DATA__.brokerName || "Kafka") + "\nBroker");
635
+ visNodes.push({
636
+ id: "__broker__",
637
+ label: "⚡ " + brokerLabel,
638
+ title: "Message broker — retransmite eventos asíncronos entre módulos",
639
+ color: {
640
+ background: C.gold, border: "#c49a00",
641
+ highlight: { background: "#ffd84d", border: "#c49a00" },
642
+ hover: { background: "#ffd84d", border: "#c49a00" },
643
+ },
644
+ font: { color: "#0d1f3c", size: 13, face: "'Plus Jakarta Sans', sans-serif", bold: true },
645
+ shape: "box", borderWidth: 2, borderWidthSelected: 3, margin: 12,
646
+ });
647
+ }
648
+
649
+ // Async edges — routed through broker + group maps for hover highlighting
650
+ const edgeGroupMap = {}; // edgeId → eventIndex
651
+ const eventEdgesMap = {}; // eventIndex → [edgeIds]
652
+ const edgeLabelMap = {}; // edgeId → original label
653
+ EVENTS.forEach((ev, i) => {
654
+ const shortLabel = ev.event.replace(/Event$/, "");
655
+ eventEdgesMap[i] = [];
656
+ // producer → broker
657
+ const pubId = "async-pub-" + i;
658
+ edgeGroupMap[pubId] = i;
659
+ eventEdgesMap[i].push(pubId);
660
+ edgeLabelMap[pubId] = shortLabel;
661
+ visEdges.push({
662
+ id: pubId,
663
+ from: ev.producer,
664
+ to: "__broker__",
665
+ label: shortLabel,
666
+ dashes: [6, 4],
667
+ color: { color: C.gold + "cc", highlight: C.gold, hover: C.gold },
668
+ font: { color: C.gold, size: 10, face: "'JetBrains Mono', monospace",
669
+ background: C.bg, strokeWidth: 0, align: "middle" },
670
+ arrows: { to: { enabled: true, scaleFactor: 0.6 } },
671
+ width: 1.5,
672
+ smooth: { enabled: true, type: "dynamic" },
673
+ });
674
+ // broker → each consumer
675
+ (ev.consumers || []).forEach((consumer, j) => {
676
+ const subId = "async-sub-" + i + "-" + j;
677
+ edgeGroupMap[subId] = i;
678
+ eventEdgesMap[i].push(subId);
679
+ edgeLabelMap[subId] = "";
680
+ visEdges.push({
681
+ id: subId,
682
+ from: "__broker__",
683
+ to: consumer,
684
+ label: "",
685
+ dashes: [4, 4],
686
+ color: { color: C.gold + "77", highlight: C.gold, hover: C.gold },
687
+ font: { color: "rgba(0,0,0,0)", strokeWidth: 0 },
688
+ arrows: { to: { enabled: true, scaleFactor: 0.5 } },
689
+ width: 1.5,
690
+ smooth: { enabled: true, type: "dynamic" },
691
+ });
692
+ });
693
+ });
694
+
695
+ return { visNodes, visEdges, edgeGroupMap, eventEdgesMap, edgeLabelMap };
696
+ }
697
+
698
+ function initNetwork(phys) {
699
+ if (!containerRef.current) return;
700
+ if (networkRef.current) { networkRef.current.destroy(); }
701
+
702
+ const { visNodes, visEdges, edgeGroupMap, eventEdgesMap, edgeLabelMap } = buildDatasets();
703
+ edgeGroupMapRef.current = edgeGroupMap;
704
+ eventEdgesMapRef.current = eventEdgesMap;
705
+ edgeLabelMapRef.current = edgeLabelMap;
706
+ const edgesDS = new vis.DataSet(visEdges);
707
+ edgesDataRef.current = edgesDS;
708
+ const data = {
709
+ nodes: new vis.DataSet(visNodes),
710
+ edges: edgesDS,
711
+ };
712
+ const options = {
713
+ physics: {
714
+ enabled: phys,
715
+ solver: "forceAtlas2Based",
716
+ forceAtlas2Based: { gravitationalConstant: -60, springLength: 160, springConstant: 0.05, damping: 0.5 },
717
+ stabilization: { iterations: 400, updateInterval: 25 },
718
+ },
719
+ interaction: { dragNodes: true, zoomView: true, hover: true, tooltipDelay: 150 },
720
+ edges: { selectionWidth: 2 },
721
+ nodes: { chosen: true },
722
+ layout: { randomSeed: 42 },
723
+ };
724
+
725
+ const net = new vis.Network(containerRef.current, data, options);
726
+ net.once("stabilizationIterationsDone", () => {
727
+ net.setOptions({ physics: { enabled: false } });
728
+ setPhysicsOn(false);
729
+ });
730
+ net.on("hoverNode", (p) => setHoveredNode(p.node));
731
+ net.on("blurNode", () => setHoveredNode(null));
732
+ net.on("hoverEdge", (p) => {
733
+ const groupIdx = edgeGroupMapRef.current[p.edge];
734
+ if (groupIdx === undefined || !edgesDataRef.current) return;
735
+ const groupIds = eventEdgesMapRef.current[groupIdx] || [];
736
+ const allIds = Object.keys(edgeGroupMapRef.current);
737
+ const RED = "#ff6b6b";
738
+ const GREEN = "#4ade80";
739
+ const FONT_PUB = { color: C.gold, size: 10, face: "'JetBrains Mono', monospace", background: C.bg, strokeWidth: 0, align: "middle" };
740
+ const FONT_HID = { color: "rgba(0,0,0,0)", background: "rgba(0,0,0,0)", strokeWidth: 0 };
741
+ edgesDataRef.current.update(allIds.map(id => {
742
+ const inGroup = groupIds.includes(id);
743
+ const isPub = id.startsWith("async-pub-");
744
+ return inGroup
745
+ ? { id,
746
+ color: { color: RED, highlight: RED, hover: RED },
747
+ font: isPub ? { ...FONT_PUB, color: GREEN, background: "rgba(0,0,0,0)", vadjust: -14 } : FONT_HID,
748
+ label: edgeLabelMapRef.current[id],
749
+ width: 1.5 }
750
+ : { id,
751
+ color: { color: C.gold + "18", highlight: C.gold + "22", hover: C.gold + "22" },
752
+ font: FONT_HID,
753
+ label: "",
754
+ width: 1.5 };
755
+ }));
756
+ // update event tooltip
757
+ const ev = EVENTS[groupIdx];
758
+ if (ev) setHoveredEvent({ name: ev.event, producer: ev.producer, consumers: ev.consumers || [] });
759
+ });
760
+ net.on("blurEdge", () => {
761
+ setHoveredEvent(null);
762
+ if (!edgesDataRef.current) return;
763
+ const allIds = Object.keys(edgeGroupMapRef.current);
764
+ const FONT_PUB = { color: C.gold, size: 10, face: "'JetBrains Mono', monospace", background: C.bg, strokeWidth: 0, align: "middle" };
765
+ const FONT_SUB = { color: "rgba(0,0,0,0)", strokeWidth: 0 };
766
+ edgesDataRef.current.update(allIds.map(id => {
767
+ const isPub = id.startsWith("async-pub-");
768
+ return {
769
+ id,
770
+ color: isPub
771
+ ? { color: C.gold + "cc", highlight: C.gold, hover: C.gold }
772
+ : { color: C.gold + "77", highlight: C.gold, hover: C.gold },
773
+ font: isPub ? FONT_PUB : FONT_SUB,
774
+ label: edgeLabelMapRef.current[id],
775
+ width: 1.5,
776
+ };
777
+ }));
778
+ });
779
+ networkRef.current = net;
780
+ }
781
+
782
+ useEffect(() => {
783
+ initNetwork(true);
784
+ return () => { if (networkRef.current) networkRef.current.destroy(); };
785
+ }, []);
786
+
787
+ const togglePhysics = () => {
788
+ const next = !physicsOn;
789
+ setPhysicsOn(next);
790
+ if (networkRef.current) networkRef.current.setOptions({ physics: { enabled: next } });
791
+ };
792
+
793
+ const fitView = () => networkRef.current && networkRef.current.fit({ animation: { duration: 400, easingFunction: "easeInOutQuad" } });
794
+ const resetNet = () => initNetwork(true);
795
+
796
+ const hoveredMod = (hoveredNode && hoveredNode !== "__broker__") ? MODULES[hoveredNode] : null;
797
+ const hoveredBroker = hoveredNode === "__broker__";
798
+ // overlay priority: hoveredEvent > node tooltips
799
+ const showOverlay = hoveredEvent || hoveredMod || hoveredBroker;
800
+
801
+ return (
802
+ <div>
803
+ {/* Toolbar */}
804
+ <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 14, flexWrap: "wrap" }}>
805
+ <button onClick={togglePhysics} style={{
806
+ background: physicsOn ? C.blue + "22" : C.surface,
807
+ border: `1px solid ${physicsOn ? C.blue : C.border}`,
808
+ color: physicsOn ? C.blue : C.textMuted,
809
+ borderRadius: 8, padding: "7px 14px", cursor: "pointer",
810
+ fontFamily: "inherit", fontSize: 12, fontWeight: 600,
811
+ transition: "all 0.15s",
812
+ }}>
813
+ {physicsOn ? "⏸ Detener física" : "▶ Activar física"}
814
+ </button>
815
+ <button onClick={fitView} style={{
816
+ background: C.surface, border: `1px solid ${C.border}`,
817
+ color: C.textMuted, borderRadius: 8, padding: "7px 14px",
818
+ cursor: "pointer", fontFamily: "inherit", fontSize: 12,
819
+ }}>⊞ Ajustar vista</button>
820
+ <button onClick={resetNet} style={{
821
+ background: C.surface, border: `1px solid ${C.border}`,
822
+ color: C.textMuted, borderRadius: 8, padding: "7px 14px",
823
+ cursor: "pointer", fontFamily: "inherit", fontSize: 12,
824
+ }}>⟳ Reiniciar</button>
825
+ <div style={{ flex: 1 }} />
826
+ </div>
827
+
828
+ {/* Canvas */}
829
+ <div style={{
830
+ position: "relative",
831
+ background: C.surface, border: `1px solid ${C.border}`,
832
+ borderRadius: 12, overflow: "hidden",
833
+ }}>
834
+ <div ref={containerRef} style={{ width: "100%", height: 720 }} />
835
+ {showOverlay && (
836
+ <div style={{
837
+ position: "absolute", bottom: 12, left: 12,
838
+ background: C.bg + "ee",
839
+ border: `1px solid ${
840
+ hoveredEvent ? "#4ade80"
841
+ : hoveredBroker ? C.gold
842
+ : hoveredMod.color}66`,
843
+ borderRadius: 8, padding: "6px 12px", backdropFilter: "blur(4px)",
844
+ fontSize: 12, fontWeight: 600,
845
+ animation: "fadeIn 0.15s", pointerEvents: "none",
846
+ maxWidth: "80%",
847
+ }}>
848
+ {hoveredEvent
849
+ ? <span style={{ color: "#4ade80" }}>
850
+ ⬡ {hoveredEvent.name}
851
+ <span style={{ color: C.textMuted, fontWeight: 400 }}> — </span>
852
+ <span style={{ color: C.textDim, fontWeight: 400 }}>
853
+ {hoveredEvent.producer} → [{hoveredEvent.consumers.join(", ")}]
854
+ </span>
855
+ </span>
856
+ : hoveredBroker
857
+ ? <span style={{ color: C.gold }}>⚡ Kafka Broker — <span style={{ color: C.textDim, fontWeight: 400 }}>Retransmite eventos asíncronos entre módulos</span></span>
858
+ : <span style={{ color: hoveredMod.color }}>{hoveredMod.icon} {hoveredMod.label} — <span style={{ color: C.textDim, fontWeight: 400 }}>{hoveredMod.desc}</span></span>
859
+ }
860
+ </div>
861
+ )}
862
+ </div>
863
+
864
+ {/* Legend */}
865
+ <div style={{
866
+ display: "flex", gap: 24, marginTop: 14, flexWrap: "wrap",
867
+ alignItems: "center", paddingLeft: 4,
868
+ }}>
869
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
870
+ <svg width="36" height="12">
871
+ <line x1="0" y1="6" x2="36" y2="6"
872
+ stroke={C.blue} strokeWidth="2" strokeLinecap="round" />
873
+ <polygon points="32,3 36,6 32,9" fill={C.blue} />
874
+ </svg>
875
+ <span style={{ color: C.textMuted, fontSize: 12 }}>Síncrono (HTTP)</span>
876
+ </div>
877
+ <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
878
+ <svg width="36" height="12">
879
+ <line x1="0" y1="6" x2="36" y2="6"
880
+ stroke={C.gold} strokeWidth="1.5" strokeDasharray="6 4" strokeLinecap="round" />
881
+ <polygon points="32,3 36,6 32,9" fill={C.gold} />
882
+ </svg>
883
+ <span style={{ color: C.textMuted, fontSize: 12 }}>Asíncrono (Kafka)</span>
884
+ </div>
885
+ <div style={{ color: C.textMuted, fontSize: 11, marginLeft: "auto" }}>
886
+ {MODULES_LIST.length} módulos · {SYNC_INTEGRATIONS.length} puertos sync · {EVENTS.length} eventos
887
+ </div>
888
+ </div>
889
+ </div>
890
+ );
891
+ }
892
+ // ─── Mermaid helper ──────────────────────────────────────────────────────────────
893
+ let _mermaidReady = false;
894
+ function ensureMermaid() {
895
+ if (!_mermaidReady && typeof window.mermaid !== 'undefined') {
896
+ window.mermaid.initialize({
897
+ startOnLoad: false,
898
+ theme: 'dark',
899
+ themeVariables: {
900
+ background: '#0a0a0f',
901
+ primaryColor: '#1e1e2e',
902
+ primaryBorderColor: '#4a4a8a',
903
+ lineColor: '#8c8caa',
904
+ fontFamily: "'JetBrains Mono', monospace",
905
+ fontSize: '13px',
906
+ },
907
+ });
908
+ _mermaidReady = true;
909
+ }
910
+ return _mermaidReady;
911
+ }
912
+
913
+ // ─── DiagramPanel ────────────────────────────────────────────────────────────────
914
+ function DiagramPanel({ moduleKey, diagramText }) {
915
+ const containerRef = useRef(null);
916
+ const wrapperRef = useRef(null);
917
+ const [renderState, setRenderState] = useState('idle');
918
+ const [errorMsg, setErrorMsg] = useState('');
919
+ const [wrapperJustify, setWrapperJustify] = useState('flex-start');
920
+
921
+ useEffect(() => {
922
+ if (!diagramText) return;
923
+ let cancelled = false;
924
+ setRenderState('loading');
925
+ (async () => {
926
+ try {
927
+ // Wait up to 4s for mermaid to load (deferred script)
928
+ let waited = 0;
929
+ while (!ensureMermaid() && waited < 4000) {
930
+ await new Promise((r) => setTimeout(r, 100));
931
+ waited += 100;
932
+ }
933
+ if (!ensureMermaid()) throw new Error('Mermaid no está disponible');
934
+ if (cancelled) return;
935
+ const id = 'mmd-' + moduleKey.replace(/[^a-zA-Z0-9]/g, '-') + '-' + Date.now();
936
+ const { svg } = await window.mermaid.render(id, diagramText);
937
+ if (cancelled || !containerRef.current) return;
938
+ containerRef.current.innerHTML = svg;
939
+ const svgEl = containerRef.current.querySelector('svg');
940
+ if (svgEl) {
941
+ svgEl.removeAttribute('height');
942
+ svgEl.removeAttribute('width');
943
+
944
+ // Measure intrinsic width from viewBox to decide sizing strategy
945
+ const vb = svgEl.getAttribute('viewBox');
946
+ const intrinsicW = vb ? parseFloat(vb.split(/\s+/)[2]) : 0;
947
+ const containerW = wrapperRef.current ? wrapperRef.current.clientWidth - 40 : 0;
948
+
949
+ if (intrinsicW > 0 && intrinsicW < containerW * 0.65) {
950
+ // Small diagram (1-2 classes): render at natural size and center
951
+ svgEl.style.width = intrinsicW + 'px';
952
+ svgEl.style.maxWidth = '100%';
953
+ setWrapperJustify('center');
954
+ } else {
955
+ // Large diagram: expand to fill; overflow: auto handles horizontal scroll
956
+ svgEl.style.width = Math.max(intrinsicW, containerW) + 'px';
957
+ svgEl.style.maxWidth = 'none';
958
+ setWrapperJustify('flex-start');
959
+ }
960
+ }
961
+ setRenderState('done');
962
+ } catch (err) {
963
+ if (!cancelled) {
964
+ setErrorMsg(err.message || String(err));
965
+ setRenderState('error');
966
+ }
967
+ }
968
+ })();
969
+ return () => { cancelled = true; };
970
+ }, [moduleKey, diagramText]);
971
+
972
+ if (!diagramText) {
973
+ return (
974
+ <div style={{ padding: 40, textAlign: 'center', color: C.textMuted, fontSize: 13 }}>
975
+ No hay diagrama disponible para este módulo
976
+ </div>
977
+ );
978
+ }
979
+ return (
980
+ <div
981
+ ref={wrapperRef}
982
+ style={{
983
+ background: '#0d0d15', borderRadius: 10, padding: 20,
984
+ overflow: 'auto', border: `1px solid ${C.border}`, minHeight: 200,
985
+ display: 'flex', justifyContent: wrapperJustify,
986
+ }}
987
+ >
988
+ {renderState === 'loading' && (
989
+ <div style={{ color: C.textMuted, fontSize: 13, textAlign: 'center', padding: 30, width: '100%' }}>
990
+ ⏳ Renderizando diagrama...
991
+ </div>
992
+ )}
993
+ {renderState === 'error' && (
994
+ <div style={{ color: C.accent, fontSize: 12, padding: 10, fontFamily: "'JetBrains Mono', monospace" }}>
995
+ ⚠ {errorMsg}
996
+ </div>
997
+ )}
998
+ <div ref={containerRef} style={{ display: renderState === 'done' ? 'block' : 'none' }} />
999
+ </div>
1000
+ );
1001
+ }
1002
+
1003
+ // ─── DomainTab ──────────────────────────────────────────────────────────────────
1004
+ function DomainTab() {
1005
+ const [expandedChecks, setExpandedChecks] = useState({});
1006
+ const [selectedModule, setSelectedModule] = useState('all');
1007
+ const [view, setView] = useState('findings');
1008
+ if (!DOMAIN_VALIDATION) return null;
1009
+
1010
+ const { summary, categories, diagrams } = DOMAIN_VALIDATION;
1011
+
1012
+ // Collect all unique module names that have at least one finding
1013
+ const allModules = [...new Set(
1014
+ categories.flatMap(cat =>
1015
+ cat.checks.flatMap(check => check.findings.map(f => f.module))
1016
+ )
1017
+ )].sort();
1018
+
1019
+ const SEV_COLOR = {
1020
+ error: C.accent,
1021
+ warning: C.gold,
1022
+ info: C.blue,
1023
+ ok: C.green,
1024
+ };
1025
+ const SEV_LABEL = {
1026
+ error: 'Error',
1027
+ warning: 'Warning',
1028
+ info: 'Info',
1029
+ ok: 'OK',
1030
+ };
1031
+ const SEV_ICON = {
1032
+ error: '🔴',
1033
+ warning: '🟡',
1034
+ info: '🔵',
1035
+ ok: '🟢',
1036
+ };
1037
+
1038
+ function toggleCheck(id) {
1039
+ setExpandedChecks(prev => ({ ...prev, [id]: !prev[id] }));
1040
+ }
1041
+
1042
+ function changeModule(mod) {
1043
+ setSelectedModule(mod);
1044
+ setExpandedChecks({});
1045
+ }
1046
+
1047
+ return (
1048
+ <div style={{ animation: 'fadeIn 0.25s ease' }}>
1049
+
1050
+ {/* Summary bar */}
1051
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 20 }}>
1052
+ {[
1053
+ { label: 'Errors', count: summary.errors, color: C.accent },
1054
+ { label: 'Warnings', count: summary.warnings, color: C.gold },
1055
+ { label: 'Info', count: summary.info, color: C.blue },
1056
+ { label: 'OK', count: summary.ok, color: C.green },
1057
+ ].map(stat => (
1058
+ <div key={stat.label} style={{
1059
+ background: C.surface, border: `1px solid ${stat.color}44`,
1060
+ borderRadius: 10, padding: '16px 20px', textAlign: 'center',
1061
+ }}>
1062
+ <div style={{ fontSize: 28, fontWeight: 900, color: stat.color }}>{stat.count}</div>
1063
+ <div style={{ fontSize: 12, color: C.textMuted, marginTop: 4 }}>{stat.label}</div>
1064
+ </div>
1065
+ ))}
1066
+ </div>
1067
+
1068
+ {/* Module filter */}
1069
+ <div style={{
1070
+ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20,
1071
+ padding: '12px 16px', background: C.surface,
1072
+ border: `1px solid ${C.border}`, borderRadius: 8,
1073
+ }}>
1074
+ <span style={{ fontSize: 12, color: C.textMuted, fontWeight: 600, flexShrink: 0 }}>Filtrar por módulo</span>
1075
+ <select
1076
+ value={selectedModule}
1077
+ onChange={e => changeModule(e.target.value)}
1078
+ style={{
1079
+ background: C.bg, color: C.text,
1080
+ border: `1px solid ${selectedModule !== 'all' ? C.blue : C.borderBright}`,
1081
+ borderRadius: 6, padding: '6px 32px 6px 12px', fontSize: 13,
1082
+ cursor: 'pointer', fontFamily: 'inherit', outline: 'none',
1083
+ appearance: 'none', WebkitAppearance: 'none',
1084
+ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238c8caa'/%3E%3C/svg%3E")`,
1085
+ backgroundRepeat: 'no-repeat', backgroundPosition: 'right 10px center',
1086
+ }}
1087
+ >
1088
+ <option value="all">Todos los módulos ({allModules.length})</option>
1089
+ {allModules.map(m => (
1090
+ <option key={m} value={m}>{m}</option>
1091
+ ))}
1092
+ </select>
1093
+ {selectedModule !== 'all' && (
1094
+ <button
1095
+ onClick={() => changeModule('all')}
1096
+ style={{
1097
+ background: C.accent + '22', color: C.accent,
1098
+ border: `1px solid ${C.accent}44`, borderRadius: 6,
1099
+ padding: '4px 12px', fontSize: 12, cursor: 'pointer',
1100
+ fontFamily: 'inherit', fontWeight: 600,
1101
+ }}
1102
+ >
1103
+ ✕ limpiar
1104
+ </button>
1105
+ )}
1106
+ {selectedModule !== 'all' && (
1107
+ <span style={{ color: C.blue, fontSize: 12, marginLeft: 4 }}>
1108
+ Mostrando hallazgos de <strong style={{ color: C.text }}>{selectedModule}</strong>
1109
+ </span>
1110
+ )}
1111
+ </div>
1112
+
1113
+ {/* View toggle pill (only rendered when diagrams are available) */}
1114
+ {diagrams && Object.keys(diagrams).length > 0 && (
1115
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 20 }}>
1116
+ {[{ id: 'findings', label: '📋 Hallazgos' }, { id: 'diagram', label: '🗺 Diagrama' }].map(v => (
1117
+ <button
1118
+ key={v.id}
1119
+ onClick={() => setView(v.id)}
1120
+ style={{
1121
+ background: view === v.id ? C.purple + '33' : 'transparent',
1122
+ color: view === v.id ? C.purple : C.textMuted,
1123
+ border: `1px solid ${view === v.id ? C.purple + '66' : C.border}`,
1124
+ borderRadius: 6, padding: '6px 16px', fontSize: 12,
1125
+ fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
1126
+ transition: 'all 0.15s',
1127
+ }}
1128
+ >{v.label}</button>
1129
+ ))}
1130
+ </div>
1131
+ )}
1132
+
1133
+ {/* Diagram view */}
1134
+ {view === 'diagram' && (
1135
+ <div>
1136
+ {selectedModule === 'all' ? (
1137
+ <div style={{ padding: 32, textAlign: 'center', background: C.surface, borderRadius: 10, border: `1px solid ${C.border}`, color: C.textMuted, fontSize: 13 }}>
1138
+ Selecciona un módulo en el filtro de arriba para ver su diagrama de clases
1139
+ </div>
1140
+ ) : (
1141
+ <DiagramPanel moduleKey={selectedModule} diagramText={diagrams && diagrams[selectedModule]} />
1142
+ )}
1143
+ </div>
1144
+ )}
1145
+
1146
+ {/* Category cards */}
1147
+ {view === 'findings' && categories.map(cat => {
1148
+ // Apply module filter to each check's findings
1149
+ const filteredChecks = cat.checks.map(check => ({
1150
+ ...check,
1151
+ visibleFindings: selectedModule === 'all'
1152
+ ? check.findings
1153
+ : check.findings.filter(f => f.module === selectedModule),
1154
+ }));
1155
+ // If filtering and no check in this category has visible findings, skip the card
1156
+ const anyVisible = filteredChecks.some(c => c.visibleFindings.length > 0 || c.severity === 'ok');
1157
+
1158
+ return (
1159
+ <div key={cat.id} style={{
1160
+ marginBottom: 20, border: `1px solid ${C.border}`,
1161
+ borderRadius: 10, overflow: 'hidden',
1162
+ opacity: selectedModule !== 'all' && !filteredChecks.some(c => c.visibleFindings.length > 0) ? 0.45 : 1,
1163
+ transition: 'opacity 0.2s',
1164
+ }}>
1165
+ {/* Category header */}
1166
+ <div style={{
1167
+ background: C.surface, padding: '14px 18px',
1168
+ borderBottom: `1px solid ${C.border}`,
1169
+ display: 'flex', alignItems: 'flex-start', gap: 12,
1170
+ }}>
1171
+ <span style={{
1172
+ background: C.purple + '33', color: C.purple,
1173
+ border: `1px solid ${C.purple}44`, borderRadius: 6,
1174
+ padding: '2px 10px', fontSize: 12, fontWeight: 700,
1175
+ fontFamily: "'JetBrains Mono', monospace", flexShrink: 0, marginTop: 2,
1176
+ }}>{cat.id}</span>
1177
+ <div style={{ flex: 1 }}>
1178
+ <div style={{ fontWeight: 700, fontSize: 14, color: C.text }}>{cat.label}</div>
1179
+ <div style={{ fontSize: 12, color: C.textMuted, marginTop: 3 }}>{cat.description}</div>
1180
+ </div>
1181
+ {selectedModule !== 'all' && (
1182
+ <span style={{ fontSize: 11, color: C.textMuted, flexShrink: 0, marginTop: 4 }}>
1183
+ {filteredChecks.reduce((n, c) => n + c.visibleFindings.length, 0)} hallazgo(s)
1184
+ </span>
1185
+ )}
1186
+ </div>
1187
+
1188
+ {/* Checks list */}
1189
+ <div>
1190
+ {filteredChecks.map((check, idx) => {
1191
+ const color = SEV_COLOR[check.severity] || C.textMuted;
1192
+ const isExpanded = expandedChecks[check.id];
1193
+ const hasFindings = check.visibleFindings.length > 0;
1194
+ return (
1195
+ <div key={check.id} style={{
1196
+ borderBottom: idx < filteredChecks.length - 1 ? `1px solid ${C.border}` : 'none',
1197
+ }}>
1198
+ {/* Check row */}
1199
+ <div
1200
+ onClick={() => hasFindings && toggleCheck(check.id)}
1201
+ style={{
1202
+ display: 'flex', alignItems: 'center', gap: 12,
1203
+ padding: '11px 18px',
1204
+ cursor: hasFindings ? 'pointer' : 'default',
1205
+ background: isExpanded ? C.surfaceHover : 'transparent',
1206
+ transition: 'background 0.15s',
1207
+ }}
1208
+ >
1209
+ <span style={{
1210
+ fontFamily: "'JetBrains Mono', monospace",
1211
+ fontSize: 11, color: C.textMuted, flexShrink: 0, width: 60,
1212
+ }}>{check.id}</span>
1213
+
1214
+ <span style={{
1215
+ background: color + '22', color, border: `1px solid ${color}44`,
1216
+ borderRadius: 4, padding: '1px 8px', fontSize: 11, fontWeight: 700,
1217
+ flexShrink: 0, minWidth: 64, textAlign: 'center',
1218
+ }}>
1219
+ {SEV_ICON[check.severity]} {SEV_LABEL[check.severity]}
1220
+ </span>
1221
+
1222
+ <span style={{ fontSize: 13, color: C.textDim, flex: 1 }}>{check.label}</span>
1223
+
1224
+ {hasFindings && (
1225
+ <span style={{
1226
+ background: color + '22', color, borderRadius: 20,
1227
+ padding: '2px 10px', fontSize: 11, fontWeight: 700, flexShrink: 0,
1228
+ }}>
1229
+ {check.visibleFindings.length}
1230
+ {selectedModule === 'all' ? '' : ` / ${check.findings.length}`}
1231
+ {' '}hallazgo{check.visibleFindings.length !== 1 ? 's' : ''}
1232
+ </span>
1233
+ )}
1234
+
1235
+ {hasFindings && (
1236
+ <span style={{ color: C.textMuted, fontSize: 12, flexShrink: 0, transition: 'transform 0.15s', transform: isExpanded ? 'rotate(90deg)' : 'none' }}>&#9654;</span>
1237
+ )}
1238
+ </div>
1239
+
1240
+ {/* Findings panel */}
1241
+ {isExpanded && hasFindings && (
1242
+ <div style={{
1243
+ background: '#0d0d15', borderTop: `1px solid ${C.border}`,
1244
+ padding: '0 18px 12px 18px',
1245
+ }}>
1246
+ {check.visibleFindings.map((f, fi) => (
1247
+ <div key={fi} style={{
1248
+ padding: '10px 0',
1249
+ borderBottom: fi < check.visibleFindings.length - 1 ? `1px solid ${C.border}` : 'none',
1250
+ display: 'flex', alignItems: 'flex-start', gap: 12,
1251
+ }}>
1252
+ <span style={{
1253
+ background: color + '22', color,
1254
+ border: `1px solid ${color}44`, borderRadius: 4,
1255
+ padding: '1px 8px', fontSize: 11, fontWeight: 600,
1256
+ flexShrink: 0, marginTop: 1,
1257
+ fontFamily: "'JetBrains Mono', monospace",
1258
+ }}>{f.module}</span>
1259
+ <div>
1260
+ <div style={{ fontSize: 13, color: C.text }}>{f.message}</div>
1261
+ {f.context && (
1262
+ <div style={{ fontSize: 11, color: C.textMuted, marginTop: 4 }}>{f.context}</div>
1263
+ )}
1264
+ </div>
1265
+ </div>
1266
+ ))}
1267
+ </div>
1268
+ )}
1269
+ </div>
1270
+ );
1271
+ })}
1272
+ </div>
1273
+ </div>
1274
+ );
1275
+ })}
1276
+ </div>
1277
+ );
1278
+ }
1279
+ // ─── App root ───────────────────────────────────────────────────────────
1280
+ function App() {
1281
+ const [tab, setTab] = useState("validation");
1282
+
1283
+ const tabs = [
1284
+ { id: "validation", label: "Validación", icon: "🔍" },
1285
+ { id: "flows", label: "Simulador de flujos", icon: "▶" },
1286
+ { id: "architecture", label: "Arquitectura", icon: "🗺️" },
1287
+ { id: "diagram", label: "Diagrama", icon: "◈" },
1288
+ ...(DOMAIN_VALIDATION ? [{ id: "domain", label: "Dominio", icon: "🏛️" }] : []),
1289
+ ];
1290
+
1291
+ const sys = window.__EVA_DATA__;
1292
+ const tech = [];
1293
+ // Detect tech from systemConfig embedded in data
1294
+ if (sys.events && sys.events.length > 0) tech.push({ label: "Kafka", color: C.gold });
1295
+
1296
+ return (
1297
+ <div style={{ background: C.bg, minHeight: "100vh", color: C.text, fontFamily: "'Plus Jakarta Sans', system-ui, -apple-system, sans-serif" }}>
1298
+
1299
+ {/* Header */}
1300
+ <div style={{ borderBottom: `1px solid ${C.border}`, padding: "0 24px" }}>
1301
+ <div style={{ maxWidth: 1100, margin: "0 auto", display: "flex", alignItems: "center", gap: 16, height: 64 }}>
1302
+ <div>
1303
+ <span style={{ fontWeight: 900, fontSize: 18, color: C.accent, letterSpacing: -0.5 }}>eva4j</span>
1304
+ <span style={{ fontWeight: 400, fontSize: 14, color: C.textMuted, marginLeft: 8 }}>/ architecture validator</span>
1305
+ </div>
1306
+ <div style={{ height: 20, width: 1, background: C.border }} />
1307
+ <div style={{ fontWeight: 700, fontSize: 16, color: C.text }}>{systemName}</div>
1308
+ <div style={{ flex: 1 }} />
1309
+ <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
1310
+ {sys.events && sys.events.length > 0 && (
1311
+ <Tag color={C.gold}>Kafka · {sys.events.length} events</Tag>
1312
+ )}
1313
+ {sys.syncIntegrations && sys.syncIntegrations.length > 0 && (
1314
+ <Tag color={C.blue}>Sync · {sys.syncIntegrations.length} ports</Tag>
1315
+ )}
1316
+ <Tag color={C.purple}>{sys.modules.length} modules</Tag>
1317
+ </div>
1318
+ </div>
1319
+ </div>
1320
+
1321
+ {/* Tabs */}
1322
+ <div style={{ borderBottom: `1px solid ${C.border}`, padding: "0 24px" }}>
1323
+ <div style={{ maxWidth: 1100, margin: "0 auto", display: "flex", gap: 4 }}>
1324
+ {tabs.map(t => (
1325
+ <button key={t.id} onClick={() => setTab(t.id)} style={{
1326
+ background: "transparent", border: "none",
1327
+ color: tab === t.id ? C.text : C.textMuted,
1328
+ padding: "16px 20px", cursor: "pointer",
1329
+ fontWeight: tab === t.id ? 700 : 400,
1330
+ borderBottom: `2px solid ${tab === t.id ? C.accent : "transparent"}`,
1331
+ fontSize: 13, transition: "all 0.15s", fontFamily: "inherit",
1332
+ display: "flex", alignItems: "center", gap: 8,
1333
+ }}>
1334
+ <span>{t.icon}</span> {t.label}
1335
+ </button>
1336
+ ))}
1337
+ <div style={{ flex: 1 }} />
1338
+ <div style={{ display: "flex", alignItems: "center" }}>
1339
+ <span style={{ color: C.textMuted, fontSize: 10 }}>
1340
+ generated {new Date(generatedAt).toLocaleString()}
1341
+ </span>
1342
+ </div>
1343
+ </div>
1344
+ </div>
1345
+
1346
+ {/* Tab content */}
1347
+ <div style={{ maxWidth: 1100, margin: "0 auto", padding: "28px 24px" }}>
1348
+ {tab === "validation" && <ValidationTab />}
1349
+ {tab === "flows" && <FlowSimulator />}
1350
+ {tab === "architecture" && <ArchitectureTab />}
1351
+ {tab === "diagram" && <DiagramTab />}
1352
+ {tab === "domain" && DOMAIN_VALIDATION && <DomainTab />}
1353
+ </div>
1354
+ </div>
1355
+ );
1356
+ }
1357
+
1358
+ // Mount
1359
+ const root = ReactDOM.createRoot(document.getElementById("root"));
1360
+ root.render(React.createElement(App, null));
1361
+ </script>
1362
+ </body>
1363
+ </html>