spec-gen-cli 1.0.0

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 (303) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1078 -0
  3. package/dist/api/analyze.d.ts +17 -0
  4. package/dist/api/analyze.d.ts.map +1 -0
  5. package/dist/api/analyze.js +109 -0
  6. package/dist/api/analyze.js.map +1 -0
  7. package/dist/api/drift.d.ts +21 -0
  8. package/dist/api/drift.d.ts.map +1 -0
  9. package/dist/api/drift.js +145 -0
  10. package/dist/api/drift.js.map +1 -0
  11. package/dist/api/generate.d.ts +18 -0
  12. package/dist/api/generate.d.ts.map +1 -0
  13. package/dist/api/generate.js +251 -0
  14. package/dist/api/generate.js.map +1 -0
  15. package/dist/api/index.d.ts +39 -0
  16. package/dist/api/index.d.ts.map +1 -0
  17. package/dist/api/index.js +32 -0
  18. package/dist/api/index.js.map +1 -0
  19. package/dist/api/init.d.ts +18 -0
  20. package/dist/api/init.d.ts.map +1 -0
  21. package/dist/api/init.js +82 -0
  22. package/dist/api/init.js.map +1 -0
  23. package/dist/api/run.d.ts +19 -0
  24. package/dist/api/run.d.ts.map +1 -0
  25. package/dist/api/run.js +291 -0
  26. package/dist/api/run.js.map +1 -0
  27. package/dist/api/specs.d.ts +49 -0
  28. package/dist/api/specs.d.ts.map +1 -0
  29. package/dist/api/specs.js +136 -0
  30. package/dist/api/specs.js.map +1 -0
  31. package/dist/api/types.d.ts +176 -0
  32. package/dist/api/types.d.ts.map +1 -0
  33. package/dist/api/types.js +9 -0
  34. package/dist/api/types.js.map +1 -0
  35. package/dist/api/verify.d.ts +20 -0
  36. package/dist/api/verify.d.ts.map +1 -0
  37. package/dist/api/verify.js +117 -0
  38. package/dist/api/verify.js.map +1 -0
  39. package/dist/cli/commands/analyze.d.ts +27 -0
  40. package/dist/cli/commands/analyze.d.ts.map +1 -0
  41. package/dist/cli/commands/analyze.js +485 -0
  42. package/dist/cli/commands/analyze.js.map +1 -0
  43. package/dist/cli/commands/drift.d.ts +9 -0
  44. package/dist/cli/commands/drift.d.ts.map +1 -0
  45. package/dist/cli/commands/drift.js +540 -0
  46. package/dist/cli/commands/drift.js.map +1 -0
  47. package/dist/cli/commands/generate.d.ts +9 -0
  48. package/dist/cli/commands/generate.d.ts.map +1 -0
  49. package/dist/cli/commands/generate.js +633 -0
  50. package/dist/cli/commands/generate.js.map +1 -0
  51. package/dist/cli/commands/init.d.ts +9 -0
  52. package/dist/cli/commands/init.d.ts.map +1 -0
  53. package/dist/cli/commands/init.js +171 -0
  54. package/dist/cli/commands/init.js.map +1 -0
  55. package/dist/cli/commands/mcp.d.ts +638 -0
  56. package/dist/cli/commands/mcp.d.ts.map +1 -0
  57. package/dist/cli/commands/mcp.js +574 -0
  58. package/dist/cli/commands/mcp.js.map +1 -0
  59. package/dist/cli/commands/run.d.ts +24 -0
  60. package/dist/cli/commands/run.d.ts.map +1 -0
  61. package/dist/cli/commands/run.js +546 -0
  62. package/dist/cli/commands/run.js.map +1 -0
  63. package/dist/cli/commands/verify.d.ts +9 -0
  64. package/dist/cli/commands/verify.d.ts.map +1 -0
  65. package/dist/cli/commands/verify.js +417 -0
  66. package/dist/cli/commands/verify.js.map +1 -0
  67. package/dist/cli/commands/view.d.ts +9 -0
  68. package/dist/cli/commands/view.d.ts.map +1 -0
  69. package/dist/cli/commands/view.js +511 -0
  70. package/dist/cli/commands/view.js.map +1 -0
  71. package/dist/cli/index.d.ts +9 -0
  72. package/dist/cli/index.d.ts.map +1 -0
  73. package/dist/cli/index.js +83 -0
  74. package/dist/cli/index.js.map +1 -0
  75. package/dist/core/analyzer/architecture-writer.d.ts +67 -0
  76. package/dist/core/analyzer/architecture-writer.d.ts.map +1 -0
  77. package/dist/core/analyzer/architecture-writer.js +209 -0
  78. package/dist/core/analyzer/architecture-writer.js.map +1 -0
  79. package/dist/core/analyzer/artifact-generator.d.ts +222 -0
  80. package/dist/core/analyzer/artifact-generator.d.ts.map +1 -0
  81. package/dist/core/analyzer/artifact-generator.js +726 -0
  82. package/dist/core/analyzer/artifact-generator.js.map +1 -0
  83. package/dist/core/analyzer/call-graph.d.ts +83 -0
  84. package/dist/core/analyzer/call-graph.d.ts.map +1 -0
  85. package/dist/core/analyzer/call-graph.js +827 -0
  86. package/dist/core/analyzer/call-graph.js.map +1 -0
  87. package/dist/core/analyzer/code-shaper.d.ts +33 -0
  88. package/dist/core/analyzer/code-shaper.d.ts.map +1 -0
  89. package/dist/core/analyzer/code-shaper.js +149 -0
  90. package/dist/core/analyzer/code-shaper.js.map +1 -0
  91. package/dist/core/analyzer/dependency-graph.d.ts +179 -0
  92. package/dist/core/analyzer/dependency-graph.d.ts.map +1 -0
  93. package/dist/core/analyzer/dependency-graph.js +574 -0
  94. package/dist/core/analyzer/dependency-graph.js.map +1 -0
  95. package/dist/core/analyzer/duplicate-detector.d.ts +52 -0
  96. package/dist/core/analyzer/duplicate-detector.d.ts.map +1 -0
  97. package/dist/core/analyzer/duplicate-detector.js +279 -0
  98. package/dist/core/analyzer/duplicate-detector.js.map +1 -0
  99. package/dist/core/analyzer/embedding-service.d.ts +50 -0
  100. package/dist/core/analyzer/embedding-service.d.ts.map +1 -0
  101. package/dist/core/analyzer/embedding-service.js +104 -0
  102. package/dist/core/analyzer/embedding-service.js.map +1 -0
  103. package/dist/core/analyzer/file-walker.d.ts +78 -0
  104. package/dist/core/analyzer/file-walker.d.ts.map +1 -0
  105. package/dist/core/analyzer/file-walker.js +531 -0
  106. package/dist/core/analyzer/file-walker.js.map +1 -0
  107. package/dist/core/analyzer/import-parser.d.ts +91 -0
  108. package/dist/core/analyzer/import-parser.d.ts.map +1 -0
  109. package/dist/core/analyzer/import-parser.js +720 -0
  110. package/dist/core/analyzer/import-parser.js.map +1 -0
  111. package/dist/core/analyzer/index.d.ts +10 -0
  112. package/dist/core/analyzer/index.d.ts.map +1 -0
  113. package/dist/core/analyzer/index.js +10 -0
  114. package/dist/core/analyzer/index.js.map +1 -0
  115. package/dist/core/analyzer/refactor-analyzer.d.ts +80 -0
  116. package/dist/core/analyzer/refactor-analyzer.d.ts.map +1 -0
  117. package/dist/core/analyzer/refactor-analyzer.js +339 -0
  118. package/dist/core/analyzer/refactor-analyzer.js.map +1 -0
  119. package/dist/core/analyzer/repository-mapper.d.ts +150 -0
  120. package/dist/core/analyzer/repository-mapper.d.ts.map +1 -0
  121. package/dist/core/analyzer/repository-mapper.js +731 -0
  122. package/dist/core/analyzer/repository-mapper.js.map +1 -0
  123. package/dist/core/analyzer/signature-extractor.d.ts +31 -0
  124. package/dist/core/analyzer/signature-extractor.d.ts.map +1 -0
  125. package/dist/core/analyzer/signature-extractor.js +387 -0
  126. package/dist/core/analyzer/signature-extractor.js.map +1 -0
  127. package/dist/core/analyzer/significance-scorer.d.ts +79 -0
  128. package/dist/core/analyzer/significance-scorer.d.ts.map +1 -0
  129. package/dist/core/analyzer/significance-scorer.js +407 -0
  130. package/dist/core/analyzer/significance-scorer.js.map +1 -0
  131. package/dist/core/analyzer/subgraph-extractor.d.ts +43 -0
  132. package/dist/core/analyzer/subgraph-extractor.d.ts.map +1 -0
  133. package/dist/core/analyzer/subgraph-extractor.js +129 -0
  134. package/dist/core/analyzer/subgraph-extractor.js.map +1 -0
  135. package/dist/core/analyzer/vector-index.d.ts +63 -0
  136. package/dist/core/analyzer/vector-index.d.ts.map +1 -0
  137. package/dist/core/analyzer/vector-index.js +169 -0
  138. package/dist/core/analyzer/vector-index.js.map +1 -0
  139. package/dist/core/drift/drift-detector.d.ts +102 -0
  140. package/dist/core/drift/drift-detector.d.ts.map +1 -0
  141. package/dist/core/drift/drift-detector.js +597 -0
  142. package/dist/core/drift/drift-detector.js.map +1 -0
  143. package/dist/core/drift/git-diff.d.ts +55 -0
  144. package/dist/core/drift/git-diff.d.ts.map +1 -0
  145. package/dist/core/drift/git-diff.js +356 -0
  146. package/dist/core/drift/git-diff.js.map +1 -0
  147. package/dist/core/drift/index.d.ts +12 -0
  148. package/dist/core/drift/index.d.ts.map +1 -0
  149. package/dist/core/drift/index.js +9 -0
  150. package/dist/core/drift/index.js.map +1 -0
  151. package/dist/core/drift/spec-mapper.d.ts +73 -0
  152. package/dist/core/drift/spec-mapper.d.ts.map +1 -0
  153. package/dist/core/drift/spec-mapper.js +353 -0
  154. package/dist/core/drift/spec-mapper.js.map +1 -0
  155. package/dist/core/generator/adr-generator.d.ts +32 -0
  156. package/dist/core/generator/adr-generator.d.ts.map +1 -0
  157. package/dist/core/generator/adr-generator.js +192 -0
  158. package/dist/core/generator/adr-generator.js.map +1 -0
  159. package/dist/core/generator/index.d.ts +9 -0
  160. package/dist/core/generator/index.d.ts.map +1 -0
  161. package/dist/core/generator/index.js +12 -0
  162. package/dist/core/generator/index.js.map +1 -0
  163. package/dist/core/generator/mapping-generator.d.ts +54 -0
  164. package/dist/core/generator/mapping-generator.d.ts.map +1 -0
  165. package/dist/core/generator/mapping-generator.js +239 -0
  166. package/dist/core/generator/mapping-generator.js.map +1 -0
  167. package/dist/core/generator/openspec-compat.d.ts +160 -0
  168. package/dist/core/generator/openspec-compat.d.ts.map +1 -0
  169. package/dist/core/generator/openspec-compat.js +523 -0
  170. package/dist/core/generator/openspec-compat.js.map +1 -0
  171. package/dist/core/generator/openspec-format-generator.d.ts +111 -0
  172. package/dist/core/generator/openspec-format-generator.d.ts.map +1 -0
  173. package/dist/core/generator/openspec-format-generator.js +817 -0
  174. package/dist/core/generator/openspec-format-generator.js.map +1 -0
  175. package/dist/core/generator/openspec-writer.d.ts +131 -0
  176. package/dist/core/generator/openspec-writer.d.ts.map +1 -0
  177. package/dist/core/generator/openspec-writer.js +379 -0
  178. package/dist/core/generator/openspec-writer.js.map +1 -0
  179. package/dist/core/generator/prompts.d.ts +35 -0
  180. package/dist/core/generator/prompts.d.ts.map +1 -0
  181. package/dist/core/generator/prompts.js +212 -0
  182. package/dist/core/generator/prompts.js.map +1 -0
  183. package/dist/core/generator/spec-pipeline.d.ts +94 -0
  184. package/dist/core/generator/spec-pipeline.d.ts.map +1 -0
  185. package/dist/core/generator/spec-pipeline.js +474 -0
  186. package/dist/core/generator/spec-pipeline.js.map +1 -0
  187. package/dist/core/generator/stages/stage1-survey.d.ts +19 -0
  188. package/dist/core/generator/stages/stage1-survey.d.ts.map +1 -0
  189. package/dist/core/generator/stages/stage1-survey.js +105 -0
  190. package/dist/core/generator/stages/stage1-survey.js.map +1 -0
  191. package/dist/core/generator/stages/stage2-entities.d.ts +11 -0
  192. package/dist/core/generator/stages/stage2-entities.d.ts.map +1 -0
  193. package/dist/core/generator/stages/stage2-entities.js +67 -0
  194. package/dist/core/generator/stages/stage2-entities.js.map +1 -0
  195. package/dist/core/generator/stages/stage3-services.d.ts +11 -0
  196. package/dist/core/generator/stages/stage3-services.d.ts.map +1 -0
  197. package/dist/core/generator/stages/stage3-services.js +75 -0
  198. package/dist/core/generator/stages/stage3-services.js.map +1 -0
  199. package/dist/core/generator/stages/stage4-api.d.ts +11 -0
  200. package/dist/core/generator/stages/stage4-api.d.ts.map +1 -0
  201. package/dist/core/generator/stages/stage4-api.js +65 -0
  202. package/dist/core/generator/stages/stage4-api.js.map +1 -0
  203. package/dist/core/generator/stages/stage5-architecture.d.ts +10 -0
  204. package/dist/core/generator/stages/stage5-architecture.d.ts.map +1 -0
  205. package/dist/core/generator/stages/stage5-architecture.js +62 -0
  206. package/dist/core/generator/stages/stage5-architecture.js.map +1 -0
  207. package/dist/core/generator/stages/stage6-adr.d.ts +8 -0
  208. package/dist/core/generator/stages/stage6-adr.d.ts.map +1 -0
  209. package/dist/core/generator/stages/stage6-adr.js +41 -0
  210. package/dist/core/generator/stages/stage6-adr.js.map +1 -0
  211. package/dist/core/services/chat-agent.d.ts +45 -0
  212. package/dist/core/services/chat-agent.d.ts.map +1 -0
  213. package/dist/core/services/chat-agent.js +310 -0
  214. package/dist/core/services/chat-agent.js.map +1 -0
  215. package/dist/core/services/chat-tools.d.ts +32 -0
  216. package/dist/core/services/chat-tools.d.ts.map +1 -0
  217. package/dist/core/services/chat-tools.js +270 -0
  218. package/dist/core/services/chat-tools.js.map +1 -0
  219. package/dist/core/services/config-manager.d.ts +61 -0
  220. package/dist/core/services/config-manager.d.ts.map +1 -0
  221. package/dist/core/services/config-manager.js +143 -0
  222. package/dist/core/services/config-manager.js.map +1 -0
  223. package/dist/core/services/gitignore-manager.d.ts +29 -0
  224. package/dist/core/services/gitignore-manager.d.ts.map +1 -0
  225. package/dist/core/services/gitignore-manager.js +106 -0
  226. package/dist/core/services/gitignore-manager.js.map +1 -0
  227. package/dist/core/services/index.d.ts +8 -0
  228. package/dist/core/services/index.d.ts.map +1 -0
  229. package/dist/core/services/index.js +8 -0
  230. package/dist/core/services/index.js.map +1 -0
  231. package/dist/core/services/llm-service.d.ts +336 -0
  232. package/dist/core/services/llm-service.d.ts.map +1 -0
  233. package/dist/core/services/llm-service.js +1155 -0
  234. package/dist/core/services/llm-service.js.map +1 -0
  235. package/dist/core/services/mcp-handlers/analysis.d.ts +42 -0
  236. package/dist/core/services/mcp-handlers/analysis.d.ts.map +1 -0
  237. package/dist/core/services/mcp-handlers/analysis.js +300 -0
  238. package/dist/core/services/mcp-handlers/analysis.js.map +1 -0
  239. package/dist/core/services/mcp-handlers/graph.d.ts +65 -0
  240. package/dist/core/services/mcp-handlers/graph.d.ts.map +1 -0
  241. package/dist/core/services/mcp-handlers/graph.js +509 -0
  242. package/dist/core/services/mcp-handlers/graph.js.map +1 -0
  243. package/dist/core/services/mcp-handlers/semantic.d.ts +38 -0
  244. package/dist/core/services/mcp-handlers/semantic.d.ts.map +1 -0
  245. package/dist/core/services/mcp-handlers/semantic.js +172 -0
  246. package/dist/core/services/mcp-handlers/semantic.js.map +1 -0
  247. package/dist/core/services/mcp-handlers/utils.d.ts +21 -0
  248. package/dist/core/services/mcp-handlers/utils.d.ts.map +1 -0
  249. package/dist/core/services/mcp-handlers/utils.js +62 -0
  250. package/dist/core/services/mcp-handlers/utils.js.map +1 -0
  251. package/dist/core/services/project-detector.d.ts +32 -0
  252. package/dist/core/services/project-detector.d.ts.map +1 -0
  253. package/dist/core/services/project-detector.js +111 -0
  254. package/dist/core/services/project-detector.js.map +1 -0
  255. package/dist/core/verifier/index.d.ts +5 -0
  256. package/dist/core/verifier/index.d.ts.map +1 -0
  257. package/dist/core/verifier/index.js +5 -0
  258. package/dist/core/verifier/index.js.map +1 -0
  259. package/dist/core/verifier/verification-engine.d.ts +226 -0
  260. package/dist/core/verifier/verification-engine.d.ts.map +1 -0
  261. package/dist/core/verifier/verification-engine.js +681 -0
  262. package/dist/core/verifier/verification-engine.js.map +1 -0
  263. package/dist/types/index.d.ts +252 -0
  264. package/dist/types/index.d.ts.map +1 -0
  265. package/dist/types/index.js +5 -0
  266. package/dist/types/index.js.map +1 -0
  267. package/dist/types/pipeline.d.ts +148 -0
  268. package/dist/types/pipeline.d.ts.map +1 -0
  269. package/dist/types/pipeline.js +5 -0
  270. package/dist/types/pipeline.js.map +1 -0
  271. package/dist/utils/errors.d.ts +51 -0
  272. package/dist/utils/errors.d.ts.map +1 -0
  273. package/dist/utils/errors.js +128 -0
  274. package/dist/utils/errors.js.map +1 -0
  275. package/dist/utils/logger.d.ts +149 -0
  276. package/dist/utils/logger.d.ts.map +1 -0
  277. package/dist/utils/logger.js +331 -0
  278. package/dist/utils/logger.js.map +1 -0
  279. package/dist/utils/progress.d.ts +142 -0
  280. package/dist/utils/progress.d.ts.map +1 -0
  281. package/dist/utils/progress.js +280 -0
  282. package/dist/utils/progress.js.map +1 -0
  283. package/dist/utils/prompts.d.ts +53 -0
  284. package/dist/utils/prompts.d.ts.map +1 -0
  285. package/dist/utils/prompts.js +199 -0
  286. package/dist/utils/prompts.js.map +1 -0
  287. package/dist/utils/shutdown.d.ts +89 -0
  288. package/dist/utils/shutdown.d.ts.map +1 -0
  289. package/dist/utils/shutdown.js +237 -0
  290. package/dist/utils/shutdown.js.map +1 -0
  291. package/package.json +114 -0
  292. package/src/viewer/InteractiveGraphViewer.jsx +1486 -0
  293. package/src/viewer/app/index.html +17 -0
  294. package/src/viewer/app/main.jsx +13 -0
  295. package/src/viewer/components/ArchitectureView.jsx +177 -0
  296. package/src/viewer/components/ChatPanel.jsx +448 -0
  297. package/src/viewer/components/ClusterGraph.jsx +441 -0
  298. package/src/viewer/components/FilterBar.jsx +179 -0
  299. package/src/viewer/components/FlatGraph.jsx +275 -0
  300. package/src/viewer/components/MicroComponents.jsx +83 -0
  301. package/src/viewer/hooks/usePanZoom.js +79 -0
  302. package/src/viewer/utils/constants.js +47 -0
  303. package/src/viewer/utils/graph-helpers.js +291 -0
@@ -0,0 +1,1486 @@
1
+ import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
2
+ import { extColor } from './utils/constants.js';
3
+ import {
4
+ parseSpecRequirements,
5
+ buildMappingIndex,
6
+ normalizePath,
7
+ parseGraph,
8
+ enrichGraphWithRefactors,
9
+ computeBlast,
10
+ } from './utils/graph-helpers.js';
11
+ import { FlatGraph } from './components/FlatGraph.jsx';
12
+ import { ClusterGraph } from './components/ClusterGraph.jsx';
13
+ import { FilterBar } from './components/FilterBar.jsx';
14
+ import { ArchitectureView } from './components/ArchitectureView.jsx';
15
+ import { Hint, SL, Row, Chip, KindBadge } from './components/MicroComponents.jsx';
16
+ import { ChatPanel } from './components/ChatPanel.jsx';
17
+
18
+ export default function App({ graphUrl, mappingUrl = '/api/mapping', specUrl = '/api/spec' }) {
19
+ const [graph, setGraph] = useState(null);
20
+ const [llmCtx, setLlmCtx] = useState(null);
21
+ const [refReport, setRefReport] = useState(null);
22
+ const [mapping, setMapping] = useState(null);
23
+ const [specReqs, setSpecReqs] = useState({});
24
+ const [selectedId, setSelectedId] = useState(null);
25
+ const [affectedIds, setAffectedIds] = useState([]);
26
+ const [focusedIds, setFocusedIds] = useState([]);
27
+ const [search, setSearch] = useState('');
28
+ const [semanticResults, setSemanticResults] = useState([]);
29
+ const [semanticAvailable, setSemanticAvailable] = useState(true);
30
+ const semanticTimer = useRef(null);
31
+ const [tab, setTab] = useState('node');
32
+ const [skeletonData, setSkeletonData] = useState(null);
33
+ const [skeletonLoading, setSkeletonLoading] = useState(false);
34
+ const [viewMode, setViewMode] = useState('clusters');
35
+ const [expandedClusters, setExpandedClusters] = useState(new Set());
36
+ const [filters, setFilters] = useState({
37
+ hideOrphans: false,
38
+ minScore: 0,
39
+ topN: 999,
40
+ cluster: '',
41
+ refactorOnly: false,
42
+ });
43
+ const [loaded, setLoaded] = useState(false);
44
+ const [chatOpen, setChatOpen] = useState(false);
45
+ const fileRef = useRef();
46
+ const hasAutoLoadedRef = useRef(false);
47
+
48
+ useEffect(() => {
49
+ setTimeout(() => setLoaded(true), 80);
50
+ }, []);
51
+
52
+ const loadGraph = useCallback(
53
+ (jsonStr) => {
54
+ try {
55
+ const g = parseGraph(JSON.parse(jsonStr));
56
+ setGraph(refReport ? enrichGraphWithRefactors(g, refReport) : g);
57
+ setSelectedId(null);
58
+ setAffectedIds([]);
59
+ setFocusedIds([]);
60
+ setSearch('');
61
+ setFilters({
62
+ hideOrphans: false,
63
+ minScore: 0,
64
+ topN: 999,
65
+ cluster: '',
66
+ refactorOnly: false,
67
+ });
68
+ setExpandedClusters(new Set());
69
+ } catch (e) {
70
+ alert('Invalid JSON: ' + e.message);
71
+ }
72
+ },
73
+ [refReport]
74
+ );
75
+
76
+ const loadMapping = useCallback((jsonStr) => {
77
+ try {
78
+ const m = JSON.parse(jsonStr);
79
+ setMapping(buildMappingIndex(m));
80
+ } catch (e) {
81
+ console.error('Invalid mapping JSON', e);
82
+ }
83
+ }, []);
84
+
85
+ const loadSpec = useCallback((mdStr) => {
86
+ setSpecReqs(parseSpecRequirements(mdStr));
87
+ }, []);
88
+
89
+ const mappingRef = useRef();
90
+ const specRef = useRef();
91
+
92
+ useEffect(() => {
93
+ if (!graphUrl || hasAutoLoadedRef.current) return;
94
+ hasAutoLoadedRef.current = true;
95
+ (async () => {
96
+ try {
97
+ const res = await fetch(graphUrl);
98
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
99
+ const text = await res.text();
100
+ loadGraph(text);
101
+
102
+ try {
103
+ const ctxRes = await fetch('/api/llm-context');
104
+ if (ctxRes.ok) setLlmCtx(await ctxRes.json());
105
+ } catch { /* ignore */ }
106
+
107
+ try {
108
+ const refRes = await fetch('/api/refactor-priorities');
109
+ if (refRes.ok) {
110
+ const report = await refRes.json();
111
+ setRefReport(report);
112
+ setGraph((g) => (g ? enrichGraphWithRefactors(g, report) : g));
113
+ }
114
+ } catch { /* ignore */ }
115
+
116
+ try {
117
+ const mRes = await fetch('/api/mapping');
118
+ if (mRes.ok) loadMapping(await mRes.text());
119
+ } catch { /* ignore */ }
120
+ try {
121
+ const srRes = await fetch('/api/spec-requirements');
122
+ if (srRes.ok) {
123
+ const reqsJson = await srRes.json();
124
+ setSpecReqs(reqsJson);
125
+ } else {
126
+ try {
127
+ const sRes = await fetch('/api/spec');
128
+ if (sRes.ok) loadSpec(await sRes.text());
129
+ } catch { /* ignore */ }
130
+ }
131
+ } catch { /* ignore */ }
132
+ } catch (e) {
133
+ console.error('Failed to load graph from', graphUrl, e);
134
+ }
135
+ })();
136
+ }, [graphUrl, mappingUrl, specUrl, loadGraph]);
137
+
138
+ const handleFile = (e) => {
139
+ const f = e.target.files[0];
140
+ if (!f) return;
141
+ const r = new FileReader();
142
+ r.onload = (ev) => loadGraph(ev.target.result);
143
+ r.readAsText(f);
144
+ };
145
+
146
+ // ── Filtered nodes/edges ──────────────────────────────────────────────────
147
+ const { visibleNodes, visibleEdges, filterStats } = useMemo(() => {
148
+ if (!graph) return { visibleNodes: [], visibleEdges: [], filterStats: {} };
149
+
150
+ const connectedIds = new Set();
151
+ graph.edges.forEach((e) => {
152
+ connectedIds.add(e.source);
153
+ connectedIds.add(e.target);
154
+ });
155
+ const orphanCount = graph.nodes.filter((n) => !connectedIds.has(n.id)).length;
156
+
157
+ let nodes = filters.cluster
158
+ ? graph.nodes.filter((n) => n.cluster.name === filters.cluster)
159
+ : graph.nodes;
160
+
161
+ if (filters.refactorOnly) {
162
+ nodes = nodes.filter((n) => n.refactor);
163
+ }
164
+
165
+ if (filters.hideOrphans) nodes = nodes.filter((n) => connectedIds.has(n.id));
166
+ if (filters.minScore > 0) nodes = nodes.filter((n) => n.score >= filters.minScore);
167
+
168
+ if (filters.topN < 999) {
169
+ const ranked = graph.rankings.byImportance || graph.nodes.map((n) => n.id);
170
+ const topSet = new Set(ranked.slice(0, filters.topN));
171
+ nodes = nodes.filter((n) => topSet.has(n.id));
172
+ }
173
+
174
+ const vset = new Set(nodes.map((n) => n.id));
175
+ const edges = graph.edges.filter((e) => vset.has(e.source) && vset.has(e.target));
176
+
177
+ const refactorTotal =
178
+ graph.refactorStats?.withIssues ?? graph.nodes.filter((n) => n.refactor).length;
179
+ const refactorVisible = nodes.filter((n) => n.refactor).length;
180
+
181
+ return {
182
+ visibleNodes: nodes,
183
+ visibleEdges: edges,
184
+ filterStats: {
185
+ total: graph.nodes.length,
186
+ visible: nodes.length,
187
+ visibleEdges: edges.length,
188
+ orphanCount,
189
+ refactorTotal,
190
+ refactorVisible,
191
+ },
192
+ };
193
+ }, [graph, filters]);
194
+
195
+ const handleSearch = (q) => {
196
+ setSearch(q);
197
+ if (!q.trim()) {
198
+ setFocusedIds([]);
199
+ setSemanticResults([]);
200
+ clearTimeout(semanticTimer.current);
201
+ return;
202
+ }
203
+ const lo = q.toLowerCase();
204
+ setFocusedIds(
205
+ visibleNodes
206
+ .filter(
207
+ (n) =>
208
+ n.label.toLowerCase().includes(lo) ||
209
+ n.path.toLowerCase().includes(lo) ||
210
+ n.ext.includes(lo) ||
211
+ n.tags.some((t) => t.toLowerCase().includes(lo)) ||
212
+ n.exports.some((ex) => ex.name.toLowerCase().includes(lo))
213
+ )
214
+ .map((n) => n.id)
215
+ );
216
+ if (!semanticAvailable || q.trim().length < 3) return;
217
+ clearTimeout(semanticTimer.current);
218
+ semanticTimer.current = setTimeout(async () => {
219
+ try {
220
+ const res = await fetch(`/api/search?q=${encodeURIComponent(q.trim())}`);
221
+ if (res.status === 404) { setSemanticAvailable(false); return; }
222
+ if (!res.ok) return;
223
+ setSemanticResults(await res.json());
224
+ } catch { /* ignore */ }
225
+ }, 400);
226
+ };
227
+
228
+ const handleSelect = useCallback(
229
+ (id) => {
230
+ if (selectedId === id) {
231
+ setSelectedId(null);
232
+ setAffectedIds([]);
233
+ return;
234
+ }
235
+ setSelectedId(id);
236
+ setAffectedIds(computeBlast(visibleEdges, id));
237
+ setTab(mapping ? 'spec' : 'node');
238
+ },
239
+ [selectedId, visibleEdges, mapping]
240
+ );
241
+
242
+ const toggleCluster = useCallback((cid) => {
243
+ setExpandedClusters((prev) => {
244
+ const next = new Set(prev);
245
+ next.has(cid) ? next.delete(cid) : next.add(cid);
246
+ return next;
247
+ });
248
+ }, []);
249
+
250
+ const clearSelection = useCallback(() => {
251
+ setSelectedId(null);
252
+ setAffectedIds([]);
253
+ setExpandedClusters(new Set());
254
+ setSemanticResults([]);
255
+ setSkeletonData(null);
256
+ }, []);
257
+
258
+ useEffect(() => {
259
+ const onKey = (e) => { if (e.key === 'Escape') clearSelection(); };
260
+ window.addEventListener('keydown', onKey);
261
+ return () => window.removeEventListener('keydown', onKey);
262
+ }, [clearSelection]);
263
+
264
+ // Auto-expand clusters when their nodes are highlighted by the chatbot
265
+ useEffect(() => {
266
+ if (!graph || focusedIds.length === 0) return;
267
+
268
+ const clusterIdsToExpand = new Set();
269
+ const validNodeIds = [];
270
+ focusedIds.forEach((fid) => {
271
+ const node = graph.nodes.find((n) => n.id === fid);
272
+ if (node) {
273
+ if (node.cluster?.id) clusterIdsToExpand.add(node.cluster.id);
274
+ validNodeIds.push(fid);
275
+ }
276
+ });
277
+
278
+ if (clusterIdsToExpand.size > 0) {
279
+ setExpandedClusters((prev) => {
280
+ const next = new Set(prev);
281
+ clusterIdsToExpand.forEach((cid) => next.add(cid));
282
+ return next;
283
+ });
284
+ }
285
+
286
+ // Select the first highlighted node to show details and prominent highlight
287
+ if (validNodeIds.length > 0) {
288
+ const id = validNodeIds[0];
289
+ setSelectedId(id);
290
+ setAffectedIds(computeBlast(visibleEdges, id));
291
+ setTab(mapping ? 'spec' : 'node');
292
+ }
293
+ }, [focusedIds, graph, visibleEdges, mapping, computeBlast]);
294
+
295
+ const selectedNode = graph?.nodes.find((n) => n.id === selectedId);
296
+
297
+ const selectedPath = selectedNode?.path ?? null;
298
+ useEffect(() => {
299
+ if (tab !== 'skeleton' || !selectedPath) { setSkeletonData(null); return; }
300
+ setSkeletonLoading(true);
301
+ fetch(`/api/skeleton?file=${encodeURIComponent(selectedPath)}`)
302
+ .then(r => r.ok ? r.json() : null)
303
+ .then(d => { setSkeletonData(d); setSkeletonLoading(false); })
304
+ .catch(() => setSkeletonLoading(false));
305
+ }, [tab, selectedPath]);
306
+
307
+ const selectedEdges = useMemo(() => {
308
+ if (!selectedId) return [];
309
+ return visibleEdges.filter((e) => e.source === selectedId || e.target === selectedId);
310
+ }, [selectedId, visibleEdges]);
311
+
312
+ const linkedIds = useMemo(() => {
313
+ if (!selectedId) return new Set();
314
+ const set = new Set([selectedId, ...affectedIds]);
315
+ visibleEdges.forEach((e) => {
316
+ if (e.source === selectedId) set.add(e.target);
317
+ if (e.target === selectedId) set.add(e.source);
318
+ });
319
+ return set;
320
+ }, [selectedId, affectedIds, visibleEdges]);
321
+
322
+ const stats = graph?.statistics || {};
323
+ const clusterNames = graph?.clusters.map((c) => c.name) || [];
324
+
325
+ // ── Upload screen ─────────────────────────────────────────────────────────
326
+ if (!graph)
327
+ return (
328
+ <div
329
+ style={{
330
+ width: '100%',
331
+ height: '100vh',
332
+ background: '#07091a',
333
+ display: 'flex',
334
+ flexDirection: 'column',
335
+ alignItems: 'center',
336
+ justifyContent: 'center',
337
+ fontFamily: "'JetBrains Mono',monospace",
338
+ color: '#c8cde8',
339
+ opacity: loaded ? 1 : 0,
340
+ transition: 'opacity 0.3s',
341
+ }}
342
+ >
343
+ <div style={{ fontSize: 10, letterSpacing: '0.18em', color: '#2a2f4a', marginBottom: 28 }}>
344
+ INTERACTIVE GRAPH VIEWER
345
+ </div>
346
+ <div
347
+ style={{
348
+ border: '1px dashed #252a45',
349
+ borderRadius: 12,
350
+ padding: '44px 64px',
351
+ textAlign: 'center',
352
+ cursor: 'pointer',
353
+ }}
354
+ onClick={() => fileRef.current.click()}
355
+ onDragOver={(e) => e.preventDefault()}
356
+ onDrop={(e) => {
357
+ e.preventDefault();
358
+ const f = e.dataTransfer.files[0];
359
+ if (f) {
360
+ const r = new FileReader();
361
+ r.onload = (ev) => loadGraph(ev.target.result);
362
+ r.readAsText(f);
363
+ }
364
+ }}
365
+ >
366
+ <div style={{ fontSize: 32, marginBottom: 14, color: '#7c6af7' }}>⬡</div>
367
+ <div style={{ fontSize: 12, color: '#8890b0', marginBottom: 6 }}>
368
+ Drop a <code style={{ color: '#7c6af7' }}>dependency-graph.json</code>
369
+ </div>
370
+ <div style={{ fontSize: 10, color: '#3a3f5c' }}>or click to browse</div>
371
+ </div>
372
+ <input
373
+ ref={fileRef}
374
+ type="file"
375
+ accept=".json"
376
+ style={{ display: 'none' }}
377
+ onChange={handleFile}
378
+ />
379
+ <input
380
+ ref={mappingRef}
381
+ type="file"
382
+ accept=".json"
383
+ style={{ display: 'none' }}
384
+ onChange={(e) => {
385
+ const f = e.target.files[0];
386
+ if (f) {
387
+ const r = new FileReader();
388
+ r.onload = (ev) => loadMapping(ev.target.result);
389
+ r.readAsText(f);
390
+ }
391
+ }}
392
+ />
393
+ <input
394
+ ref={specRef}
395
+ type="file"
396
+ accept=".md"
397
+ style={{ display: 'none' }}
398
+ onChange={(e) => {
399
+ const f = e.target.files[0];
400
+ if (f) {
401
+ const r = new FileReader();
402
+ r.onload = (ev) => loadSpec(ev.target.result);
403
+ r.readAsText(f);
404
+ }
405
+ }}
406
+ />
407
+ </div>
408
+ );
409
+
410
+ // ── Main UI ───────────────────────────────────────────────────────────────
411
+ return (
412
+ <div
413
+ style={{
414
+ width: '100%',
415
+ height: '100vh',
416
+ background: '#07091a',
417
+ fontFamily: "'JetBrains Mono',monospace",
418
+ color: '#c8cde8',
419
+ display: 'flex',
420
+ flexDirection: 'column',
421
+ opacity: loaded ? 1 : 0,
422
+ transition: 'opacity 0.3s',
423
+ }}
424
+ >
425
+ {/* Top bar */}
426
+ <div
427
+ style={{
428
+ display: 'flex',
429
+ alignItems: 'center',
430
+ gap: 10,
431
+ padding: '8px 18px',
432
+ borderBottom: '1px solid #0f1224',
433
+ background: '#080a1c',
434
+ flexShrink: 0,
435
+ }}
436
+ >
437
+ <div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
438
+ <div
439
+ style={{
440
+ width: 6,
441
+ height: 6,
442
+ borderRadius: '50%',
443
+ background: '#7c6af7',
444
+ boxShadow: '0 0 8px #7c6af7',
445
+ }}
446
+ />
447
+ <span
448
+ style={{ fontSize: 10, fontWeight: 700, color: '#e0e4f0', letterSpacing: '0.09em' }}
449
+ >
450
+ GRAPH VIEWER
451
+ </span>
452
+ </div>
453
+ {[
454
+ ['nodes', stats.nodeCount],
455
+ ['edges', stats.edgeCount],
456
+ ['clusters', stats.clusterCount],
457
+ ].map(([l, v]) => (
458
+ <div
459
+ key={l}
460
+ style={{
461
+ fontSize: 9,
462
+ color: '#3a4060',
463
+ background: '#0e1028',
464
+ borderRadius: 4,
465
+ padding: '2px 7px',
466
+ border: '1px solid #141830',
467
+ }}
468
+ >
469
+ <span style={{ color: '#6a70a0' }}>{v}</span> {l}
470
+ </div>
471
+ ))}
472
+ <div style={{ display: 'flex', gap: 2, marginLeft: 8 }}>
473
+ {[
474
+ ['clusters', '⬡ clusters'],
475
+ ['flat', '⊙ flat'],
476
+ ['architecture', '⬛ architecture'],
477
+ ].map(([v, lbl]) => (
478
+ <button
479
+ key={v}
480
+ onClick={() => {
481
+ setViewMode(v);
482
+ setSelectedId(null);
483
+ setAffectedIds([]);
484
+ }}
485
+ style={{
486
+ padding: '3px 10px',
487
+ fontSize: 9,
488
+ background: viewMode === v ? '#181b38' : 'transparent',
489
+ border: `1px solid ${viewMode === v ? '#7c6af7' : '#141830'}`,
490
+ borderRadius: 4,
491
+ color: viewMode === v ? '#c8cde8' : '#3a3f5c',
492
+ cursor: 'pointer',
493
+ fontFamily: 'inherit',
494
+ }}
495
+ >
496
+ {lbl}
497
+ </button>
498
+ ))}
499
+ </div>
500
+ <div style={{ marginLeft: 'auto', position: 'relative' }}>
501
+ <input
502
+ value={search}
503
+ onChange={(e) => handleSearch(e.target.value)}
504
+ placeholder="search name, path, export, tag..."
505
+ style={{
506
+ background: '#0c0e22',
507
+ border: '1px solid #141830',
508
+ color: '#c8cde8',
509
+ padding: '5px 12px 5px 26px',
510
+ borderRadius: 5,
511
+ fontSize: 9,
512
+ width: 230,
513
+ outline: 'none',
514
+ fontFamily: 'inherit',
515
+ }}
516
+ />
517
+ <span
518
+ style={{
519
+ position: 'absolute',
520
+ left: 8,
521
+ top: '50%',
522
+ transform: 'translateY(-50%)',
523
+ fontSize: 11,
524
+ color: '#3a3f5c',
525
+ }}
526
+ >
527
+
528
+ </span>
529
+ {search && (
530
+ <span
531
+ onClick={() => handleSearch('')}
532
+ style={{
533
+ position: 'absolute',
534
+ right: focusedIds.length > 0 ? 22 : 8,
535
+ top: '50%',
536
+ transform: 'translateY(-50%)',
537
+ fontSize: 10,
538
+ color: '#3a3f5c',
539
+ cursor: 'pointer',
540
+ lineHeight: 1,
541
+ }}
542
+ >
543
+ x
544
+ </span>
545
+ )}
546
+ {focusedIds.length > 0 && (
547
+ <span
548
+ style={{
549
+ position: 'absolute',
550
+ right: 8,
551
+ top: '50%',
552
+ transform: 'translateY(-50%)',
553
+ fontSize: 9,
554
+ color: '#7c6af7',
555
+ }}
556
+ >
557
+ {focusedIds.length}
558
+ </span>
559
+ )}
560
+ {semanticResults.length > 0 && (
561
+ <div
562
+ style={{
563
+ position: 'absolute',
564
+ top: '100%',
565
+ right: 0,
566
+ marginTop: 4,
567
+ width: 280,
568
+ background: '#0d0f22',
569
+ border: '1px solid #1a1f38',
570
+ borderRadius: 5,
571
+ zIndex: 100,
572
+ boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
573
+ overflow: 'hidden',
574
+ }}
575
+ >
576
+ <div style={{ padding: '4px 8px', borderBottom: '1px solid #1a1f38', fontSize: 8, color: '#3a3f5c', fontFamily: 'inherit' }}>
577
+ semantic matches
578
+ </div>
579
+ {semanticResults.map((r) => {
580
+ const node = graph?.nodes.find((n) => n.path === r.filePath || n.path.endsWith(r.filePath) || r.filePath.endsWith(n.path));
581
+ return (
582
+ <div
583
+ key={r.id}
584
+ onClick={() => { if (node) { handleSelect(node.id); setSemanticResults([]); setSearch(''); } }}
585
+ style={{
586
+ padding: '5px 8px',
587
+ cursor: node ? 'pointer' : 'default',
588
+ borderBottom: '1px solid #111428',
589
+ display: 'flex',
590
+ flexDirection: 'column',
591
+ gap: 2,
592
+ opacity: node ? 1 : 0.4,
593
+ }}
594
+ onMouseEnter={(e) => { if (node) e.currentTarget.style.background = '#131630'; }}
595
+ onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
596
+ >
597
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
598
+ <span style={{ fontSize: 9, color: '#c8cde8', fontFamily: "'JetBrains Mono',monospace" }}>{r.name}</span>
599
+ <span style={{ fontSize: 8, color: '#4a3f7a', fontFamily: 'inherit' }}>{(1 - r.score).toFixed(2)}</span>
600
+ </div>
601
+ <span style={{ fontSize: 8, color: '#3a3f5c', fontFamily: 'inherit', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
602
+ {r.filePath.split('/').slice(-2).join('/')}
603
+ </span>
604
+ </div>
605
+ );
606
+ })}
607
+ </div>
608
+ )}
609
+ </div>
610
+ <button
611
+ onClick={() => {
612
+ setGraph(null);
613
+ setSelectedId(null);
614
+ }}
615
+ style={{
616
+ background: 'none',
617
+ border: '1px solid #1a1f38',
618
+ borderRadius: 4,
619
+ color: '#3a3f5c',
620
+ fontSize: 8,
621
+ padding: '3px 8px',
622
+ cursor: 'pointer',
623
+ fontFamily: 'inherit',
624
+ letterSpacing: '0.06em',
625
+ }}
626
+ >
627
+ LOAD
628
+ </button>
629
+ <button
630
+ onClick={() => mappingRef.current.click()}
631
+ style={{
632
+ background: mapping ? '#0a1a0a' : 'none',
633
+ border: `1px solid ${mapping ? '#4ade80' : '#1a1f38'}`,
634
+ borderRadius: 4,
635
+ color: mapping ? '#4ade80' : '#3a3f5c',
636
+ fontSize: 8,
637
+ padding: '3px 8px',
638
+ cursor: 'pointer',
639
+ fontFamily: 'inherit',
640
+ letterSpacing: '0.06em',
641
+ }}
642
+ title="Load mapping.json"
643
+ >
644
+ {mapping ? '[x] MAP' : 'MAP'}
645
+ </button>
646
+ <button
647
+ onClick={() => specRef.current.click()}
648
+ style={{
649
+ background: Object.keys(specReqs).length ? '#0a0a1a' : 'none',
650
+ border: `1px solid ${Object.keys(specReqs).length ? '#7c6af7' : '#1a1f38'}`,
651
+ borderRadius: 4,
652
+ color: Object.keys(specReqs).length ? '#7c6af7' : '#3a3f5c',
653
+ fontSize: 8,
654
+ padding: '3px 8px',
655
+ cursor: 'pointer',
656
+ fontFamily: 'inherit',
657
+ letterSpacing: '0.06em',
658
+ }}
659
+ title="Load spec.md"
660
+ >
661
+ {Object.keys(specReqs).length ? '[x] SPEC' : 'SPEC'}
662
+ </button>
663
+ <button
664
+ onClick={() => setChatOpen((v) => !v)}
665
+ style={{
666
+ background: chatOpen ? '#1a1050' : 'none',
667
+ border: `1px solid ${chatOpen ? '#7c6af7' : '#1a1f38'}`,
668
+ borderRadius: 4,
669
+ color: chatOpen ? '#7c6af7' : '#3a3f5c',
670
+ fontSize: 8,
671
+ padding: '3px 8px',
672
+ cursor: 'pointer',
673
+ fontFamily: 'inherit',
674
+ letterSpacing: '0.06em',
675
+ }}
676
+ title="Toggle AI chat"
677
+ >
678
+ CHAT
679
+ </button>
680
+ <input
681
+ ref={mappingRef}
682
+ type="file"
683
+ accept=".json"
684
+ style={{ display: 'none' }}
685
+ onChange={(e) => {
686
+ const f = e.target.files[0];
687
+ if (f) {
688
+ const r = new FileReader();
689
+ r.onload = (ev) => loadMapping(ev.target.result);
690
+ r.readAsText(f);
691
+ }
692
+ }}
693
+ />
694
+ <input
695
+ ref={specRef}
696
+ type="file"
697
+ accept=".md"
698
+ style={{ display: 'none' }}
699
+ onChange={(e) => {
700
+ const f = e.target.files[0];
701
+ if (f) {
702
+ const r = new FileReader();
703
+ r.onload = (ev) => loadSpec(ev.target.result);
704
+ r.readAsText(f);
705
+ }
706
+ }}
707
+ />
708
+ </div>
709
+
710
+ {/* Filter bar */}
711
+ <FilterBar
712
+ filters={filters}
713
+ setFilters={setFilters}
714
+ stats={filterStats}
715
+ clusterNames={clusterNames}
716
+ />
717
+
718
+ {/* Body */}
719
+ <div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
720
+ {/* Canvas */}
721
+ <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
722
+ {viewMode === 'architecture' ? (
723
+ <ArchitectureView graph={graph} llmCtx={llmCtx} focusedIds={focusedIds} />
724
+ ) : viewMode === 'clusters' ? (
725
+ <ClusterGraph
726
+ clusters={graph.clusters.filter(
727
+ (cl) => !filters.cluster || cl.name === filters.cluster
728
+ )}
729
+ edges={visibleEdges}
730
+ nodes={visibleNodes}
731
+ allNodes={graph.nodes.filter(
732
+ (n) => !filters.cluster || n.cluster.name === filters.cluster
733
+ )}
734
+ expandedClusters={expandedClusters}
735
+ onToggle={toggleCluster}
736
+ onSelectNode={handleSelect}
737
+ onClear={clearSelection}
738
+ hasSelection={selectedId !== null || expandedClusters.size > 0}
739
+ selectedId={selectedId}
740
+ affectedIds={affectedIds}
741
+ linkedIds={linkedIds}
742
+ focusedIds={focusedIds}
743
+ />
744
+ ) : (
745
+ <FlatGraph
746
+ nodes={visibleNodes}
747
+ edges={visibleEdges}
748
+ selectedId={selectedId}
749
+ affectedIds={affectedIds}
750
+ focusedIds={focusedIds}
751
+ onSelect={handleSelect}
752
+ refactorOnly={filters.refactorOnly}
753
+ linkedIds={linkedIds}
754
+ />
755
+ )}
756
+ {!selectedId && (
757
+ <div
758
+ style={{
759
+ position: 'absolute',
760
+ bottom: 12,
761
+ left: '50%',
762
+ transform: 'translateX(-50%)',
763
+ fontSize: 9,
764
+ color: '#181c38',
765
+ letterSpacing: '0.1em',
766
+ pointerEvents: 'none',
767
+ whiteSpace: 'nowrap',
768
+ }}
769
+ >
770
+ {viewMode === 'clusters'
771
+ ? 'CLICK CLUSTER -> EXPAND · CLICK NODE -> INSPECT'
772
+ : 'CLICK NODE -> INSPECT'}
773
+ </div>
774
+ )}
775
+ </div>
776
+
777
+ {/* Chat panel */}
778
+ {chatOpen && (
779
+ <ChatPanel
780
+ onHighlight={(ids) => setFocusedIds(ids)}
781
+ onClose={() => { setChatOpen(false); setFocusedIds([]); }}
782
+ />
783
+ )}
784
+
785
+ {/* Side panel */}
786
+ <div
787
+ style={{
788
+ width: 282,
789
+ borderLeft: '1px solid #0f1224',
790
+ background: '#080b1e',
791
+ display: viewMode === 'architecture' ? 'none' : 'flex',
792
+ flexDirection: 'column',
793
+ overflow: 'hidden',
794
+ flexShrink: 0,
795
+ }}
796
+ >
797
+ <div style={{ display: 'flex', borderBottom: '1px solid #0f1224', flexShrink: 0 }}>
798
+ {['node', 'links', 'blast', 'spec', 'skeleton', 'info'].map((t) => (
799
+ <button
800
+ key={t}
801
+ onClick={() => setTab(t)}
802
+ style={{
803
+ flex: 1,
804
+ padding: '7px 0',
805
+ background: 'none',
806
+ border: 'none',
807
+ borderBottom: tab === t ? '2px solid #7c6af7' : '2px solid transparent',
808
+ color: tab === t ? '#c8cde8' : '#3a3f5c',
809
+ fontSize: 8,
810
+ letterSpacing: '0.06em',
811
+ fontWeight: 700,
812
+ cursor: 'pointer',
813
+ fontFamily: 'inherit',
814
+ textTransform: 'uppercase',
815
+ }}
816
+ >
817
+ {t}
818
+ </button>
819
+ ))}
820
+ </div>
821
+
822
+ <div style={{ flex: 1, overflow: 'auto', padding: 13 }}>
823
+ {/* NODE */}
824
+ {tab === 'node' && !selectedNode && <Hint>Select a node to inspect it.</Hint>}
825
+ {tab === 'node' && selectedNode && (
826
+ <div>
827
+ <div style={{ fontSize: 12, fontWeight: 700, color: '#e0e4f0', marginBottom: 2 }}>
828
+ {selectedNode.label}
829
+ </div>
830
+ <div
831
+ style={{
832
+ fontSize: 8,
833
+ color: '#3a3f5c',
834
+ marginBottom: 9,
835
+ wordBreak: 'break-all',
836
+ lineHeight: 1.7,
837
+ }}
838
+ >
839
+ {selectedNode.path}
840
+ </div>
841
+ <Row
842
+ label="ext"
843
+ value={<Chip color={extColor(selectedNode.ext)}>{selectedNode.ext || '--'}</Chip>}
844
+ />
845
+ <Row label="lines" value={selectedNode.lines} />
846
+ <Row label="size" value={`${(selectedNode.size / 1024).toFixed(1)} KB`} />
847
+ <Row
848
+ label="score"
849
+ value={
850
+ <span style={{ color: '#7c6af7', fontWeight: 700 }}>{selectedNode.score}</span>
851
+ }
852
+ />
853
+ <Row
854
+ label="cluster"
855
+ value={
856
+ <Chip color={selectedNode.cluster.color}>{selectedNode.cluster.name}</Chip>
857
+ }
858
+ />
859
+ <div style={{ display: 'flex', gap: 4, marginTop: 8, flexWrap: 'wrap' }}>
860
+ {selectedNode.isEntry && <Chip color="#f77c6a">entry-point</Chip>}
861
+ {selectedNode.isConfig && <Chip color="#f5c518">config</Chip>}
862
+ {selectedNode.isTest && <Chip color="#3ecfcf">test</Chip>}
863
+ {selectedNode.tags.map((t) => (
864
+ <Chip key={t} color="#4a5070">
865
+ {t}
866
+ </Chip>
867
+ ))}
868
+ </div>
869
+ {selectedNode.exports.length > 0 && (
870
+ <>
871
+ <SL>Exports ({selectedNode.exports.length})</SL>
872
+ {selectedNode.exports.map((ex, i) => (
873
+ <div
874
+ key={i}
875
+ style={{
876
+ display: 'flex',
877
+ gap: 5,
878
+ alignItems: 'center',
879
+ padding: '3px 0',
880
+ borderBottom: '1px solid #0f1228',
881
+ }}
882
+ >
883
+ <KindBadge kind={ex.kind} />
884
+ <span style={{ fontSize: 9, color: '#8890b0' }}>{ex.name}</span>
885
+ <span style={{ marginLeft: 'auto', fontSize: 8, color: '#2a2f4a' }}>
886
+ L{ex.line}
887
+ </span>
888
+ </div>
889
+ ))}
890
+ </>
891
+ )}
892
+ <SL>Metrics</SL>
893
+ {[
894
+ ['inDegree', '↙'],
895
+ ['outDegree', '↗'],
896
+ ['pageRank', 'PR'],
897
+ ['betweenness', '⋈'],
898
+ ].map(([k, s]) => (
899
+ <Row
900
+ key={k}
901
+ label={`${s} ${k}`}
902
+ value={
903
+ typeof selectedNode.metrics[k] === 'number'
904
+ ? selectedNode.metrics[k].toFixed(3)
905
+ : '-'
906
+ }
907
+ />
908
+ ))}
909
+ {selectedNode.refactor && (
910
+ <>
911
+ <SL>Refactor</SL>
912
+ <Row label="Functions affected" value={selectedNode.refactor.functions} />
913
+ <Row
914
+ label="Max priority"
915
+ value={
916
+ <span
917
+ style={{
918
+ color: selectedNode.refactor.maxPriority >= 5 ? '#f97373' : '#fbbf24',
919
+ fontWeight: 700,
920
+ }}
921
+ >
922
+ {selectedNode.refactor.maxPriority.toFixed(1)}
923
+ </span>
924
+ }
925
+ />
926
+ <div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
927
+ {selectedNode.refactor.issues.map((iss) => (
928
+ <Chip key={iss} color="#f97373">
929
+ {iss.replace(/_/g, ' ')}
930
+ </Chip>
931
+ ))}
932
+ </div>
933
+ </>
934
+ )}
935
+ </div>
936
+ )}
937
+
938
+ {/* LINKS */}
939
+ {tab === 'links' && !selectedId && (
940
+ <Hint>Select a node to see its direct imports/exports.</Hint>
941
+ )}
942
+ {tab === 'links' && selectedId && (
943
+ <div>
944
+ {(() => {
945
+ const outEdges = selectedEdges.filter((e) => e.source === selectedId);
946
+ const inEdges = selectedEdges.filter((e) => e.target === selectedId);
947
+ return (
948
+ <>
949
+ <SL>Imports ({outEdges.length})</SL>
950
+ {outEdges.length === 0 && (
951
+ <div style={{ color: '#2a2f4a', fontSize: 9 }}>No imports.</div>
952
+ )}
953
+ {outEdges.map((e, i) => {
954
+ const tn = graph.nodes.find((n) => n.id === e.target);
955
+ return (
956
+ <div
957
+ key={i}
958
+ onClick={() => handleSelect(e.target)}
959
+ style={{
960
+ padding: '5px 7px',
961
+ marginBottom: 3,
962
+ background: '#0c0e20',
963
+ borderRadius: 4,
964
+ border: `1px solid ${tn?.cluster.color || '#141830'}22`,
965
+ cursor: 'pointer',
966
+ }}
967
+ >
968
+ <div
969
+ style={{
970
+ display: 'flex',
971
+ alignItems: 'center',
972
+ gap: 5,
973
+ marginBottom: e.importedNames.length ? 3 : 0,
974
+ }}
975
+ >
976
+ <span style={{ fontSize: 8, color: extColor(tn?.ext || '') }}>↗</span>
977
+ <span style={{ fontSize: 9, color: '#c8cde8' }}>
978
+ {tn?.label || e.target}
979
+ </span>
980
+ {e.isType && (
981
+ <span style={{ fontSize: 7, color: '#3a3f6a', marginLeft: 'auto' }}>
982
+ type
983
+ </span>
984
+ )}
985
+ </div>
986
+ {e.importedNames.length > 0 && (
987
+ <div style={{ fontSize: 7.5, color: '#3a4060', paddingLeft: 12 }}>
988
+ {e.importedNames.join(', ')}
989
+ </div>
990
+ )}
991
+ </div>
992
+ );
993
+ })}
994
+ <SL>Imported by ({inEdges.length})</SL>
995
+ {inEdges.length === 0 && (
996
+ <div style={{ color: '#2a2f4a', fontSize: 9 }}>
997
+ Not imported by any visible files.
998
+ </div>
999
+ )}
1000
+ {inEdges.map((e, i) => {
1001
+ const sn = graph.nodes.find((n) => n.id === e.source);
1002
+ return (
1003
+ <div
1004
+ key={i}
1005
+ onClick={() => handleSelect(e.source)}
1006
+ style={{
1007
+ padding: '5px 7px',
1008
+ marginBottom: 3,
1009
+ background: '#0c0e20',
1010
+ borderRadius: 4,
1011
+ border: `1px solid ${sn?.cluster.color || '#141830'}22`,
1012
+ cursor: 'pointer',
1013
+ }}
1014
+ >
1015
+ <div
1016
+ style={{
1017
+ display: 'flex',
1018
+ alignItems: 'center',
1019
+ gap: 5,
1020
+ marginBottom: e.importedNames.length ? 3 : 0,
1021
+ }}
1022
+ >
1023
+ <span style={{ fontSize: 8, color: '#7c6af7' }}>↙</span>
1024
+ <span style={{ fontSize: 9, color: '#c8cde8' }}>
1025
+ {sn?.label || e.source}
1026
+ </span>
1027
+ {e.isType && (
1028
+ <span style={{ fontSize: 7, color: '#3a3f6a', marginLeft: 'auto' }}>
1029
+ type
1030
+ </span>
1031
+ )}
1032
+ </div>
1033
+ {e.importedNames.length > 0 && (
1034
+ <div style={{ fontSize: 7.5, color: '#3a4060', paddingLeft: 12 }}>
1035
+ {e.importedNames.join(', ')}
1036
+ </div>
1037
+ )}
1038
+ </div>
1039
+ );
1040
+ })}
1041
+ </>
1042
+ );
1043
+ })()}
1044
+ </div>
1045
+ )}
1046
+
1047
+ {/* BLAST */}
1048
+ {tab === 'blast' && !selectedId && (
1049
+ <Hint>Select a node to compute downstream impact.</Hint>
1050
+ )}
1051
+ {tab === 'blast' && selectedId && (
1052
+ <div>
1053
+ <div style={{ fontSize: 9, color: '#8890b0', marginBottom: 10 }}>
1054
+ Modifying <span style={{ color: '#7c6af7' }}>{selectedNode?.label}</span> impacts:
1055
+ </div>
1056
+ {affectedIds.length === 0 ? (
1057
+ <div style={{ color: '#2a2f4a', fontSize: 9 }}>No visible downstream nodes.</div>
1058
+ ) : (
1059
+ affectedIds.map((id) => {
1060
+ const n = graph.nodes.find((x) => x.id === id);
1061
+ return (
1062
+ <div
1063
+ key={id}
1064
+ onClick={() => handleSelect(id)}
1065
+ style={{
1066
+ display: 'flex',
1067
+ alignItems: 'center',
1068
+ gap: 6,
1069
+ padding: '4px 7px',
1070
+ marginBottom: 3,
1071
+ background: '#0c0e20',
1072
+ borderRadius: 4,
1073
+ border: '1px solid #141830',
1074
+ cursor: 'pointer',
1075
+ }}
1076
+ >
1077
+ <span style={{ fontSize: 8, color: extColor(n?.ext || '') }}>
1078
+ {n?.ext || '?'}
1079
+ </span>
1080
+ <span
1081
+ style={{
1082
+ fontSize: 9,
1083
+ color: '#c8cde8',
1084
+ flex: 1,
1085
+ overflow: 'hidden',
1086
+ textOverflow: 'ellipsis',
1087
+ whiteSpace: 'nowrap',
1088
+ }}
1089
+ >
1090
+ {n?.label || id}
1091
+ </span>
1092
+ <span style={{ fontSize: 7, color: `${n?.cluster.color || '#3a3f5c'}80` }}>
1093
+ {n?.cluster.name.split('/').pop()}
1094
+ </span>
1095
+ </div>
1096
+ );
1097
+ })
1098
+ )}
1099
+ <div
1100
+ style={{
1101
+ marginTop: 10,
1102
+ padding: '8px 10px',
1103
+ background: '#0c0e20',
1104
+ borderRadius: 5,
1105
+ border: '1px solid #1a1f38',
1106
+ }}
1107
+ >
1108
+ <div style={{ fontSize: 8, color: '#3a3f5c', marginBottom: 2 }}>BLAST RADIUS</div>
1109
+ <div
1110
+ style={{
1111
+ fontSize: 22,
1112
+ fontWeight: 700,
1113
+ color:
1114
+ affectedIds.length > 8
1115
+ ? '#f77c6a'
1116
+ : affectedIds.length > 3
1117
+ ? '#f7c76a'
1118
+ : '#7c6af7',
1119
+ }}
1120
+ >
1121
+ {affectedIds.length}{' '}
1122
+ <span style={{ fontSize: 10, fontWeight: 400, color: '#3a3f5c' }}>nodes</span>
1123
+ </div>
1124
+ </div>
1125
+ </div>
1126
+ )}
1127
+
1128
+ {/* SPEC */}
1129
+ {tab === 'spec' && !mapping && (
1130
+ <Hint>
1131
+ Load a <code style={{ color: '#7c6af7' }}>mapping.json</code> and{' '}
1132
+ <code style={{ color: '#7c6af7' }}>spec.md</code> using the MAP / SPEC buttons in
1133
+ the top bar.
1134
+ </Hint>
1135
+ )}
1136
+ {tab === 'spec' && mapping && !selectedId && (
1137
+ <Hint>Select a node to see its linked spec requirements.</Hint>
1138
+ )}
1139
+ {tab === 'spec' &&
1140
+ mapping &&
1141
+ selectedId &&
1142
+ (() => {
1143
+ const nodePath = normalizePath(selectedNode?.path || selectedId);
1144
+ const entries = [];
1145
+ for (const [k, list] of Object.entries(mapping)) {
1146
+ if (nodePath.endsWith(k) || k.endsWith(nodePath) || nodePath === k) {
1147
+ entries.push(...list);
1148
+ }
1149
+ }
1150
+ const seen = new Set();
1151
+ const unique = entries.filter((e) => {
1152
+ const key = e.requirement;
1153
+ if (seen.has(key)) return false;
1154
+ seen.add(key);
1155
+ return true;
1156
+ });
1157
+
1158
+ if (unique.length === 0)
1159
+ return <Hint>No spec requirements mapped to this file.</Hint>;
1160
+
1161
+ const confidenceColor = (c) => (c === 'llm' ? '#4ade80' : '#3a3f5c');
1162
+
1163
+ return (
1164
+ <div>
1165
+ <div style={{ fontSize: 8, color: '#3a3f5c', marginBottom: 8 }}>
1166
+ {unique.length} requirement{unique.length > 1 ? 's' : ''} linked
1167
+ </div>
1168
+ {unique.map((entry, i) => {
1169
+ const req = specReqs ? specReqs[entry.requirement] : null;
1170
+ const domainColor =
1171
+ {
1172
+ llm: '#3ecfcf',
1173
+ task: '#f7c76a',
1174
+ project: '#6af7a0',
1175
+ openspec: '#7c6af7',
1176
+ }[entry.domain] || '#64748b';
1177
+ return (
1178
+ <div
1179
+ key={i}
1180
+ style={{
1181
+ marginBottom: 10,
1182
+ background: '#0b0d1f',
1183
+ borderRadius: 5,
1184
+ border: '1px solid #141830',
1185
+ overflow: 'hidden',
1186
+ }}
1187
+ >
1188
+ <div
1189
+ style={{
1190
+ padding: '6px 9px',
1191
+ borderBottom: '1px solid #0f1224',
1192
+ display: 'flex',
1193
+ alignItems: 'center',
1194
+ gap: 5,
1195
+ flexWrap: 'wrap',
1196
+ }}
1197
+ >
1198
+ <span
1199
+ style={{ fontSize: 9, fontWeight: 700, color: '#c8cde8', flex: 1 }}
1200
+ >
1201
+ {entry.requirement}
1202
+ </span>
1203
+ <span
1204
+ style={{
1205
+ fontSize: 7,
1206
+ padding: '1px 5px',
1207
+ borderRadius: 3,
1208
+ background: `${domainColor}18`,
1209
+ color: domainColor,
1210
+ border: `1px solid ${domainColor}30`,
1211
+ }}
1212
+ >
1213
+ {entry.domain}
1214
+ </span>
1215
+ <span
1216
+ style={{ fontSize: 7, color: confidenceColor(entry.confidence) }}
1217
+ title={`confidence: ${entry.confidence}`}
1218
+ >
1219
+ {entry.confidence === 'llm' ? '● llm' : '◌ heuristic'}
1220
+ </span>
1221
+ </div>
1222
+ {req?.body ? (
1223
+ <div
1224
+ style={{
1225
+ padding: '7px 9px',
1226
+ fontSize: 8.5,
1227
+ color: '#8890b0',
1228
+ lineHeight: 1.7,
1229
+ maxHeight: 200,
1230
+ overflow: 'auto',
1231
+ }}
1232
+ >
1233
+ {req.body.split('\n').map((line, li) => {
1234
+ if (line.startsWith('####'))
1235
+ return (
1236
+ <div
1237
+ key={li}
1238
+ style={{
1239
+ color: '#5a6090',
1240
+ fontWeight: 700,
1241
+ marginTop: 6,
1242
+ fontSize: 8,
1243
+ }}
1244
+ >
1245
+ {line.replace(/^#+\s*/, '')}
1246
+ </div>
1247
+ );
1248
+ if (line.startsWith('- **'))
1249
+ return (
1250
+ <div key={li} style={{ paddingLeft: 6, color: '#6a709a' }}>
1251
+ {line.replace(/\*\*/g, '')}
1252
+ </div>
1253
+ );
1254
+ if (line.trim() === '')
1255
+ return <div key={li} style={{ height: 4 }} />;
1256
+ return <div key={li}>{line}</div>;
1257
+ })}
1258
+ </div>
1259
+ ) : (
1260
+ <div style={{ padding: '7px 9px', fontSize: 8, color: '#2a2f4a' }}>
1261
+ {req
1262
+ ? 'Requirement title mismatch -- spec section not found in the spec file.'
1263
+ : <>Spec not loaded -- run <code style={{ color: '#7c6af7' }}>spec-gen view</code> or load <code style={{ color: '#7c6af7' }}>spec.md</code> manually.</>}
1264
+ </div>
1265
+ )}
1266
+ <div
1267
+ style={{
1268
+ padding: '4px 9px',
1269
+ borderTop: '1px solid #0f1224',
1270
+ fontSize: 7.5,
1271
+ color: '#2a3060',
1272
+ }}
1273
+ >
1274
+ service: <span style={{ color: '#3a4080' }}>{entry.service}</span>
1275
+ </div>
1276
+ </div>
1277
+ );
1278
+ })}
1279
+ </div>
1280
+ );
1281
+ })()}
1282
+
1283
+ {/* SKELETON */}
1284
+ {tab === 'skeleton' && !selectedNode && (
1285
+ <Hint>Select a node to view its code skeleton.</Hint>
1286
+ )}
1287
+ {tab === 'skeleton' && selectedNode && (
1288
+ <div>
1289
+ {skeletonLoading && <Hint>Loading...</Hint>}
1290
+ {!skeletonLoading && !skeletonData && <Hint>Skeleton unavailable for this file.</Hint>}
1291
+ {!skeletonLoading && skeletonData && (
1292
+ <div>
1293
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
1294
+ <span style={{ fontSize: 9, color: '#6a70a0', fontFamily: 'inherit' }}>
1295
+ {skeletonData.language} · {skeletonData.skeletonLines}/{skeletonData.originalLines} lines
1296
+ </span>
1297
+ <span style={{ fontSize: 9, color: skeletonData.reductionPct >= 20 ? '#7c6af7' : '#3a3f5c', fontFamily: 'inherit' }}>
1298
+ -{skeletonData.reductionPct}%
1299
+ </span>
1300
+ </div>
1301
+ <pre style={{
1302
+ margin: 0,
1303
+ fontSize: 8,
1304
+ lineHeight: 1.6,
1305
+ color: '#9aa0c8',
1306
+ fontFamily: "'JetBrains Mono', monospace",
1307
+ whiteSpace: 'pre-wrap',
1308
+ wordBreak: 'break-word',
1309
+ background: '#060819',
1310
+ border: '1px solid #0f1224',
1311
+ borderRadius: 4,
1312
+ padding: '8px 10px',
1313
+ }}>
1314
+ {skeletonData.skeleton}
1315
+ </pre>
1316
+ </div>
1317
+ )}
1318
+ </div>
1319
+ )}
1320
+
1321
+ {/* INFO */}
1322
+ {tab === 'info' && (
1323
+ <div>
1324
+ <SL>Statistics</SL>
1325
+ {[
1326
+ ['Nodes', stats.nodeCount],
1327
+ ['Edges', stats.edgeCount],
1328
+ ['Clusters', stats.clusterCount],
1329
+ ['Cycles', stats.cycleCount],
1330
+ ['Avg degree', stats.avgDegree?.toFixed(2)],
1331
+ ['Density', stats.density?.toFixed(4)],
1332
+ ].map(([l, v]) => (
1333
+ <Row key={l} label={l} value={v ?? '-'} />
1334
+ ))}
1335
+ <SL>Active filters</SL>
1336
+ <Row
1337
+ label="Visible nodes"
1338
+ value={<span style={{ color: '#7c6af7' }}>{filterStats.visible}</span>}
1339
+ />
1340
+ <Row
1341
+ label="Visible edges"
1342
+ value={<span style={{ color: '#3ecfcf' }}>{filterStats.visibleEdges}</span>}
1343
+ />
1344
+ <Row label="Orphans" value={filterStats.orphanCount} />
1345
+ <SL>Top 10 by score</SL>
1346
+ {(graph.rankings.byImportance || []).slice(0, 10).map((fid, i) => {
1347
+ const n = graph.nodes.find((x) => x.id === fid);
1348
+ if (!n) return null;
1349
+ return (
1350
+ <div
1351
+ key={fid}
1352
+ onClick={() => handleSelect(fid)}
1353
+ style={{
1354
+ display: 'flex',
1355
+ gap: 5,
1356
+ alignItems: 'center',
1357
+ padding: '3px 0',
1358
+ cursor: 'pointer',
1359
+ }}
1360
+ >
1361
+ <span style={{ fontSize: 8, color: '#2a2f4a', minWidth: 12 }}>{i + 1}</span>
1362
+ <span style={{ fontSize: 8, color: extColor(n.ext) }}>{n.ext || '--'}</span>
1363
+ <span
1364
+ style={{
1365
+ fontSize: 9,
1366
+ color: '#8890b0',
1367
+ flex: 1,
1368
+ overflow: 'hidden',
1369
+ textOverflow: 'ellipsis',
1370
+ whiteSpace: 'nowrap',
1371
+ }}
1372
+ >
1373
+ {n.label}
1374
+ </span>
1375
+ <span style={{ fontSize: 9, color: '#7c6af7' }}>{n.score}</span>
1376
+ </div>
1377
+ );
1378
+ })}
1379
+ </div>
1380
+ )}
1381
+ </div>
1382
+
1383
+ {/* Cluster legend */}
1384
+ <div style={{ padding: '9px 13px', borderTop: '1px solid #0f1224', flexShrink: 0 }}>
1385
+ <div style={{ display: 'flex', gap: 12, marginBottom: 8 }}>
1386
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
1387
+ <svg width="24" height="8" style={{ overflow: 'visible' }}>
1388
+ <line
1389
+ x1="0"
1390
+ y1="4"
1391
+ x2="18"
1392
+ y2="4"
1393
+ stroke="#5a6090"
1394
+ strokeWidth="1.5"
1395
+ markerEnd="url(#arr-legend)"
1396
+ />
1397
+ <defs>
1398
+ <marker
1399
+ id="arr-legend"
1400
+ markerWidth="5"
1401
+ markerHeight="5"
1402
+ refX="4"
1403
+ refY="2.5"
1404
+ orient="auto"
1405
+ >
1406
+ <path d="M0,0 L0,5 L5,2.5z" fill="#5a6090" />
1407
+ </marker>
1408
+ </defs>
1409
+ </svg>
1410
+ <span style={{ fontSize: 7.5, color: '#3a3f5c' }}>runtime import</span>
1411
+ </div>
1412
+ <div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
1413
+ <svg width="24" height="8" style={{ overflow: 'visible' }}>
1414
+ <line
1415
+ x1="0"
1416
+ y1="4"
1417
+ x2="18"
1418
+ y2="4"
1419
+ stroke="#3a3f5c"
1420
+ strokeWidth="1.2"
1421
+ strokeDasharray="3 2"
1422
+ markerEnd="url(#arr-legend-type)"
1423
+ />
1424
+ <defs>
1425
+ <marker
1426
+ id="arr-legend-type"
1427
+ markerWidth="5"
1428
+ markerHeight="5"
1429
+ refX="4"
1430
+ refY="2.5"
1431
+ orient="auto"
1432
+ >
1433
+ <path d="M0,0 L0,5 L5,2.5z" fill="#3a3f5c" />
1434
+ </marker>
1435
+ </defs>
1436
+ </svg>
1437
+ <span style={{ fontSize: 7.5, color: '#3a3f5c' }}>type-only</span>
1438
+ </div>
1439
+ </div>
1440
+ <div
1441
+ style={{ fontSize: 8, color: '#1e2240', letterSpacing: '0.08em', marginBottom: 5 }}
1442
+ >
1443
+ CLUSTERS · click to filter
1444
+ </div>
1445
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
1446
+ {graph.clusters.map((cl) => (
1447
+ <div
1448
+ key={cl.id}
1449
+ onClick={() =>
1450
+ setFilters((f) => ({ ...f, cluster: f.cluster === cl.name ? '' : cl.name }))
1451
+ }
1452
+ style={{
1453
+ display: 'flex',
1454
+ alignItems: 'center',
1455
+ gap: 3,
1456
+ cursor: 'pointer',
1457
+ opacity: filters.cluster && filters.cluster !== cl.name ? 0.25 : 1,
1458
+ transition: 'opacity 0.15s',
1459
+ }}
1460
+ >
1461
+ <div
1462
+ style={{
1463
+ width: 5,
1464
+ height: 5,
1465
+ borderRadius: '50%',
1466
+ background: cl.color,
1467
+ boxShadow: filters.cluster === cl.name ? `0 0 5px ${cl.color}` : 'none',
1468
+ }}
1469
+ />
1470
+ <span
1471
+ style={{
1472
+ fontSize: 7.5,
1473
+ color: filters.cluster === cl.name ? cl.color : '#3a3f5c',
1474
+ }}
1475
+ >
1476
+ {cl.name}
1477
+ </span>
1478
+ </div>
1479
+ ))}
1480
+ </div>
1481
+ </div>
1482
+ </div>
1483
+ </div>
1484
+ </div>
1485
+ );
1486
+ }