@leanspec/ui 0.2.13 → 0.2.15-dev.21022397862

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 (149) 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 +13 -3
  52. package/eslint.config.js +0 -23
  53. package/postcss.config.js +0 -6
  54. package/src/App.css +0 -42
  55. package/src/App.tsx +0 -17
  56. package/src/assets/react.svg +0 -1
  57. package/src/components/LanguageSwitcher.tsx +0 -67
  58. package/src/components/Layout.tsx +0 -88
  59. package/src/components/MainSidebar.tsx +0 -163
  60. package/src/components/MermaidDiagram.tsx +0 -85
  61. package/src/components/MinimalLayout.tsx +0 -51
  62. package/src/components/Navigation.tsx +0 -254
  63. package/src/components/PriorityBadge.tsx +0 -59
  64. package/src/components/ProjectSwitcher.tsx +0 -222
  65. package/src/components/QuickSearch.tsx +0 -225
  66. package/src/components/RootRedirect.tsx +0 -40
  67. package/src/components/SpecDetailLayout.context.ts +0 -10
  68. package/src/components/SpecDetailLayout.tsx +0 -14
  69. package/src/components/SpecsNavSidebar.tsx +0 -615
  70. package/src/components/StatusBadge.tsx +0 -59
  71. package/src/components/ThemeToggle.tsx +0 -25
  72. package/src/components/Tooltip.tsx +0 -29
  73. package/src/components/context/ContextClient.tsx +0 -471
  74. package/src/components/context/ContextFileDetail.tsx +0 -163
  75. package/src/components/dashboard/ActivityItem.tsx +0 -36
  76. package/src/components/dashboard/DashboardClient.tsx +0 -218
  77. package/src/components/dashboard/SpecListItem.tsx +0 -58
  78. package/src/components/dashboard/StatCard.tsx +0 -52
  79. package/src/components/dependencies/SpecNode.tsx +0 -128
  80. package/src/components/dependencies/SpecSidebar.tsx +0 -256
  81. package/src/components/dependencies/constants.ts +0 -25
  82. package/src/components/dependencies/types.ts +0 -38
  83. package/src/components/dependencies/utils.ts +0 -261
  84. package/src/components/metadata-editors/PriorityEditor.tsx +0 -89
  85. package/src/components/metadata-editors/StatusEditor.tsx +0 -85
  86. package/src/components/metadata-editors/TagsEditor.tsx +0 -207
  87. package/src/components/projects/CreateProjectDialog.tsx +0 -162
  88. package/src/components/projects/DirectoryPicker.tsx +0 -182
  89. package/src/components/shared/BackToTop.tsx +0 -39
  90. package/src/components/shared/ColorPicker.tsx +0 -68
  91. package/src/components/shared/EmptyState.tsx +0 -35
  92. package/src/components/shared/ErrorBoundary.tsx +0 -79
  93. package/src/components/shared/PageHeader.tsx +0 -23
  94. package/src/components/shared/PageTransition.tsx +0 -40
  95. package/src/components/shared/ProjectAvatar.tsx +0 -107
  96. package/src/components/shared/Skeletons.tsx +0 -184
  97. package/src/components/spec-detail/EditableMetadata.tsx +0 -129
  98. package/src/components/spec-detail/MarkdownRenderer.tsx +0 -47
  99. package/src/components/spec-detail/TableOfContents.tsx +0 -150
  100. package/src/components/specs/BoardView.tsx +0 -204
  101. package/src/components/specs/ListView.tsx +0 -62
  102. package/src/components/specs/SpecsFilters.tsx +0 -190
  103. package/src/contexts/KeyboardShortcutsContext.tsx +0 -95
  104. package/src/contexts/LayoutContext.tsx +0 -45
  105. package/src/contexts/ProjectContext.tsx +0 -163
  106. package/src/contexts/ThemeContext.tsx +0 -90
  107. package/src/contexts/index.ts +0 -7
  108. package/src/hooks/useKeyboardShortcuts.ts +0 -87
  109. package/src/index.css +0 -624
  110. package/src/lib/api.ts +0 -72
  111. package/src/lib/backend-adapter.ts +0 -382
  112. package/src/lib/date-utils.ts +0 -122
  113. package/src/lib/i18n.test.ts +0 -57
  114. package/src/lib/i18n.ts +0 -51
  115. package/src/lib/markdown-utils.ts +0 -38
  116. package/src/lib/sub-spec-utils.ts +0 -166
  117. package/src/lib/utils.ts +0 -6
  118. package/src/locales/en/common.json +0 -660
  119. package/src/locales/en/errors.json +0 -20
  120. package/src/locales/en/help.json +0 -8
  121. package/src/locales/zh-CN/common.json +0 -660
  122. package/src/locales/zh-CN/errors.json +0 -20
  123. package/src/locales/zh-CN/help.json +0 -8
  124. package/src/main.tsx +0 -12
  125. package/src/pages/ContextPage.tsx +0 -111
  126. package/src/pages/DashboardPage.tsx +0 -97
  127. package/src/pages/DependenciesPage.tsx +0 -881
  128. package/src/pages/ProjectsPage.tsx +0 -432
  129. package/src/pages/SpecDetailPage.tsx +0 -592
  130. package/src/pages/SpecsPage.tsx +0 -319
  131. package/src/pages/StatsPage.tsx +0 -307
  132. package/src/router/projectRoutes.tsx +0 -36
  133. package/src/router.tsx +0 -33
  134. package/src/test/setup.ts +0 -39
  135. package/src/types/api.ts +0 -185
  136. package/tailwind.config.ts +0 -57
  137. package/tsconfig.app.json +0 -29
  138. package/tsconfig.json +0 -7
  139. package/tsconfig.node.json +0 -26
  140. package/tsconfig.tsbuildinfo +0 -1
  141. package/vite.config.ts +0 -27
  142. package/vitest.config.ts +0 -18
  143. /package/{public → dist}/favicon.ico +0 -0
  144. /package/{public → dist}/github-mark-white.svg +0 -0
  145. /package/{public → dist}/github-mark.svg +0 -0
  146. /package/{public → dist}/logo-dark-bg.svg +0 -0
  147. /package/{public → dist}/logo-with-bg.svg +0 -0
  148. /package/{public → dist}/logo.svg +0 -0
  149. /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
- }