@leanspec/ui 0.2.14 → 0.2.15-dev.21025278490

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 (150) hide show
  1. package/bin/leanspec-ui.js +191 -0
  2. package/dist/assets/_baseUniq-CRqreL7N.js +1 -0
  3. package/dist/assets/arc-DMhx9AJT.js +1 -0
  4. package/dist/assets/architectureDiagram-VXUJARFQ-DM0L0YzO.js +36 -0
  5. package/dist/assets/blockDiagram-VD42YOAC-DHQXDHsD.js +122 -0
  6. package/dist/assets/c4Diagram-YG6GDRKO-0L7o2gpH.js +10 -0
  7. package/dist/assets/channel-2tOl0nAZ.js +1 -0
  8. package/dist/assets/chunk-4BX2VUAB-CwFT-Uaj.js +1 -0
  9. package/dist/assets/chunk-55IACEB6-CjvuUHHG.js +1 -0
  10. package/dist/assets/chunk-B4BG7PRW-BRJBysMK.js +165 -0
  11. package/dist/assets/chunk-DI55MBZ5-BnNEeoaA.js +220 -0
  12. package/dist/assets/chunk-FMBD7UC4-BK2l30pm.js +15 -0
  13. package/dist/assets/chunk-QN33PNHL-BN_cZkCU.js +1 -0
  14. package/dist/assets/chunk-QZHKN3VN-Brc3Yrub.js +1 -0
  15. package/dist/assets/chunk-TZMSLE5B-D2zzpLfO.js +1 -0
  16. package/dist/assets/classDiagram-2ON5EDUG-BB9CSNmS.js +1 -0
  17. package/dist/assets/classDiagram-v2-WZHVMYZB-BB9CSNmS.js +1 -0
  18. package/dist/assets/clone-BjxVFtyI.js +1 -0
  19. package/dist/assets/core-DV6XEvTN.js +1 -0
  20. package/dist/assets/cose-bilkent-S5V4N54A-CLJgM3XR.js +1 -0
  21. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  22. package/dist/assets/dagre-6UL2VRFP-_IFvBJKJ.js +4 -0
  23. package/dist/assets/diagram-PSM6KHXK--83HIYSQ.js +24 -0
  24. package/dist/assets/diagram-QEK2KX5R-6jAWnCnZ.js +43 -0
  25. package/dist/assets/diagram-S2PKOQOG-D5pwHvjZ.js +24 -0
  26. package/dist/assets/erDiagram-Q2GNP2WA-B4FV3mTd.js +60 -0
  27. package/dist/assets/flowDiagram-NV44I4VS-mtD2kF4M.js +162 -0
  28. package/dist/assets/ganttDiagram-JELNMOA3-BKALgqTK.js +267 -0
  29. package/dist/assets/gitGraphDiagram-NY62KEGX-Bd7r0pAf.js +65 -0
  30. package/dist/assets/graph-B2rEI7cK.js +1 -0
  31. package/dist/assets/index-Bekv_o1t.css +1 -0
  32. package/dist/assets/index-DSRxU-E5.js +389 -0
  33. package/dist/assets/infoDiagram-WHAUD3N6--nJOBKqh.js +2 -0
  34. package/dist/assets/journeyDiagram-XKPGCS4Q-BzGutKN3.js +139 -0
  35. package/dist/assets/kanban-definition-3W4ZIXB7-DyQO17vq.js +89 -0
  36. package/dist/assets/katex-XbL3y5x-.js +261 -0
  37. package/dist/assets/layout-iCSHU015.js +1 -0
  38. package/dist/assets/min-BK_AIJdo.js +1 -0
  39. package/dist/assets/mindmap-definition-VGOIOE7T-BZMj_6zo.js +68 -0
  40. package/dist/assets/pieDiagram-ADFJNKIX-CkAGsq9p.js +30 -0
  41. package/dist/assets/quadrantDiagram-AYHSOK5B-CWa93px1.js +7 -0
  42. package/dist/assets/requirementDiagram-UZGBJVZJ-CufFVR8c.js +64 -0
  43. package/dist/assets/sankeyDiagram-TZEHDZUN-BEPgVgU4.js +10 -0
  44. package/dist/assets/sequenceDiagram-WL72ISMW-BkdBWhel.js +145 -0
  45. package/dist/assets/stateDiagram-FKZM4ZOC-D5T73yx0.js +1 -0
  46. package/dist/assets/stateDiagram-v2-4FDKWEC3-9hJWG2n6.js +1 -0
  47. package/dist/assets/timeline-definition-IT6M3QCI-CX7kTdU2.js +61 -0
  48. package/dist/assets/treemap-KMMF4GRG-ftWCQ9lJ.js +128 -0
  49. package/dist/assets/xychartDiagram-PRI3JC2R-Ngrels4n.js +7 -0
  50. package/{index.html → dist/index.html} +2 -1
  51. package/package.json +12 -2
  52. package/eslint.config.js +0 -23
  53. package/package.json.backup +0 -83
  54. package/postcss.config.js +0 -6
  55. package/src/App.css +0 -42
  56. package/src/App.tsx +0 -17
  57. package/src/assets/react.svg +0 -1
  58. package/src/components/LanguageSwitcher.tsx +0 -67
  59. package/src/components/Layout.tsx +0 -88
  60. package/src/components/MainSidebar.tsx +0 -163
  61. package/src/components/MermaidDiagram.tsx +0 -85
  62. package/src/components/MinimalLayout.tsx +0 -51
  63. package/src/components/Navigation.tsx +0 -254
  64. package/src/components/PriorityBadge.tsx +0 -59
  65. package/src/components/ProjectSwitcher.tsx +0 -222
  66. package/src/components/QuickSearch.tsx +0 -225
  67. package/src/components/RootRedirect.tsx +0 -40
  68. package/src/components/SpecDetailLayout.context.ts +0 -10
  69. package/src/components/SpecDetailLayout.tsx +0 -14
  70. package/src/components/SpecsNavSidebar.tsx +0 -615
  71. package/src/components/StatusBadge.tsx +0 -59
  72. package/src/components/ThemeToggle.tsx +0 -25
  73. package/src/components/Tooltip.tsx +0 -29
  74. package/src/components/context/ContextClient.tsx +0 -471
  75. package/src/components/context/ContextFileDetail.tsx +0 -163
  76. package/src/components/dashboard/ActivityItem.tsx +0 -36
  77. package/src/components/dashboard/DashboardClient.tsx +0 -218
  78. package/src/components/dashboard/SpecListItem.tsx +0 -58
  79. package/src/components/dashboard/StatCard.tsx +0 -52
  80. package/src/components/dependencies/SpecNode.tsx +0 -128
  81. package/src/components/dependencies/SpecSidebar.tsx +0 -256
  82. package/src/components/dependencies/constants.ts +0 -25
  83. package/src/components/dependencies/types.ts +0 -38
  84. package/src/components/dependencies/utils.ts +0 -261
  85. package/src/components/metadata-editors/PriorityEditor.tsx +0 -89
  86. package/src/components/metadata-editors/StatusEditor.tsx +0 -85
  87. package/src/components/metadata-editors/TagsEditor.tsx +0 -207
  88. package/src/components/projects/CreateProjectDialog.tsx +0 -162
  89. package/src/components/projects/DirectoryPicker.tsx +0 -182
  90. package/src/components/shared/BackToTop.tsx +0 -39
  91. package/src/components/shared/ColorPicker.tsx +0 -68
  92. package/src/components/shared/EmptyState.tsx +0 -35
  93. package/src/components/shared/ErrorBoundary.tsx +0 -79
  94. package/src/components/shared/PageHeader.tsx +0 -23
  95. package/src/components/shared/PageTransition.tsx +0 -40
  96. package/src/components/shared/ProjectAvatar.tsx +0 -107
  97. package/src/components/shared/Skeletons.tsx +0 -184
  98. package/src/components/spec-detail/EditableMetadata.tsx +0 -129
  99. package/src/components/spec-detail/MarkdownRenderer.tsx +0 -47
  100. package/src/components/spec-detail/TableOfContents.tsx +0 -150
  101. package/src/components/specs/BoardView.tsx +0 -204
  102. package/src/components/specs/ListView.tsx +0 -62
  103. package/src/components/specs/SpecsFilters.tsx +0 -190
  104. package/src/contexts/KeyboardShortcutsContext.tsx +0 -95
  105. package/src/contexts/LayoutContext.tsx +0 -45
  106. package/src/contexts/ProjectContext.tsx +0 -163
  107. package/src/contexts/ThemeContext.tsx +0 -90
  108. package/src/contexts/index.ts +0 -7
  109. package/src/hooks/useKeyboardShortcuts.ts +0 -87
  110. package/src/index.css +0 -624
  111. package/src/lib/api.ts +0 -72
  112. package/src/lib/backend-adapter.ts +0 -382
  113. package/src/lib/date-utils.ts +0 -122
  114. package/src/lib/i18n.test.ts +0 -57
  115. package/src/lib/i18n.ts +0 -51
  116. package/src/lib/markdown-utils.ts +0 -38
  117. package/src/lib/sub-spec-utils.ts +0 -166
  118. package/src/lib/utils.ts +0 -6
  119. package/src/locales/en/common.json +0 -660
  120. package/src/locales/en/errors.json +0 -20
  121. package/src/locales/en/help.json +0 -8
  122. package/src/locales/zh-CN/common.json +0 -660
  123. package/src/locales/zh-CN/errors.json +0 -20
  124. package/src/locales/zh-CN/help.json +0 -8
  125. package/src/main.tsx +0 -12
  126. package/src/pages/ContextPage.tsx +0 -111
  127. package/src/pages/DashboardPage.tsx +0 -97
  128. package/src/pages/DependenciesPage.tsx +0 -881
  129. package/src/pages/ProjectsPage.tsx +0 -432
  130. package/src/pages/SpecDetailPage.tsx +0 -592
  131. package/src/pages/SpecsPage.tsx +0 -319
  132. package/src/pages/StatsPage.tsx +0 -307
  133. package/src/router/projectRoutes.tsx +0 -36
  134. package/src/router.tsx +0 -33
  135. package/src/test/setup.ts +0 -39
  136. package/src/types/api.ts +0 -185
  137. package/tailwind.config.ts +0 -57
  138. package/tsconfig.app.json +0 -29
  139. package/tsconfig.json +0 -7
  140. package/tsconfig.node.json +0 -26
  141. package/tsconfig.tsbuildinfo +0 -1
  142. package/vite.config.ts +0 -27
  143. package/vitest.config.ts +0 -18
  144. /package/{public → dist}/favicon.ico +0 -0
  145. /package/{public → dist}/github-mark-white.svg +0 -0
  146. /package/{public → dist}/github-mark.svg +0 -0
  147. /package/{public → dist}/logo-dark-bg.svg +0 -0
  148. /package/{public → dist}/logo-with-bg.svg +0 -0
  149. /package/{public → dist}/logo.svg +0 -0
  150. /package/{public → dist}/vite.svg +0 -0
@@ -1,881 +0,0 @@
1
- import * as React from 'react';
2
- import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
3
- import { useTranslation } from 'react-i18next';
4
- import { Check, ChevronsUpDown, X } from 'lucide-react';
5
- import {
6
- Button,
7
- Command,
8
- CommandEmpty,
9
- CommandGroup,
10
- CommandInput,
11
- CommandItem,
12
- CommandList,
13
- Popover,
14
- PopoverContent,
15
- PopoverTrigger,
16
- } from '@leanspec/ui-components';
17
- import ReactFlow, {
18
- Background,
19
- Controls,
20
- type Edge,
21
- MiniMap,
22
- MarkerType,
23
- type Node,
24
- Position,
25
- type ReactFlowInstance,
26
- } from 'reactflow';
27
- import 'reactflow/dist/style.css';
28
- import { cn } from '../lib/utils';
29
- import { api } from '../lib/api';
30
- import type { DependencyGraph } from '../types/api';
31
- import { useProject } from '../contexts';
32
-
33
- import { nodeTypes } from '../components/dependencies/SpecNode';
34
- import { SpecSidebar } from '../components/dependencies/SpecSidebar';
35
- import { getConnectionDepths, layoutGraph } from '../components/dependencies/utils';
36
- import { DEPENDS_ON_COLOR, toneBgColors } from '../components/dependencies/constants';
37
- import type { SpecNodeData, GraphTone, FocusedNodeDetails, ConnectionStats } from '../components/dependencies/types';
38
- import { PageHeader } from '../components/shared/PageHeader';
39
-
40
- export function DependenciesPage() {
41
- const { specName, projectId } = useParams<{ specName?: string; projectId?: string }>();
42
- const navigate = useNavigate();
43
- const [searchParams, setSearchParams] = useSearchParams();
44
- const { t } = useTranslation();
45
- const { currentProject, loading: projectLoading } = useProject();
46
- const projectReady = !projectId || currentProject?.id === projectId;
47
-
48
- const specParam = searchParams.get('spec');
49
- const basePath = projectId ? `/projects/${projectId}` : '/projects/default';
50
-
51
- // Helper to generate project-scoped URLs
52
- const getSpecUrl = React.useCallback((specNumber: number | string) => {
53
- return `${basePath}/specs/${specNumber}`;
54
- }, [basePath]);
55
-
56
- const [data, setData] = React.useState<DependencyGraph | null>(null);
57
- const [loading, setLoading] = React.useState(true);
58
- const [error, setError] = React.useState<string | null>(null);
59
- const [instance, setInstance] = React.useState<ReactFlowInstance | null>(null);
60
- const [showStandalone, setShowStandalone] = React.useState(false);
61
- const [statusFilter, setStatusFilter] = React.useState<string[]>([]);
62
- const [focusedNodeId, setFocusedNodeId] = React.useState<string | null>(null);
63
- const [viewMode, setViewMode] = React.useState<'graph' | 'focus'>('graph');
64
- const [isCompact, setIsCompact] = React.useState(false);
65
- const [selectorOpen, setSelectorOpen] = React.useState(false);
66
- const [selectorQuery, setSelectorQuery] = React.useState('');
67
-
68
- // Track if we've completed initial URL-to-state sync
69
- const initialSyncComplete = React.useRef(false);
70
- // Track the expected focusedNodeId after URL initialization (to avoid URL update race)
71
- const initialFocusedNodeId = React.useRef<string | null>(null);
72
-
73
- // Load data
74
- React.useEffect(() => {
75
- if (!projectReady || projectLoading) return;
76
-
77
- setLoading(true);
78
- api.getDependencies(specName)
79
- .then((responseData) => {
80
- setData(responseData);
81
- setIsCompact(responseData.nodes.length > 30);
82
- })
83
- .catch((err) => setError(err instanceof Error ? err.message : t('errors:loadingError')))
84
- .finally(() => setLoading(false));
85
- }, [projectLoading, projectReady, specName, t]);
86
-
87
- // Initialize focused node from URL param on mount
88
- React.useEffect(() => {
89
- if (data && !initialSyncComplete.current) {
90
- if (specParam) {
91
- const node = data.nodes.find((n) => n.number.toString() === specParam);
92
- if (node) {
93
- initialFocusedNodeId.current = node.id;
94
- setFocusedNodeId(node.id);
95
- }
96
- }
97
- initialSyncComplete.current = true;
98
- }
99
- }, [specParam, data]);
100
-
101
- // Sync URL with focused node state
102
- React.useEffect(() => {
103
- if (!data) return;
104
- // Skip if initial sync hasn't completed
105
- if (!initialSyncComplete.current) return;
106
-
107
- // Skip if this is the initial focusedNodeId set from URL
108
- if (initialFocusedNodeId.current !== null) {
109
- if (focusedNodeId === initialFocusedNodeId.current) {
110
- // This is the initial sync, clear the ref but don't update URL
111
- initialFocusedNodeId.current = null;
112
- return;
113
- }
114
- // focusedNodeId changed to something else, clear the ref
115
- initialFocusedNodeId.current = null;
116
- }
117
-
118
- const focusedNode = focusedNodeId ? data.nodes.find((n) => n.id === focusedNodeId) : null;
119
- const newSpecParam = focusedNode ? focusedNode.number.toString() : null;
120
-
121
- // Only update if different from current URL
122
- if (newSpecParam !== specParam) {
123
- const params = new URLSearchParams(searchParams);
124
- if (newSpecParam) {
125
- params.set('spec', newSpecParam);
126
- } else {
127
- params.delete('spec');
128
- }
129
- setSearchParams(params, { replace: true });
130
- }
131
- }, [focusedNodeId, data, specParam, searchParams, setSearchParams]);
132
-
133
- // Only use dependsOn edges (DAG only)
134
- const dependsOnEdges = React.useMemo(
135
- () => (data?.edges || []).filter((e) => e.type === 'dependsOn'),
136
- [data?.edges]
137
- );
138
-
139
- const adjacencyMaps = React.useMemo(() => {
140
- const upstream = new Map<string, Set<string>>();
141
- const downstream = new Map<string, Set<string>>();
142
-
143
- dependsOnEdges.forEach((e) => {
144
- if (!upstream.has(e.source)) upstream.set(e.source, new Set());
145
- upstream.get(e.source)!.add(e.target);
146
-
147
- if (!downstream.has(e.target)) downstream.set(e.target, new Set());
148
- downstream.get(e.target)!.add(e.source);
149
- });
150
-
151
- return { upstream, downstream };
152
- }, [dependsOnEdges]);
153
-
154
- // Get connection depths for focused node (all transitive deps)
155
- const connectionDepths = React.useMemo(() => {
156
- if (!focusedNodeId) return null;
157
- return getConnectionDepths(focusedNodeId, dependsOnEdges, Infinity);
158
- }, [focusedNodeId, dependsOnEdges]);
159
-
160
- React.useEffect(() => {
161
- if (!focusedNodeId && viewMode === 'focus') {
162
- setViewMode('graph');
163
- }
164
- }, [focusedNodeId, viewMode]);
165
-
166
- // Helper to get all transitive IDs
167
- const getAllTransitiveIds = React.useCallback((startId: string, adjacencyMap: Map<string, Set<string>>) => {
168
- const visited = new Set<string>();
169
- const queue = [startId];
170
- while (queue.length > 0) {
171
- const id = queue.shift()!;
172
- const neighbors = adjacencyMap.get(id);
173
- if (neighbors) {
174
- neighbors.forEach(n => {
175
- if (!visited.has(n)) {
176
- visited.add(n);
177
- queue.push(n);
178
- }
179
- });
180
- }
181
- }
182
- return visited;
183
- }, []);
184
-
185
- // Get detailed info for focused node (for sidebar)
186
- const focusedNodeDetails = React.useMemo((): FocusedNodeDetails | null => {
187
- if (!focusedNodeId || !data) return null;
188
- const node = data.nodes.find((n) => n.id === focusedNodeId);
189
- if (!node) return null;
190
-
191
- const nodeMap = new Map(data.nodes.map((n) => [n.id, n]));
192
-
193
- // BFS to get all upstream specs grouped by depth
194
- const getTransitiveDeps = (
195
- startId: string,
196
- adjacencyMap: Map<string, Set<string>>
197
- ): { depth: number; specs: typeof data.nodes }[] => {
198
- const visited = new Set<string>([startId]);
199
- const result: { depth: number; specs: typeof data.nodes }[] = [];
200
- let currentLevel = new Set([startId]);
201
- let depth = 1;
202
-
203
- while (currentLevel.size > 0) {
204
- const nextLevel = new Set<string>();
205
- const specsAtDepth: typeof data.nodes = [];
206
-
207
- currentLevel.forEach((nodeId) => {
208
- const neighbors = adjacencyMap.get(nodeId);
209
- if (neighbors) {
210
- neighbors.forEach((neighborId) => {
211
- if (!visited.has(neighborId)) {
212
- visited.add(neighborId);
213
- nextLevel.add(neighborId);
214
- const spec = nodeMap.get(neighborId);
215
- if (spec) specsAtDepth.push(spec);
216
- }
217
- });
218
- }
219
- });
220
-
221
- if (specsAtDepth.length > 0) {
222
- result.push({ depth, specs: specsAtDepth });
223
- }
224
- currentLevel = nextLevel;
225
- depth++;
226
- }
227
-
228
- return result;
229
- };
230
-
231
- const upstream = getTransitiveDeps(focusedNodeId, adjacencyMaps.upstream);
232
- const downstream = getTransitiveDeps(focusedNodeId, adjacencyMaps.downstream);
233
-
234
- return { node, upstream, downstream };
235
- }, [focusedNodeId, data, adjacencyMaps]);
236
-
237
- // Build the graph
238
- const graph = React.useMemo(() => {
239
- if (!data) return { nodes: [], edges: [] };
240
-
241
- const isFocusMode = viewMode === 'focus' && !!focusedNodeId;
242
-
243
- if (isFocusMode && focusedNodeId) {
244
- const upstreamIds = getAllTransitiveIds(focusedNodeId, adjacencyMaps.upstream);
245
- const downstreamIds = getAllTransitiveIds(focusedNodeId, adjacencyMaps.downstream);
246
- const visibleNodeIds = new Set<string>([focusedNodeId, ...upstreamIds, ...downstreamIds]);
247
-
248
- const visibleNodes = data.nodes.filter((n) => visibleNodeIds.has(n.id));
249
-
250
- const nodes: Node<SpecNodeData>[] = visibleNodes.map((node) => {
251
- const isFocused = focusedNodeId === node.id;
252
- const connectionDepth = isFocused ? 0 : connectionDepths?.get(node.id);
253
-
254
- return {
255
- id: node.id,
256
- type: 'specNode',
257
- data: {
258
- label: node.name,
259
- shortLabel: node.name.length > 14 ? node.name.slice(0, 12) + '…' : node.name,
260
- badge: node.status === 'in-progress' ? 'WIP' : node.status.slice(0, 3).toUpperCase(),
261
- number: node.number,
262
- tone: node.status as GraphTone,
263
- priority: node.priority,
264
- href: getSpecUrl(node.number),
265
- interactive: true,
266
- isFocused,
267
- connectionDepth,
268
- isDimmed: false,
269
- isCompact,
270
- isSecondary: false,
271
- },
272
- position: { x: 0, y: 0 },
273
- draggable: true,
274
- selectable: true,
275
- sourcePosition: Position.Right,
276
- targetPosition: Position.Left,
277
- };
278
- });
279
-
280
- const edges: Edge[] = dependsOnEdges
281
- .filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target))
282
- .map((edge) => {
283
- const isHighlighted = edge.source === focusedNodeId || edge.target === focusedNodeId;
284
-
285
- return {
286
- id: `${edge.source}-${edge.target}-dependsOn`,
287
- source: edge.source,
288
- target: edge.target,
289
- type: 'smoothstep',
290
- animated: isHighlighted,
291
- markerEnd: {
292
- type: MarkerType.ArrowClosed,
293
- color: DEPENDS_ON_COLOR,
294
- width: 18,
295
- height: 18,
296
- },
297
- style: {
298
- stroke: DEPENDS_ON_COLOR,
299
- strokeWidth: isHighlighted ? 2.75 : 2,
300
- opacity: 1,
301
- },
302
- };
303
- });
304
-
305
- return layoutGraph(nodes, edges, isCompact, false, {
306
- mode: 'focus',
307
- focusedNodeId,
308
- upstreamIds,
309
- downstreamIds,
310
- });
311
- }
312
-
313
- // Primary nodes: those matching the status filter
314
- const primaryNodes = data.nodes.filter(
315
- (node) => statusFilter.length === 0 || statusFilter.includes(node.status)
316
- );
317
- const primaryNodeIds = new Set(primaryNodes.map((n) => n.id));
318
-
319
- // Find all nodes in the critical path (connected to primary nodes via dependencies)
320
- const criticalPathIds = new Set<string>(primaryNodeIds);
321
-
322
- // BFS to find all connected nodes through dependencies
323
- const queue = [...primaryNodeIds];
324
- while (queue.length > 0) {
325
- const nodeId = queue.shift()!;
326
- dependsOnEdges.forEach((e) => {
327
- // Check both directions - upstream and downstream dependencies
328
- if (e.source === nodeId && !criticalPathIds.has(e.target)) {
329
- criticalPathIds.add(e.target);
330
- queue.push(e.target);
331
- }
332
- if (e.target === nodeId && !criticalPathIds.has(e.source)) {
333
- criticalPathIds.add(e.source);
334
- queue.push(e.source);
335
- }
336
- });
337
- }
338
-
339
- // Get all nodes in critical path
340
- const criticalPathNodes = data.nodes.filter((n) => criticalPathIds.has(n.id));
341
-
342
- // Filter edges to only those between critical path nodes
343
- const filteredEdges = dependsOnEdges.filter(
344
- (e) => criticalPathIds.has(e.source) && criticalPathIds.has(e.target)
345
- );
346
-
347
- // Filter to nodes with dependencies unless showStandalone
348
- let visibleNodes = criticalPathNodes;
349
- if (!showStandalone) {
350
- const nodesWithDeps = new Set<string>();
351
- filteredEdges.forEach((e) => {
352
- nodesWithDeps.add(e.source);
353
- nodesWithDeps.add(e.target);
354
- });
355
- visibleNodes = criticalPathNodes.filter((n) => nodesWithDeps.has(n.id));
356
- }
357
-
358
- const visibleNodeIds = new Set(visibleNodes.map((n) => n.id));
359
-
360
- // Track which nodes are "secondary" (shown due to critical path, not primary filter)
361
- const secondaryNodeIds = new Set(
362
- [...visibleNodeIds].filter((id) => !primaryNodeIds.has(id))
363
- );
364
-
365
- const nodes: Node<SpecNodeData>[] = visibleNodes.map((node) => {
366
- const isFocused = focusedNodeId === node.id;
367
- const isSecondary = secondaryNodeIds.has(node.id);
368
-
369
- let connectionDepth: number | undefined;
370
- let isDimmed = false;
371
-
372
- if (focusedNodeId) {
373
- connectionDepth = connectionDepths?.get(node.id);
374
- isDimmed = connectionDepth === undefined;
375
- }
376
-
377
- return {
378
- id: node.id,
379
- type: 'specNode',
380
- data: {
381
- label: node.name,
382
- shortLabel: node.name.length > 14 ? node.name.slice(0, 12) + '…' : node.name,
383
- badge: node.status === 'in-progress' ? 'WIP' : node.status.slice(0, 3).toUpperCase(),
384
- number: node.number,
385
- tone: node.status as GraphTone,
386
- priority: node.priority,
387
- href: getSpecUrl(node.number),
388
- interactive: true,
389
- isFocused,
390
- connectionDepth,
391
- isDimmed,
392
- isCompact,
393
- isSecondary,
394
- },
395
- position: { x: 0, y: 0 },
396
- draggable: true,
397
- selectable: true,
398
- sourcePosition: Position.Right,
399
- targetPosition: Position.Left,
400
- };
401
- });
402
-
403
- const edges: Edge[] = filteredEdges
404
- .filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target))
405
- .map((edge) => {
406
- let isHighlighted = true;
407
- let opacity = 0.7;
408
-
409
- if (focusedNodeId) {
410
- const sourceDepth = connectionDepths?.get(edge.source);
411
- const targetDepth = connectionDepths?.get(edge.target);
412
- isHighlighted =
413
- sourceDepth !== undefined &&
414
- targetDepth !== undefined &&
415
- (sourceDepth === 0 || targetDepth === 0);
416
- opacity = isHighlighted
417
- ? 1
418
- : sourceDepth !== undefined && targetDepth !== undefined
419
- ? 0.4
420
- : 0.1;
421
- }
422
-
423
- return {
424
- id: `${edge.source}-${edge.target}-dependsOn`,
425
- source: edge.source,
426
- target: edge.target,
427
- type: 'smoothstep',
428
- animated: isHighlighted && focusedNodeId !== null,
429
- markerEnd: {
430
- type: MarkerType.ArrowClosed,
431
- color: DEPENDS_ON_COLOR,
432
- width: 18,
433
- height: 18,
434
- },
435
- style: {
436
- stroke: DEPENDS_ON_COLOR,
437
- strokeWidth: isHighlighted ? 2.5 : 1.5,
438
- opacity,
439
- },
440
- };
441
- });
442
-
443
- return layoutGraph(nodes, edges, isCompact, showStandalone, { mode: 'graph' });
444
- }, [
445
- data,
446
- dependsOnEdges,
447
- statusFilter,
448
- focusedNodeId,
449
- connectionDepths,
450
- isCompact,
451
- showStandalone,
452
- adjacencyMaps,
453
- viewMode,
454
- getSpecUrl,
455
- getAllTransitiveIds,
456
- ]);
457
-
458
- // Connection stats
459
- const connectionStats = React.useMemo((): ConnectionStats => {
460
- if (!data) return { connected: 0, standalone: 0 };
461
-
462
- const nodesWithDeps = new Set<string>();
463
- dependsOnEdges.forEach((e) => {
464
- nodesWithDeps.add(e.source);
465
- nodesWithDeps.add(e.target);
466
- });
467
-
468
- return {
469
- connected: nodesWithDeps.size,
470
- standalone: data.nodes.length - nodesWithDeps.size,
471
- };
472
- }, [dependsOnEdges, data]);
473
-
474
- const statusCounts = React.useMemo(() => {
475
- if (!data) return {};
476
- const counts: Record<string, number> = {};
477
- data.nodes.forEach((node) => {
478
- counts[node.status] = (counts[node.status] || 0) + 1;
479
- });
480
- return counts;
481
- }, [data]);
482
-
483
- const handleInit = React.useCallback((flowInstance: ReactFlowInstance) => {
484
- setInstance(flowInstance);
485
- requestAnimationFrame(() => {
486
- flowInstance.fitView({ padding: 0.15, duration: 300 });
487
- });
488
- }, []);
489
-
490
- React.useEffect(() => {
491
- if (!instance) return;
492
- const timer = setTimeout(() => {
493
- instance.fitView({ padding: 0.15, duration: 300 });
494
- }, 50);
495
- return () => clearTimeout(timer);
496
- }, [instance, graph, statusFilter, showStandalone]);
497
-
498
- // Center on focused node when set from URL param
499
- React.useEffect(() => {
500
- if (!instance || !focusedNodeId || !specParam) return;
501
- const node = graph.nodes.find((n) => n.id === focusedNodeId);
502
- if (node) {
503
- const timer = setTimeout(() => {
504
- instance.setCenter(node.position.x + 80, node.position.y + 30, {
505
- duration: 400,
506
- zoom: 1,
507
- });
508
- }, 400);
509
- return () => clearTimeout(timer);
510
- }
511
- }, [instance, focusedNodeId, specParam, graph.nodes]);
512
-
513
- // Filter specs for selector dropdown
514
- const filteredSpecs = React.useMemo(() => {
515
- if (!data) return [];
516
- if (!selectorQuery.trim()) return data.nodes.slice(0, 15);
517
- const q = selectorQuery.toLowerCase();
518
- return data.nodes
519
- .filter(
520
- (n) =>
521
- n.name.toLowerCase().includes(q) ||
522
- n.number.toString().includes(q) ||
523
- n.tags.some((t) => t.toLowerCase().includes(q))
524
- )
525
- .slice(0, 15);
526
- }, [data, selectorQuery]);
527
-
528
- // Get focused spec name for display
529
- const focusedSpec = React.useMemo(
530
- () => (focusedNodeId && data ? data.nodes.find((n) => n.id === focusedNodeId) : null),
531
- [focusedNodeId, data]
532
- );
533
-
534
- const handleNodeClick = React.useCallback(
535
- (event: React.MouseEvent, node: Node<SpecNodeData>) => {
536
- if (!node?.data) return;
537
- if (event.detail === 2 && node.data.href) {
538
- navigate(node.data.href);
539
- return;
540
- }
541
- setFocusedNodeId((prev) => (prev === node.id ? null : node.id));
542
- },
543
- [navigate]
544
- );
545
-
546
- const handlePaneClick = React.useCallback(() => {
547
- setFocusedNodeId(null);
548
- }, []);
549
-
550
- const toggleStatus = (status: string) => {
551
- setStatusFilter((prev) =>
552
- prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
553
- );
554
- setFocusedNodeId(null);
555
- };
556
-
557
- const clearFilters = () => {
558
- setStatusFilter([]);
559
- setFocusedNodeId(null);
560
- setSelectorQuery('');
561
- };
562
-
563
- const handleSelectSpec = (specId: string) => {
564
- setFocusedNodeId(specId);
565
- setSelectorOpen(false);
566
- setSelectorQuery('');
567
- // Center on the selected node
568
- if (instance) {
569
- const node = graph.nodes.find((n) => n.id === specId);
570
- if (node) {
571
- instance.setCenter(node.position.x + 80, node.position.y + 30, {
572
- duration: 400,
573
- zoom: 1,
574
- });
575
- }
576
- }
577
- };
578
-
579
- const hasFilters = statusFilter.length > 0 || focusedNodeId;
580
-
581
- if (loading) {
582
- return (
583
- <div className="container mx-auto p-6">
584
- <div className="flex items-center justify-center h-[calc(100vh-10rem)]">
585
- <p className="text-muted-foreground">{t('dependenciesPage.state.loading')}</p>
586
- </div>
587
- </div>
588
- );
589
- }
590
-
591
- if (error || !data) {
592
- return (
593
- <div className="container mx-auto p-6">
594
- <div className="flex items-center justify-center h-[calc(100vh-10rem)]">
595
- <div className="text-center">
596
- <p className="text-lg font-semibold text-destructive mb-2">{t('dependenciesPage.state.errorTitle')}</p>
597
- <p className="text-sm text-muted-foreground">{error || t('dependenciesPage.state.errorDescription')}</p>
598
- </div>
599
- </div>
600
- </div>
601
- );
602
- }
603
-
604
- if (data.nodes.length === 0) {
605
- return (
606
- <div className="container mx-auto p-6">
607
- <div className="rounded-lg border border-border bg-muted/30 p-8 text-center">
608
- <h2 className="text-xl font-semibold mb-2">{t('dependenciesPage.empty.noDependencies')}</h2>
609
- <p className="text-muted-foreground">{t('dependenciesPage.empty.noDependenciesDescription')}</p>
610
- </div>
611
- </div>
612
- );
613
- }
614
-
615
- return (
616
- <div className="container mx-auto p-6 h-[calc(100vh-7rem)]">
617
- <div className="flex h-full flex-col gap-4">
618
- <PageHeader
619
- title={t('dependenciesPage.title')}
620
- description={t('dependenciesPage.description')}
621
- actions={(
622
- <Popover open={selectorOpen} onOpenChange={setSelectorOpen}>
623
- <PopoverTrigger asChild>
624
- <Button
625
- variant="outline"
626
- role="combobox"
627
- aria-expanded={selectorOpen}
628
- className={cn(
629
- 'w-[240px] h-9 justify-between px-3 text-xs',
630
- focusedNodeId && 'border-primary/60 bg-primary/10 text-foreground'
631
- )}
632
- >
633
- {focusedSpec ? (
634
- <span className="truncate flex items-center">
635
- <span className="text-muted-foreground mr-2 font-mono">#{focusedSpec.number.toString().padStart(3, '0')}</span>
636
- <span className="truncate">{focusedSpec.name}</span>
637
- </span>
638
- ) : (
639
- <span className="text-muted-foreground font-normal">{t('dependenciesPage.selector.placeholder')}</span>
640
- )}
641
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
642
- </Button>
643
- </PopoverTrigger>
644
- <PopoverContent className="w-[300px] p-0" align="end">
645
- <Command shouldFilter={false}>
646
- <CommandInput
647
- placeholder={t('dependenciesPage.selector.filterPlaceholder')}
648
- value={selectorQuery}
649
- onValueChange={setSelectorQuery}
650
- className="text-xs"
651
- />
652
- <CommandList>
653
- <CommandEmpty className="py-2 text-center text-xs text-muted-foreground">
654
- {t('dependenciesPage.selector.empty')}
655
- </CommandEmpty>
656
- <CommandGroup>
657
- {focusedNodeId && (
658
- <CommandItem
659
- onSelect={() => {
660
- setFocusedNodeId(null);
661
- setSelectorOpen(false);
662
- setSelectorQuery('');
663
- }}
664
- className="text-muted-foreground"
665
- >
666
- <X className="mr-2 h-3.5 w-3.5" />
667
- {t('dependenciesPage.selector.clearSelection')}
668
- </CommandItem>
669
- )}
670
- {filteredSpecs.map((spec) => (
671
- <CommandItem
672
- key={spec.id}
673
- value={spec.id}
674
- onSelect={() => handleSelectSpec(spec.id)}
675
- >
676
- <span className="text-muted-foreground font-mono mr-2">#{spec.number.toString().padStart(3, '0')}</span>
677
- <span className="truncate flex-1">{spec.name}</span>
678
- <span
679
- className={cn(
680
- 'text-[9px] px-1 py-0.5 rounded uppercase font-medium ml-2 shrink-0',
681
- spec.status === 'planned' && 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
682
- spec.status === 'in-progress' && 'bg-orange-500/20 text-orange-600 dark:text-orange-400',
683
- spec.status === 'complete' && 'bg-green-500/20 text-green-600 dark:text-green-400',
684
- spec.status === 'archived' && 'bg-gray-500/20 text-gray-500 dark:text-gray-400'
685
- )}
686
- >
687
- {spec.status === 'in-progress' ? 'WIP' : spec.status.slice(0, 3)}
688
- </span>
689
- <div
690
- className={cn(
691
- 'mr-2 flex h-4 w-4 items-center justify-center',
692
- focusedNodeId === spec.id ? 'opacity-100' : 'opacity-0'
693
- )}
694
- >
695
- <Check className="h-4 w-4" />
696
- </div>
697
- </CommandItem>
698
- ))}
699
- </CommandGroup>
700
- </CommandList>
701
- </Command>
702
- </PopoverContent>
703
- </Popover>
704
- )}
705
- />
706
-
707
- <div className="text-sm text-muted-foreground">
708
- {connectionStats.connected > 0 ? (
709
- <>
710
- <span className="text-emerald-600 dark:text-emerald-400">
711
- {t('dependenciesPage.header.summary.connected', { count: connectionStats.connected })}
712
- </span>
713
- {connectionStats.standalone > 0 && (
714
- <>
715
- {' • '}
716
- <span className="text-muted-foreground">
717
- {t('dependenciesPage.header.summary.standalone', { count: connectionStats.standalone })}
718
- </span>
719
- </>
720
- )}
721
- </>
722
- ) : (
723
- <span>{t('dependenciesPage.header.summary.none')}</span>
724
- )}
725
- </div>
726
-
727
- {/* Filters */}
728
- <div className="flex flex-wrap items-center gap-1.5 text-xs">
729
- {(['planned', 'in-progress', 'complete', 'archived'] as const).map((status) => {
730
- const isActive = statusFilter.length === 0 || statusFilter.includes(status);
731
- const label = t(`status.${status}`);
732
- const count = statusCounts[status] || 0;
733
- return (
734
- <button
735
- key={status}
736
- onClick={() => toggleStatus(status)}
737
- className={cn(
738
- 'rounded border px-2 py-1 font-medium transition-colors',
739
- isActive && status === 'planned' && 'border-blue-500/60 bg-blue-500/20 text-blue-700 dark:text-blue-300',
740
- isActive && status === 'in-progress' && 'border-orange-500/60 bg-orange-500/20 text-orange-700 dark:text-orange-300',
741
- isActive && status === 'complete' && 'border-green-500/60 bg-green-500/20 text-green-700 dark:text-green-300',
742
- isActive && status === 'archived' && 'border-gray-500/60 bg-gray-500/20 text-gray-600 dark:text-gray-300',
743
- !isActive && 'border-border bg-background text-muted-foreground/40'
744
- )}
745
- >
746
- {label}
747
- <span className="ml-1 opacity-60">{t('dependenciesPage.filters.count', { count })}</span>
748
- </button>
749
- );
750
- })}
751
-
752
- <span className="h-3 w-px bg-border" />
753
-
754
- <button
755
- onClick={() => setShowStandalone(!showStandalone)}
756
- className={cn(
757
- 'rounded border px-2 py-1 font-medium transition-colors',
758
- showStandalone
759
- ? 'border-violet-500/60 bg-violet-500/20 text-violet-700 dark:text-violet-300'
760
- : 'border-border bg-background hover:bg-accent text-muted-foreground'
761
- )}
762
- >
763
- {t('dependenciesPage.filters.showStandalone', { count: connectionStats.standalone })}
764
- </button>
765
-
766
- <span className="h-3 w-px bg-border" />
767
-
768
- <button
769
- onClick={() => setIsCompact(!isCompact)}
770
- className={cn(
771
- 'rounded border px-2 py-1 font-medium transition-colors',
772
- isCompact
773
- ? 'border-primary/60 bg-primary/20 text-primary'
774
- : 'border-border bg-background hover:bg-accent text-muted-foreground'
775
- )}
776
- >
777
- {t('dependenciesPage.filters.compact')}
778
- </button>
779
-
780
- {focusedNodeId && (
781
- <button
782
- onClick={() => setViewMode((prev) => (prev === 'graph' ? 'focus' : 'graph'))}
783
- className={cn(
784
- 'rounded border px-2 py-1 font-medium transition-colors',
785
- viewMode === 'focus'
786
- ? 'border-primary/60 bg-primary/20 text-primary'
787
- : 'border-border bg-background hover:bg-accent text-muted-foreground'
788
- )}
789
- >
790
- {t('dependenciesPage.filters.focusMode')}
791
- </button>
792
- )}
793
-
794
- {hasFilters && (
795
- <>
796
- <span className="h-3 w-px bg-border" />
797
- <button
798
- onClick={clearFilters}
799
- className="rounded border border-red-500/40 bg-red-500/10 px-2 py-1 font-medium text-red-400 hover:bg-red-500/20"
800
- >
801
- {t('dependenciesPage.filters.clear')}
802
- </button>
803
- </>
804
- )}
805
- </div>
806
-
807
- {/* Main content */}
808
- <div className="flex flex-1 gap-3 min-h-0">
809
- {/* Graph */}
810
- <div className="flex-1 overflow-hidden rounded-lg border border-border bg-gray-50 dark:bg-[#080c14]">
811
- {graph.nodes.length > 0 ? (
812
- <ReactFlow
813
- nodes={graph.nodes}
814
- edges={graph.edges}
815
- nodeTypes={nodeTypes}
816
- onInit={handleInit}
817
- className="h-full w-full"
818
- fitView
819
- proOptions={{ hideAttribution: true }}
820
- nodesDraggable
821
- nodesConnectable={false}
822
- elementsSelectable
823
- panOnScroll
824
- panOnDrag
825
- zoomOnScroll
826
- zoomOnPinch
827
- minZoom={0.05}
828
- maxZoom={2}
829
- onNodeClick={handleNodeClick}
830
- onPaneClick={handlePaneClick}
831
- >
832
- <Background gap={20} size={1} color="rgba(100, 116, 139, 0.06)" />
833
- <Controls showInteractive={false} className="!bg-background/90 !border-border !rounded-md" />
834
- <MiniMap
835
- nodeColor={(node) => {
836
- const d = node.data as SpecNodeData;
837
- return toneBgColors[d.tone] || '#6b7280';
838
- }}
839
- maskColor="rgba(128, 128, 128, 0.6)"
840
- className="!bg-white/95 dark:!bg-background/95 !border-border !rounded-md"
841
- style={{ width: 120, height: 80 }}
842
- pannable
843
- zoomable
844
- />
845
- </ReactFlow>
846
- ) : (
847
- <div className="flex h-full items-center justify-center text-muted-foreground">
848
- <div className="text-center">
849
- <p className="text-sm font-medium">{t('dependenciesPage.empty.title')}</p>
850
- <p className="text-xs mt-1">
851
- {showStandalone
852
- ? t('dependenciesPage.empty.filters')
853
- : t('dependenciesPage.empty.standaloneHint')}
854
- </p>
855
- </div>
856
- </div>
857
- )}
858
- </div>
859
-
860
- {/* Right Sidebar */}
861
- <SpecSidebar
862
- focusedDetails={focusedNodeDetails}
863
- onSelectSpec={setFocusedNodeId}
864
- onOpenSpec={(num) => navigate(getSpecUrl(num))}
865
- />
866
- </div>
867
-
868
- {/* Legend */}
869
- <div className="flex flex-wrap items-center gap-4 text-[10px] text-muted-foreground">
870
- <span className="inline-flex items-center gap-1.5">
871
- <span className="inline-block h-0.5 w-6 bg-amber-400 rounded" />
872
- {t('dependenciesPage.legend.dependsOn')}
873
- </span>
874
- <span className="text-muted-foreground/50 ml-auto">
875
- {t('dependenciesPage.legend.instructions')}
876
- </span>
877
- </div>
878
- </div>
879
- </div>
880
- );
881
- }