@tldiagram/core-ui 1.87.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 (272) hide show
  1. package/dist/App.d.ts +1 -0
  2. package/dist/api/client.d.ts +143 -0
  3. package/dist/api/transport-vscode.d.ts +8 -0
  4. package/dist/api/transport.d.ts +1 -0
  5. package/dist/components/CodePreviewPanel-vscode.d.ts +7 -0
  6. package/dist/components/CodePreviewPanel.d.ts +9 -0
  7. package/dist/components/ConfirmDialog.d.ts +12 -0
  8. package/dist/components/ConnectorPanel.d.ts +21 -0
  9. package/dist/components/ContextBoundaryElement.d.ts +11 -0
  10. package/dist/components/ContextNeighborElement.d.ts +29 -0
  11. package/dist/components/ContextStraightConnector.d.ts +4 -0
  12. package/dist/components/CrossBranchControls.d.ts +9 -0
  13. package/dist/components/DependenciesOnboarding.d.ts +5 -0
  14. package/dist/components/DrawingCanvas.d.ts +39 -0
  15. package/dist/components/ElementLibrary-vscode.d.ts +7 -0
  16. package/dist/components/ElementLibrary.d.ts +22 -0
  17. package/dist/components/ElementNode.d.ts +36 -0
  18. package/dist/components/ElementPanel.d.ts +25 -0
  19. package/dist/components/ExploreOnboarding.d.ts +5 -0
  20. package/dist/components/ExplorePageOnboarding.d.ts +5 -0
  21. package/dist/components/ExportModal.d.ts +16 -0
  22. package/dist/components/FloatingEdge.d.ts +9 -0
  23. package/dist/components/GitSourceLinker.d.ts +8 -0
  24. package/dist/components/HeaderContext.d.ts +16 -0
  25. package/dist/components/Icons.d.ts +95 -0
  26. package/dist/components/ImportModal.d.ts +10 -0
  27. package/dist/components/InlineElementAdder.d.ts +17 -0
  28. package/dist/components/LayoutSection.d.ts +7 -0
  29. package/dist/components/LocalSourceLinker.d.ts +8 -0
  30. package/dist/components/MiniZoomOnboarding.d.ts +5 -0
  31. package/dist/components/NavBreadcrumb.d.ts +6 -0
  32. package/dist/components/NodeBody.d.ts +12 -0
  33. package/dist/components/NodeContainer.d.ts +8 -0
  34. package/dist/components/NodeHoverCard.d.ts +10 -0
  35. package/dist/components/PanelHeader.d.ts +8 -0
  36. package/dist/components/PanelUI.d.ts +3 -0
  37. package/dist/components/ProxyConnectorEdge.d.ts +4 -0
  38. package/dist/components/ProxyConnectorPanel.d.ts +9 -0
  39. package/dist/components/SafeBackground.d.ts +13 -0
  40. package/dist/components/ScrollIndicatorWrapper.d.ts +8 -0
  41. package/dist/components/SetChildModal.d.ts +10 -0
  42. package/dist/components/SetParentModal.d.ts +10 -0
  43. package/dist/components/SlidingPanel.d.ts +16 -0
  44. package/dist/components/TagUpsert.d.ts +8 -0
  45. package/dist/components/TopMenuBar.d.ts +8 -0
  46. package/dist/components/ViewBezierConnector.d.ts +4 -0
  47. package/dist/components/ViewDrawMenu.d.ts +22 -0
  48. package/dist/components/ViewEditorEdgeLabelLayout.d.ts +16 -0
  49. package/dist/components/ViewEditorOnboarding.d.ts +5 -0
  50. package/dist/components/ViewExplorer/TagManager/ColorPicker.d.ts +7 -0
  51. package/dist/components/ViewExplorer/TagManager/GroupNamingPopover.d.ts +10 -0
  52. package/dist/components/ViewExplorer/TagManager/LayerItem.d.ts +27 -0
  53. package/dist/components/ViewExplorer/TagManager/TagItem.d.ts +25 -0
  54. package/dist/components/ViewExplorer/TagManager/index.d.ts +21 -0
  55. package/dist/components/ViewExplorer/ViewNavigator.d.ts +11 -0
  56. package/dist/components/ViewExplorer/ViewSearch.d.ts +8 -0
  57. package/dist/components/ViewExplorer/ViewTree.d.ts +18 -0
  58. package/dist/components/ViewExplorer/index.d.ts +31 -0
  59. package/dist/components/ViewExplorer/types.d.ts +11 -0
  60. package/dist/components/ViewExplorer/utils.d.ts +6 -0
  61. package/dist/components/ViewExplorer-vscode.d.ts +6 -0
  62. package/dist/components/ViewFloatingMenu-vscode.d.ts +27 -0
  63. package/dist/components/ViewFloatingMenu.d.ts +39 -0
  64. package/dist/components/ViewGridNode.d.ts +29 -0
  65. package/dist/components/ViewHeaderButton.d.ts +11 -0
  66. package/dist/components/ViewPanel.d.ts +18 -0
  67. package/dist/components/ViewsGridOnboarding.d.ts +5 -0
  68. package/dist/components/ZUI/ZUICanvas.d.ts +18 -0
  69. package/dist/components/ZUI/index.d.ts +2 -0
  70. package/dist/components/ZUI/layout.d.ts +18 -0
  71. package/dist/components/ZUI/proxy.d.ts +25 -0
  72. package/dist/components/ZUI/renderer.d.ts +30 -0
  73. package/dist/components/ZUI/types.d.ts +140 -0
  74. package/dist/components/ZUI/useZUIInteraction.d.ts +21 -0
  75. package/dist/config/runtime-vscode.d.ts +22 -0
  76. package/dist/config/runtime.d.ts +5 -0
  77. package/dist/constants/colors.d.ts +27 -0
  78. package/dist/constants/diagramColors.d.ts +1 -0
  79. package/dist/context/ThemeContext.d.ts +27 -0
  80. package/dist/crossBranch/graph.d.ts +13 -0
  81. package/dist/crossBranch/resolve.d.ts +22 -0
  82. package/dist/crossBranch/settings.d.ts +6 -0
  83. package/dist/crossBranch/store.d.ts +11 -0
  84. package/dist/crossBranch/types.d.ts +96 -0
  85. package/dist/demo/DemoPage.d.ts +9 -0
  86. package/dist/demo/seed.d.ts +9 -0
  87. package/dist/demo/store.d.ts +137 -0
  88. package/dist/demo/viewEditor.d.ts +26 -0
  89. package/dist/favicon.svg +35 -0
  90. package/dist/hooks/useSafeFitView.d.ts +16 -0
  91. package/dist/index.css +1 -0
  92. package/dist/index.d.ts +115 -0
  93. package/dist/index.js +19966 -0
  94. package/dist/lib/vscodeBridge-vscode.d.ts +13 -0
  95. package/dist/lib/vscodeBridge.d.ts +5 -0
  96. package/dist/logo-120.png +0 -0
  97. package/dist/logo-bw.png +0 -0
  98. package/dist/logo-bw.svg +15 -0
  99. package/dist/logo-text.svg +51 -0
  100. package/dist/logo.svg +35 -0
  101. package/dist/pages/AppearanceSettings.d.ts +3 -0
  102. package/dist/pages/Dependencies.d.ts +1 -0
  103. package/dist/pages/InfiniteZoom.d.ts +7 -0
  104. package/dist/pages/Settings.d.ts +7 -0
  105. package/dist/pages/ViewEditor/components/EditorMenus.d.ts +24 -0
  106. package/dist/pages/ViewEditor/components/EditorOverlays.d.ts +30 -0
  107. package/dist/pages/ViewEditor/components/EmptyCanvasState.d.ts +7 -0
  108. package/dist/pages/ViewEditor/context.d.ts +13 -0
  109. package/dist/pages/ViewEditor/hooks/useCanvasInteractions.d.ts +201 -0
  110. package/dist/pages/ViewEditor/hooks/useDrawingEngine.d.ts +40 -0
  111. package/dist/pages/ViewEditor/hooks/useViewContextNeighbours.d.ts +20 -0
  112. package/dist/pages/ViewEditor/hooks/useViewData.d.ts +74 -0
  113. package/dist/pages/ViewEditor/index.d.ts +8 -0
  114. package/dist/pages/ViewEditor/utils.d.ts +14 -0
  115. package/dist/pages/Views.d.ts +6 -0
  116. package/dist/pages/ViewsGrid.d.ts +6 -0
  117. package/dist/pkg/importer/mermaid.d.ts +7 -0
  118. package/dist/pkg/importer/mermaid.test.d.ts +1 -0
  119. package/dist/platform/PlatformContext.d.ts +6 -0
  120. package/dist/platform/context.d.ts +3 -0
  121. package/dist/platform/local.d.ts +2 -0
  122. package/dist/platform/types.d.ts +17 -0
  123. package/dist/slots.d.ts +67 -0
  124. package/dist/theme.d.ts +2 -0
  125. package/dist/types/index.d.ts +193 -0
  126. package/dist/types/vscode-messages.d.ts +60 -0
  127. package/dist/utils/edgeDistribution.d.ts +34 -0
  128. package/dist/utils/githubApi.d.ts +4 -0
  129. package/dist/utils/githubCache.d.ts +17 -0
  130. package/dist/utils/ids.d.ts +2 -0
  131. package/dist/utils/technologyCatalog.d.ts +15 -0
  132. package/dist/utils/toast.d.ts +15 -0
  133. package/dist/utils/treesitter.d.ts +13 -0
  134. package/dist/utils/url.d.ts +12 -0
  135. package/package.json +159 -0
  136. package/src/App.tsx +141 -0
  137. package/src/api/client.ts +618 -0
  138. package/src/api/transport-vscode.ts +28 -0
  139. package/src/api/transport.ts +7 -0
  140. package/src/assets/logo-mark.svg +31 -0
  141. package/src/assets/logo-wordmark.svg +22 -0
  142. package/src/assets/logo.svg +35 -0
  143. package/src/components/CodePreviewPanel-vscode.tsx +85 -0
  144. package/src/components/CodePreviewPanel.tsx +384 -0
  145. package/src/components/ConfirmDialog.tsx +66 -0
  146. package/src/components/ConnectorPanel.tsx +403 -0
  147. package/src/components/ContextBoundaryElement.tsx +35 -0
  148. package/src/components/ContextNeighborElement.tsx +282 -0
  149. package/src/components/ContextStraightConnector.tsx +144 -0
  150. package/src/components/CrossBranchControls.tsx +105 -0
  151. package/src/components/DependenciesOnboarding.tsx +427 -0
  152. package/src/components/DrawingCanvas.tsx +391 -0
  153. package/src/components/ElementLibrary-vscode.tsx +9 -0
  154. package/src/components/ElementLibrary.tsx +512 -0
  155. package/src/components/ElementNode.tsx +1033 -0
  156. package/src/components/ElementPanel.tsx +928 -0
  157. package/src/components/ExploreOnboarding.tsx +347 -0
  158. package/src/components/ExplorePageOnboarding.tsx +383 -0
  159. package/src/components/ExportModal.tsx +132 -0
  160. package/src/components/FloatingEdge.tsx +115 -0
  161. package/src/components/GitSourceLinker.tsx +1053 -0
  162. package/src/components/HeaderContext.tsx +30 -0
  163. package/src/components/Icons.tsx +245 -0
  164. package/src/components/ImportModal.tsx +219 -0
  165. package/src/components/InlineElementAdder.tsx +216 -0
  166. package/src/components/LayoutSection.tsx +624 -0
  167. package/src/components/LocalSourceLinker.tsx +330 -0
  168. package/src/components/MiniZoomOnboarding.tsx +78 -0
  169. package/src/components/NavBreadcrumb.tsx +24 -0
  170. package/src/components/NodeBody.tsx +89 -0
  171. package/src/components/NodeContainer.tsx +58 -0
  172. package/src/components/NodeHoverCard.tsx +135 -0
  173. package/src/components/PanelHeader.tsx +36 -0
  174. package/src/components/PanelUI.tsx +24 -0
  175. package/src/components/ProxyConnectorEdge.tsx +169 -0
  176. package/src/components/ProxyConnectorPanel.tsx +130 -0
  177. package/src/components/SafeBackground.tsx +19 -0
  178. package/src/components/ScrollIndicatorWrapper.tsx +117 -0
  179. package/src/components/SetChildModal.tsx +191 -0
  180. package/src/components/SetParentModal.tsx +187 -0
  181. package/src/components/SlidingPanel.tsx +114 -0
  182. package/src/components/TagUpsert.tsx +142 -0
  183. package/src/components/TopMenuBar.tsx +380 -0
  184. package/src/components/ViewBezierConnector.tsx +143 -0
  185. package/src/components/ViewDrawMenu.tsx +270 -0
  186. package/src/components/ViewEditorEdgeLabelLayout.ts +189 -0
  187. package/src/components/ViewEditorOnboarding.tsx +445 -0
  188. package/src/components/ViewExplorer/TagManager/ColorPicker.tsx +49 -0
  189. package/src/components/ViewExplorer/TagManager/GroupNamingPopover.tsx +96 -0
  190. package/src/components/ViewExplorer/TagManager/LayerItem.tsx +228 -0
  191. package/src/components/ViewExplorer/TagManager/TagItem.tsx +242 -0
  192. package/src/components/ViewExplorer/TagManager/index.tsx +418 -0
  193. package/src/components/ViewExplorer/ViewNavigator.tsx +121 -0
  194. package/src/components/ViewExplorer/ViewSearch.tsx +33 -0
  195. package/src/components/ViewExplorer/ViewTree.tsx +98 -0
  196. package/src/components/ViewExplorer/index.tsx +384 -0
  197. package/src/components/ViewExplorer/types.ts +13 -0
  198. package/src/components/ViewExplorer/utils.ts +56 -0
  199. package/src/components/ViewExplorer-vscode.tsx +8 -0
  200. package/src/components/ViewFloatingMenu-vscode.tsx +248 -0
  201. package/src/components/ViewFloatingMenu.tsx +379 -0
  202. package/src/components/ViewGridNode.tsx +451 -0
  203. package/src/components/ViewHeaderButton.tsx +60 -0
  204. package/src/components/ViewPanel.tsx +162 -0
  205. package/src/components/ViewsGridOnboarding.tsx +400 -0
  206. package/src/components/ZUI/ZUICanvas.tsx +853 -0
  207. package/src/components/ZUI/index.ts +3 -0
  208. package/src/components/ZUI/layout.ts +323 -0
  209. package/src/components/ZUI/proxy.ts +278 -0
  210. package/src/components/ZUI/renderer.ts +1189 -0
  211. package/src/components/ZUI/types.ts +150 -0
  212. package/src/components/ZUI/useZUIInteraction.ts +720 -0
  213. package/src/config/runtime-vscode.ts +46 -0
  214. package/src/config/runtime.ts +30 -0
  215. package/src/constants/colors.ts +80 -0
  216. package/src/constants/diagramColors.ts +9 -0
  217. package/src/context/ThemeContext.tsx +158 -0
  218. package/src/crossBranch/graph.ts +207 -0
  219. package/src/crossBranch/resolve.ts +643 -0
  220. package/src/crossBranch/settings.ts +59 -0
  221. package/src/crossBranch/store.ts +71 -0
  222. package/src/crossBranch/types.ts +102 -0
  223. package/src/demo/DemoPage.tsx +184 -0
  224. package/src/demo/seed.ts +67 -0
  225. package/src/demo/store.ts +536 -0
  226. package/src/demo/viewEditor.ts +110 -0
  227. package/src/hooks/useSafeFitView.ts +60 -0
  228. package/src/index.css +309 -0
  229. package/src/index.ts +184 -0
  230. package/src/kafka-ss.png +0 -0
  231. package/src/lib/vscodeBridge-vscode.ts +27 -0
  232. package/src/lib/vscodeBridge.ts +7 -0
  233. package/src/main.tsx +46 -0
  234. package/src/pages/AppearanceSettings.tsx +135 -0
  235. package/src/pages/Dependencies.tsx +926 -0
  236. package/src/pages/InfiniteZoom.tsx +404 -0
  237. package/src/pages/Settings.tsx +91 -0
  238. package/src/pages/ViewEditor/EDGE_DISTRIBUTION.md +64 -0
  239. package/src/pages/ViewEditor/components/EditorMenus.tsx +112 -0
  240. package/src/pages/ViewEditor/components/EditorOverlays.tsx +172 -0
  241. package/src/pages/ViewEditor/components/EmptyCanvasState.tsx +42 -0
  242. package/src/pages/ViewEditor/context.tsx +21 -0
  243. package/src/pages/ViewEditor/hooks/useCanvasInteractions.ts +1349 -0
  244. package/src/pages/ViewEditor/hooks/useDrawingEngine.ts +127 -0
  245. package/src/pages/ViewEditor/hooks/useViewContextNeighbours.ts +501 -0
  246. package/src/pages/ViewEditor/hooks/useViewData.ts +491 -0
  247. package/src/pages/ViewEditor/index.tsx +1366 -0
  248. package/src/pages/ViewEditor/utils.ts +88 -0
  249. package/src/pages/Views.tsx +171 -0
  250. package/src/pages/ViewsGrid.tsx +1310 -0
  251. package/src/pkg/importer/mermaid.test.ts +141 -0
  252. package/src/pkg/importer/mermaid.ts +76 -0
  253. package/src/platform/PlatformContext.tsx +17 -0
  254. package/src/platform/context.ts +9 -0
  255. package/src/platform/local.tsx +15 -0
  256. package/src/platform/types.ts +19 -0
  257. package/src/slots.ts +92 -0
  258. package/src/styles/editor-panels.css +66 -0
  259. package/src/styles/theme.css +56 -0
  260. package/src/theme.ts +336 -0
  261. package/src/types/index.ts +234 -0
  262. package/src/types/offline-ambient.d.ts +14 -0
  263. package/src/types/vscode-messages.ts +32 -0
  264. package/src/utils/edgeDistribution.ts +103 -0
  265. package/src/utils/githubApi.ts +121 -0
  266. package/src/utils/githubCache.ts +108 -0
  267. package/src/utils/ids.ts +9 -0
  268. package/src/utils/technologyCatalog.ts +143 -0
  269. package/src/utils/toast.ts +100 -0
  270. package/src/utils/treesitter.ts +147 -0
  271. package/src/utils/url.ts +72 -0
  272. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,1310 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import { SafeBackground } from '../components/SafeBackground'
4
+ import { Text as HeaderText } from '@chakra-ui/react'
5
+ import ReactFlow, {
6
+ BackgroundVariant,
7
+ ReactFlowProvider,
8
+ useReactFlow,
9
+ useStore,
10
+ type Edge as RFEdge,
11
+ type Node as RFNode,
12
+ } from 'reactflow'
13
+ import FloatingEdge from '../components/FloatingEdge'
14
+ import 'reactflow/dist/style.css'
15
+ import { useSetHeader } from '../components/HeaderContext'
16
+ import {
17
+ Box,
18
+ Button,
19
+ Flex,
20
+ FormControl,
21
+ FormLabel,
22
+ Heading,
23
+ HStack,
24
+ IconButton,
25
+ Input,
26
+ InputGroup,
27
+ InputLeftElement,
28
+ InputRightElement,
29
+ Modal,
30
+ ModalBody,
31
+ ModalContent,
32
+ ModalFooter,
33
+ ModalHeader,
34
+ ModalOverlay,
35
+ Spinner,
36
+ Text,
37
+ useDisclosure,
38
+ useBreakpointValue,
39
+ } from '@chakra-ui/react'
40
+ import { motion, AnimatePresence } from 'framer-motion'
41
+ import { SearchIcon, CloseIcon, AddIcon } from '@chakra-ui/icons'
42
+ import { api } from '../api/client'
43
+ import { toast } from '../utils/toast'
44
+ import type { ViewTreeNode } from '../types'
45
+ import ViewPanel from '../components/ViewPanel'
46
+ import ConfirmDialog from '../components/ConfirmDialog'
47
+ import ViewGridNode, { type ViewGridNodeData } from '../components/ViewGridNode'
48
+ import { useAccentColor } from '../context/ThemeContext'
49
+ import { hexToRgba } from '../constants/colors'
50
+
51
+ // ── Tree helpers ──────────────────────────────────────────────────────────────
52
+
53
+ function flattenTree(roots: ViewTreeNode[]): ViewTreeNode[] {
54
+ const result: ViewTreeNode[] = []
55
+ const traverse = (node: ViewTreeNode) => {
56
+ result.push(node)
57
+ node.children.forEach(traverse)
58
+ }
59
+ roots.forEach(traverse)
60
+ return result
61
+ }
62
+
63
+ // ── Layout algorithm ──────────────────────────────────────────────────────────
64
+
65
+ const CELL_W = 260
66
+ const CELL_H = 150
67
+ const GAP_H = 80
68
+ const GAP_V = 120
69
+
70
+ function subtreeWidth(node: ViewTreeNode): number {
71
+ if (node.children.length === 0) return 1
72
+ return node.children.reduce((sum, c) => sum + subtreeWidth(c), 0)
73
+ }
74
+
75
+ function buildDescendantSets(roots: ViewTreeNode[]): Map<number, Set<number>> {
76
+ const map = new Map<number, Set<number>>()
77
+
78
+ function visit(node: ViewTreeNode): Set<number> {
79
+ const set = new Set([node.id])
80
+ node.children.forEach((child) => {
81
+ const childSet = visit(child)
82
+ childSet.forEach((id) => set.add(id))
83
+ })
84
+ map.set(node.id, set)
85
+ return set
86
+ }
87
+
88
+ roots.forEach(visit)
89
+ return map
90
+ }
91
+
92
+ /**
93
+ * Compute layout positions.
94
+ *
95
+ * Y-axis: node.depth (= node.level) - honours manual level overrides so a
96
+ * diagram at L2 is always rendered in the L2 row even if its parent
97
+ * is at L0.
98
+ *
99
+ * X-axis: column derived from the tree-walk (pre-order rank within each
100
+ * level band), then a de-overlap pass shifts any colliding nodes and
101
+ * their subtrees rightward so nothing overlaps on the same row.
102
+ */
103
+ function computeLayout(roots: ViewTreeNode[]): Map<number, { x: number; y: number }> {
104
+ const positions = new Map<number, { x: number; y: number }>()
105
+ const flat: ViewTreeNode[] = []
106
+ const visit = (n: ViewTreeNode) => { flat.push(n); n.children.forEach(visit) }
107
+ roots.forEach(visit)
108
+
109
+ if (flat.length === 0) return positions
110
+
111
+ // ── Step 1: initial column assignment via tree walk ─────────────────────────
112
+ function layoutNode(node: ViewTreeNode, startCol: number) {
113
+ const w = subtreeWidth(node)
114
+ const centerCol = startCol + (w - 1) / 2
115
+ positions.set(node.id, {
116
+ x: centerCol * (CELL_W + GAP_H),
117
+ y: node.depth * (CELL_H + GAP_V),
118
+ })
119
+ let childStart = startCol
120
+ for (const child of node.children) {
121
+ layoutNode(child, childStart)
122
+ childStart += subtreeWidth(child)
123
+ }
124
+ }
125
+ let col = 0
126
+ for (const root of roots) {
127
+ layoutNode(root, col)
128
+ col += subtreeWidth(root)
129
+ }
130
+ // ── Step 2: build descendant sets so we can shift whole subtrees ────────────
131
+ const descendants = buildDescendantSets(roots)
132
+
133
+ // ── Step 3: de-overlap pass - per Y row (top-down), fix X collisions ────────
134
+ const STEP = CELL_W + GAP_H
135
+ const byY = new Map<number, number[]>()
136
+ flat.forEach((n) => {
137
+ const y = n.depth * (CELL_H + GAP_V)
138
+ if (!byY.has(y)) byY.set(y, [])
139
+ byY.get(y)!.push(n.id)
140
+ })
141
+
142
+ // Process rows top-down (ascending Y) so parent shifts propagate downward first
143
+ const sortedYRows = Array.from(byY.entries()).sort(([ya], [yb]) => ya - yb)
144
+
145
+ for (const [rowY, ids] of sortedYRows) {
146
+ // Snapshot original X values before any mutations in this row -
147
+ // this prevents a just-shifted node's new position from cascading
148
+ // into the next comparison and wrongly pushing correct neighbors right.
149
+ const origX = new Map<number, number>(ids.map((id) => [id, positions.get(id)?.x ?? 0]))
150
+ ids.sort((a, b) => (origX.get(a) ?? 0) - (origX.get(b) ?? 0))
151
+
152
+ let rightmostX = origX.get(ids[0]) ?? 0
153
+
154
+ for (let i = 1; i < ids.length; i++) {
155
+ const originalX = origX.get(ids[i]) ?? 0
156
+ const placedX = Math.max(originalX, rightmostX + STEP)
157
+
158
+ if (placedX > originalX) {
159
+ const delta = placedX - originalX
160
+ const toShift = descendants.get(ids[i]) ?? new Set([ids[i]])
161
+ toShift.forEach((sid) => {
162
+ const p = positions.get(sid)
163
+ if (!p) return
164
+ if (p.y === rowY && sid !== ids[i]) return
165
+ positions.set(sid, { x: p.x + delta, y: p.y })
166
+ })
167
+ }
168
+
169
+ rightmostX = placedX
170
+ }
171
+ }
172
+
173
+ return positions
174
+ }
175
+
176
+
177
+
178
+
179
+
180
+ function DepthBoundaryNode({ data }: { data: { width: number; depth: number; isReparenting?: boolean; onLevelClick?: () => void; isActive?: boolean } }) {
181
+ return (
182
+ <Box
183
+ w={`${data.width}px`}
184
+ h="20px"
185
+ position="relative"
186
+ pointerEvents={data.isReparenting ? 'auto' : 'none'}
187
+ userSelect="none"
188
+ display="flex"
189
+ alignItems="center"
190
+ cursor={data.isReparenting ? 'crosshair' : 'default'}
191
+ onClick={(e) => {
192
+ if (data.isReparenting && data.onLevelClick) {
193
+ e.stopPropagation()
194
+ data.onLevelClick()
195
+ }
196
+ }}
197
+ transition="background 0.2s"
198
+ _hover={data.isReparenting ? { bg: 'whiteAlpha.50' } : undefined}
199
+ >
200
+ <Box
201
+ w="100%"
202
+ h="1px"
203
+ borderTop="1px dashed"
204
+ borderColor={data.isActive ? 'whiteAlpha.900' : (data.isReparenting ? 'var(--accent)' : 'whiteAlpha.400')}
205
+ opacity={data.isActive ? 1 : (data.isReparenting ? 0.8 : 0.4)}
206
+ transition="all 0.2s"
207
+ />
208
+ </Box>
209
+ )
210
+ }
211
+
212
+ function ViewGridSidebar({ maxDepth, isReparenting, onLevelClick, activeLevel }: { maxDepth: number; isReparenting: boolean; onLevelClick: (level: number) => void; activeLevel?: number | null }) {
213
+ const rowHeight = CELL_H + GAP_V
214
+ const levelCount = Math.max(maxDepth + 2, 4)
215
+ const transform = useStore((s) => s.transform)
216
+ const [, translateY, zoom] = transform
217
+
218
+ return (
219
+ <Box
220
+ position="absolute"
221
+ left={0}
222
+ top={0}
223
+ bottom={0}
224
+ w="120px"
225
+ pointerEvents="none"
226
+ zIndex={10}
227
+ overflow="hidden"
228
+ >
229
+
230
+ {/* Layers Container - follows the zoom and pan of the grid */}
231
+ <Box
232
+ position="absolute"
233
+ left={0}
234
+ right={0}
235
+ top={`${translateY}px`}
236
+ transform={`scale(${zoom})`}
237
+ transformOrigin="top left"
238
+ h={`${levelCount * rowHeight}px`}
239
+ >
240
+ {Array.from({ length: levelCount }).map((_, i) => {
241
+ const isActive = activeLevel === i
242
+ return (
243
+ <Flex
244
+ key={`layer-${i}`}
245
+ position="absolute"
246
+ top={`${i * rowHeight + 75}px`}
247
+ transform="translateY(-50%)"
248
+ left="0"
249
+ right="20px"
250
+ h="140px"
251
+ align="center"
252
+ justify="flex-end"
253
+ cursor={isReparenting ? 'pointer' : 'default'}
254
+ pointerEvents="auto"
255
+ onClick={(e) => {
256
+ if (isReparenting) {
257
+ e.stopPropagation()
258
+ onLevelClick(i)
259
+ }
260
+ }}
261
+ transition="all 0.4s cubic-bezier(0.4, 0, 0.2, 1)"
262
+ role="group"
263
+ _hover={isReparenting ? { transform: 'translateY(-50%) scale(1.05)', bg: 'whiteAlpha.50' } : {}}
264
+ >
265
+ {/* Technical Tick */}
266
+ <Box
267
+ position="absolute"
268
+ top="50%"
269
+ right="-20px"
270
+ w={isReparenting || isActive ? "40px" : "20px"}
271
+ h="1px"
272
+ bg={isActive ? 'whiteAlpha.900' : (isReparenting ? 'var(--accent)' : "whiteAlpha.400")}
273
+ transition="all 0.4s"
274
+ _after={{
275
+ content: '""',
276
+ position: 'absolute',
277
+ top: '-2.5px',
278
+ right: '0',
279
+ w: '6px',
280
+ h: '6px',
281
+ borderRadius: 'full',
282
+ bg: isActive ? 'whiteAlpha.900' : (isReparenting ? 'var(--accent)' : "whiteAlpha.400"),
283
+ }}
284
+ />
285
+
286
+ <Box textAlign="left" pr={2}>
287
+ <Heading
288
+ fontSize="100px"
289
+ fontWeight="900"
290
+ color={isActive ? 'whiteAlpha.900' : (isReparenting ? 'var(--accent)' : "whiteAlpha.100")}
291
+ fontFamily="heading"
292
+ lineHeight="1"
293
+ letterSpacing="-0.06em"
294
+ transition="all 0.4s"
295
+ style={{
296
+ WebkitTextStroke: i === 0 || isReparenting || isActive ? 'none' : '1px rgba(255,255,255,0.1)',
297
+ }}
298
+ _groupHover={(isReparenting || isActive) ? { transform: 'scale(1.1)' } : {}}
299
+ >
300
+ {i}
301
+ </Heading>
302
+ </Box>
303
+ </Flex>
304
+ )
305
+ })}
306
+ </Box>
307
+ </Box>
308
+ )
309
+ }
310
+
311
+ // ── Depth boundary separator nodes ──────────────────────────────────────────
312
+
313
+
314
+ // ── Node types (stable module-level constant) ─────────────────────────────────
315
+
316
+ const NODE_TYPES = { diagramGrid: ViewGridNode, depthBoundary: DepthBoundaryNode }
317
+ const EDGE_TYPES = { floating: FloatingEdge }
318
+
319
+ // Hierarchy edges: muted neutral - structure without color noise
320
+ const HIERARCHY_EDGE_COLOR = 'rgba(255,255,255,0.2)'
321
+
322
+ // ── Props ─────────────────────────────────────────────────────────────────────
323
+
324
+ interface Props {
325
+ onShare?: (viewId: number) => void
326
+ }
327
+
328
+ // ── Root component - provides ReactFlow context ───────────────────────────────
329
+
330
+ export default function ViewsGrid({ onShare }: Props) {
331
+ return (
332
+ <ReactFlowProvider>
333
+ <ViewGridInner onShare={onShare} />
334
+ </ReactFlowProvider>
335
+ )
336
+ }
337
+
338
+ // ── Inner component - has access to useReactFlow() ────────────────────────────
339
+
340
+ function ViewGridInner({ onShare }: Props) {
341
+ const isMobileLayout = useBreakpointValue({ base: true, md: false }) ?? false
342
+ const navigate = useNavigate()
343
+ const { accent } = useAccentColor()
344
+ const canEdit = true
345
+ const setHeader = useSetHeader()
346
+
347
+ useEffect(() => {
348
+ setHeader({ node: <HeaderText fontWeight="medium" fontSize="sm" color="gray.300">View Hierarchy</HeaderText> })
349
+ return () => setHeader(null)
350
+ }, [setHeader])
351
+
352
+ const { setCenter, getViewport, zoomIn, zoomOut } = useReactFlow()
353
+ const rfContainerRef = useRef<HTMLDivElement>(null)
354
+
355
+ // ── Trackpad gesture detection: suppress zoom during two-finger pan ────────
356
+ const touchStateRef = useRef<{ lastMultiTouchWheelTime: number }>({
357
+ lastMultiTouchWheelTime: 0,
358
+ })
359
+
360
+ // Native capture-phase wheel listener so we intercept before ReactFlow's
361
+ // internal handlers. passive:false lets us call preventDefault().
362
+ useEffect(() => {
363
+ const el = rfContainerRef.current
364
+ if (!el) return
365
+ function onWheel(e: WheelEvent) {
366
+ // Track multi-touch wheel events (deltaX !== 0 indicates two-finger contact)
367
+ if (e.deltaX !== 0) {
368
+ touchStateRef.current.lastMultiTouchWheelTime = Date.now()
369
+ }
370
+
371
+ // If we just finished a multi-touch gesture, suppress zoom for ~1000ms (trackpad momentum can last longer)
372
+ const isRecentMultiTouch = Date.now() - touchStateRef.current.lastMultiTouchWheelTime < 1000
373
+
374
+ // Only zoom on notched wheel (mouse), not trackpad
375
+ const isNotchedWheel = !e.ctrlKey && e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 20
376
+ const isMouseWheel = e.deltaMode !== 0 || isNotchedWheel
377
+
378
+ if (isMouseWheel && !isRecentMultiTouch) {
379
+ e.preventDefault()
380
+ e.stopPropagation()
381
+ if (e.deltaY > 0) zoomOut()
382
+ else zoomIn()
383
+ }
384
+ }
385
+ el.addEventListener('wheel', onWheel, { passive: false, capture: true })
386
+ return () => el.removeEventListener('wheel', onWheel, { capture: true })
387
+ }, [zoomIn, zoomOut])
388
+
389
+ // ── Core state ──────────────────────────────────────────────────────────────
390
+ const [treeData, setTreeData] = useState<ViewTreeNode[]>([])
391
+ const [loading, setLoading] = useState(true)
392
+
393
+ // ── Derived tree structures ─────────────────────────────────────────────────
394
+ const roots = useMemo(() => treeData, [treeData])
395
+ const flatTree = useMemo(() => flattenTree(roots), [roots])
396
+
397
+ const [focusedId, setFocusedId] = useState<number | null>(null)
398
+ const [searchTerm, setSearchTerm] = useState('')
399
+ const [searchResults, setSearchResults] = useState<ViewTreeNode[]>([])
400
+ const [activeSearchIndex, setActiveSearchIndex] = useState(-1)
401
+
402
+ const handleSearch = (term: string) => {
403
+ setSearchTerm(term)
404
+ if (term.trim().length < 3) {
405
+ setSearchResults([])
406
+ setActiveSearchIndex(-1)
407
+ return
408
+ }
409
+
410
+ const matches = flatTree
411
+ .filter(n => n.name.toLowerCase().includes(term.toLowerCase()))
412
+ .slice(0, 5)
413
+
414
+ setSearchResults(matches)
415
+ if (matches.length > 0) {
416
+ setActiveSearchIndex(0)
417
+ setFocusedId(matches[0].id)
418
+ } else {
419
+ setActiveSearchIndex(-1)
420
+ }
421
+ }
422
+
423
+ const handleSearchKeyDown = (e: React.KeyboardEvent) => {
424
+ if (searchResults.length === 0) return
425
+
426
+ if (e.key === 'ArrowDown') {
427
+ e.preventDefault()
428
+ const nextIndex = (activeSearchIndex + 1) % searchResults.length
429
+ setActiveSearchIndex(nextIndex)
430
+ setFocusedId(searchResults[nextIndex].id)
431
+ } else if (e.key === 'ArrowUp') {
432
+ e.preventDefault()
433
+ const nextIndex = (activeSearchIndex - 1 + searchResults.length) % searchResults.length
434
+ setActiveSearchIndex(nextIndex)
435
+ setFocusedId(searchResults[nextIndex].id)
436
+ } else if (e.key === 'Enter') {
437
+ if (activeSearchIndex >= 0) {
438
+ navigate(`/views/${searchResults[activeSearchIndex].id}`)
439
+ }
440
+ } else if (e.key === 'Escape') {
441
+ setSearchResults([])
442
+ setActiveSearchIndex(-1)
443
+ }
444
+ }
445
+
446
+ // Rename
447
+ const [editingId, setEditingId] = useState<number | null>(null)
448
+ const [editName, setEditName] = useState('')
449
+
450
+ // Counts cache
451
+ const [countsByView, setCountsByDiagram] = useState<Record<number, { nodes: number; edges: number }>>({})
452
+
453
+ // Onboarding wizard
454
+ const [onboardingStep, setOnboardingStep] = useState<0 | 1 | 2>(0)
455
+ const [onboardingName, setOnboardingName] = useState('My First Diagram')
456
+ const [onboardingViewId, setOnboardingDiagramId] = useState<number | null>(null)
457
+ const [onboardingCreating, setOnboardingCreating] = useState(false)
458
+
459
+ // Details drawer
460
+ const [detailsView, setDetailsDiagram] = useState<ViewTreeNode | null>(null)
461
+ const [detailsLoading, setDetailsLoading] = useState(false)
462
+ const { isOpen: isDetailsOpen, onOpen: onDetailsOpen, onClose: onDetailsClose } = useDisclosure()
463
+
464
+ // New diagram creation
465
+ const { isOpen: isCreateOpen, onOpen: onCreateOpen, onClose: onCreateClose } = useDisclosure()
466
+ const [newName, setNewName] = useState('')
467
+ const [isCreating, setIsCreating] = useState(false)
468
+
469
+ const handleCreate = async () => {
470
+ if (!newName.trim()) return
471
+ setIsCreating(true)
472
+ try {
473
+ const d = await api.workspace.views.create({ name: newName.trim() })
474
+ await refresh()
475
+ navigate(`/views/${d.id}`)
476
+ onCreateClose()
477
+ setNewName('')
478
+ } catch (err: unknown) {
479
+ toast({ title: 'Failed to create diagram', description: err instanceof Error ? err.message : 'An unexpected error occurred', status: 'error' })
480
+ } finally {
481
+ setIsCreating(false)
482
+ }
483
+ }
484
+
485
+ // Delete dialog
486
+ const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null)
487
+ const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure()
488
+
489
+ // Level change mode
490
+ const [levelEditingNodeId, setLevelEditingNodeId] = useState<number | null>(null)
491
+
492
+ // Share modal
493
+ // ── Data fetching ───────────────────────────────────────────────────────────
494
+ const refresh = useCallback(async () => {
495
+ const tree = await api.workspace.views.tree().catch(() => null)
496
+ if (tree) {
497
+ setTreeData(tree)
498
+ if (tree.length === 0 && !localStorage.getItem('onboarding_shown')) {
499
+ localStorage.setItem('onboarding_shown', '1')
500
+ setOnboardingStep(1)
501
+ }
502
+ }
503
+ setLoading(false)
504
+ }, [])
505
+
506
+ useEffect(() => { refresh() }, [refresh])
507
+
508
+ // Fetch node/edge counts
509
+ useEffect(() => {
510
+ let cancelled = false
511
+ const ids = flatTree.map((n) => n.id)
512
+ if (ids.length === 0) { setCountsByDiagram({}); return }
513
+ ; (async () => {
514
+ const next: Record<number, { nodes: number; edges: number }> = {}
515
+ await Promise.all(
516
+ ids.map(async (id) => {
517
+ try {
518
+ const [objs, edges] = await Promise.all([
519
+ api.workspace.views.placements.list(id),
520
+ api.workspace.connectors.list(id),
521
+ ])
522
+ next[id] = { nodes: objs.length, edges: edges.length }
523
+ } catch { /* ignore per-diagram failure */ }
524
+ })
525
+ )
526
+ if (!cancelled) setCountsByDiagram((prev) => ({ ...prev, ...next }))
527
+ })()
528
+ return () => { cancelled = true }
529
+ }, [flatTree])
530
+
531
+ // ── Rename ──────────────────────────────────────────────────────────────────
532
+ const startEdit = useCallback((id: number, name: string) => {
533
+ setEditingId(id)
534
+ setEditName(name)
535
+ }, [])
536
+
537
+ const commitEdit = useCallback(async () => {
538
+ const id = editingId
539
+ const name = editName.trim()
540
+ setEditingId(null)
541
+ if (id === null || !name) return
542
+ const prev = treeData.find((n) => n.id === id)
543
+ if (!prev || prev.name === name) return
544
+ setTreeData((d) => d.map((n) => (n.id === id ? { ...n, name } : n)))
545
+ await api.workspace.views.rename(id, name).catch(() =>
546
+ setTreeData((d) => d.map((n) => (n.id === id ? { ...n, name: prev.name } : n)))
547
+ )
548
+ }, [editingId, editName, treeData])
549
+
550
+ const cancelEdit = useCallback(() => setEditingId(null), [])
551
+
552
+ // ── Details ─────────────────────────────────────────────────────────────────
553
+ const handleDetailsOpen = useCallback(async (diagId: number) => {
554
+ setDetailsLoading(true)
555
+ onDetailsOpen()
556
+ try {
557
+ const d = await api.workspace.views.get(diagId)
558
+ setDetailsDiagram(d)
559
+ } catch { /* ignore */ } finally {
560
+ setDetailsLoading(false)
561
+ }
562
+ }, [onDetailsOpen])
563
+
564
+ const handleDetailsSave = useCallback((updated: ViewTreeNode) => {
565
+ setTreeData((prev) =>
566
+ prev.map((n) =>
567
+ n.id === updated.id
568
+ ? { ...n, name: updated.name, level_label: updated.level_label }
569
+ : n
570
+ )
571
+ )
572
+ }, [])
573
+
574
+ // ── Delete ──────────────────────────────────────────────────────────────────
575
+ const handleDeleteConfirm = async () => {
576
+ if (!deleteTargetId) return
577
+ try {
578
+ await api.workspace.views.delete('', deleteTargetId)
579
+ setTreeData((prev) => prev.filter((n) => n.id !== deleteTargetId))
580
+ } catch { /* ignore */ }
581
+ onDeleteClose()
582
+ setDeleteTargetId(null)
583
+ }
584
+
585
+ const handleSetLevel = useCallback(async (level: number) => {
586
+ if (!levelEditingNodeId) return
587
+ const id = levelEditingNodeId
588
+ const node = treeData.find((n) => n.id === id)
589
+ if (!node) return
590
+
591
+ // Validate: must be strictly greater than parent's level
592
+ if (node.parent_view_id !== null) {
593
+ const parent = treeData.find((n) => n.id === node.parent_view_id)
594
+ if (parent && level <= parent.level) {
595
+ toast({ title: `Level must be > parent's level (L${parent.level})`, status: 'warning', duration: 3000, isClosable: true })
596
+ return
597
+ }
598
+ }
599
+
600
+ // Validate: must be strictly less than all direct children's levels
601
+ const childLevels = treeData.filter((n) => n.parent_view_id === id).map((n) => n.level)
602
+ if (childLevels.length > 0 && level >= Math.min(...childLevels)) {
603
+ toast({ title: `Level must be < children's levels (min L${Math.min(...childLevels)})`, status: 'warning', duration: 3000, isClosable: true })
604
+ return
605
+ }
606
+
607
+ setLevelEditingNodeId(null)
608
+ // Optimistically update locally
609
+ setTreeData((d) => d.map((n) => (n.id === id ? { ...n, level } : n)))
610
+ try {
611
+ await api.workspace.views.setLevel(id, level)
612
+ } catch {
613
+ // global error toast will show
614
+ }
615
+ await refresh()
616
+ }, [levelEditingNodeId, treeData, refresh])
617
+
618
+ const handleOnboardingCreate = async () => {
619
+ setOnboardingCreating(true)
620
+ try {
621
+ const d = await api.workspace.views.create({ name: onboardingName.trim() || 'My First Diagram' })
622
+ setOnboardingDiagramId(d.id)
623
+ await refresh()
624
+ setOnboardingStep(2)
625
+ } catch { /* ignore */ } finally {
626
+ setOnboardingCreating(false)
627
+ }
628
+ }
629
+
630
+ // ── RF nodes - pure derivation, no useState/useEffect ───────────────────────
631
+ const layoutPositions = useMemo(() => computeLayout(roots), [roots])
632
+
633
+ // Stable during drag (layoutPositions only changes after treeData refresh, never on mouse moves)
634
+ const computedMinZoom = useMemo(() => {
635
+ if (layoutPositions.size === 0) return 0.2
636
+ let minY = Infinity, maxY = -Infinity
637
+ layoutPositions.forEach(({ y }) => {
638
+ if (y < minY) minY = y
639
+ if (y + CELL_H > maxY) maxY = y + CELL_H
640
+ })
641
+ const bboxH = maxY - minY
642
+ let z = window.innerHeight / (Math.max(1, bboxH) * 1.2)
643
+ if (!isFinite(z) || isNaN(z) || z <= 0) z = 0.1
644
+ return Math.max(0.05, Math.min(z, 0.8))
645
+ }, [layoutPositions])
646
+
647
+ const computedTranslateExtent = useMemo((): [[number, number], [number, number]] | undefined => {
648
+ if (layoutPositions.size === 0) return undefined
649
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
650
+ layoutPositions.forEach(({ x, y }) => {
651
+ if (x < minX) minX = x
652
+ if (y < minY) minY = y
653
+ if (x + CELL_W > maxX) maxX = x + CELL_W
654
+ if (y + CELL_H > maxY) maxY = y + CELL_H
655
+ })
656
+ const panMarginX = Math.max(window.innerWidth, 1000)
657
+ const panMarginY = Math.max(window.innerHeight, 1000)
658
+ return [
659
+ [minX - panMarginX, minY - panMarginY],
660
+ [maxX + panMarginX, maxY + panMarginY],
661
+ ]
662
+ }, [layoutPositions])
663
+ const maxDepth = useMemo(
664
+ () => flatTree.reduce((max, n) => Math.max(max, n.depth), 0),
665
+ [flatTree]
666
+ )
667
+
668
+ // ── WASD navigation targets (IDs of the 4 navigable neighbors) ─────────────
669
+ const wasdTargets = useMemo(() => {
670
+ if (focusedId === null) return {} as Record<number, 'w' | 'a' | 's' | 'd'>
671
+ const node = flatTree.find((n) => n.id === focusedId)
672
+ if (!node) return {} as Record<number, 'w' | 'a' | 's' | 'd'>
673
+ const siblings = flatTree.filter((n) => n.parent_view_id === node.parent_view_id)
674
+ const idx = siblings.findIndex((n) => n.id === focusedId)
675
+ const targets: Record<number, 'w' | 'a' | 's' | 'd'> = {}
676
+ if (node.parent_view_id !== null) targets[node.parent_view_id] = 'w'
677
+ const firstChild = flatTree.find((n) => n.parent_view_id === focusedId)
678
+ if (firstChild) targets[firstChild.id] = 's'
679
+ if (idx > 0) targets[siblings[idx - 1].id] = 'a'
680
+ if (idx < siblings.length - 1) targets[siblings[idx + 1].id] = 'd'
681
+ return targets
682
+ }, [focusedId, flatTree])
683
+
684
+ const rfNodes = useMemo((): RFNode[] =>
685
+ flatTree.map((n): RFNode => ({
686
+ id: String(n.id),
687
+ type: 'diagramGrid',
688
+ position: layoutPositions.get(n.id) ?? { x: 0, y: 0 },
689
+ data: {
690
+ id: n.id,
691
+ name: n.name,
692
+ level_label: n.level_label,
693
+ counts: countsByView[n.id],
694
+ focused: focusedId === n.id,
695
+ canEdit,
696
+ isEditing: editingId === n.id,
697
+ editName,
698
+ onFocus: () => setFocusedId(n.id),
699
+ onOpen: () => navigate(`/views/${n.id}`),
700
+ onStartRename: () => startEdit(n.id, n.name),
701
+ onDetails: () => handleDetailsOpen(n.id),
702
+ onDelete: () => { setDeleteTargetId(n.id); onDeleteOpen() },
703
+ onShare: onShare ? () => onShare(n.id) : () => {},
704
+ onEditNameChange: setEditName,
705
+ onEditCommit: commitEdit,
706
+ onEditCancel: cancelEdit,
707
+ isMobile: isMobileLayout,
708
+ wasdKey: wasdTargets[n.id],
709
+ } satisfies ViewGridNodeData,
710
+ draggable: false,
711
+ })),
712
+ // eslint-disable-next-line react-hooks/exhaustive-deps
713
+ [flatTree, layoutPositions, focusedId, countsByView,
714
+ editingId, editName, canEdit, navigate, startEdit, handleDetailsOpen,
715
+ commitEdit, cancelEdit, onDeleteOpen,
716
+ wasdTargets, levelEditingNodeId]
717
+ )
718
+
719
+ // ── Depth boundary separator nodes ──────────────────────────────────────────
720
+ const depthBoundaryNodes = useMemo((): RFNode[] => {
721
+ if (levelEditingNodeId === null || maxDepth < 1 || layoutPositions.size === 0) return []
722
+ let minX = Infinity, maxX = -Infinity
723
+ layoutPositions.forEach(({ x }) => {
724
+ if (x < minX) minX = x
725
+ if (x + CELL_W > maxX) maxX = x + CELL_W
726
+ })
727
+ const startX = minX - 3 * GAP_H
728
+ const totalW = maxX - minX + 5 * GAP_H
729
+ const editingNode = flatTree.find((n) => n.id === levelEditingNodeId)
730
+ const activeLevel = editingNode?.level ?? null
731
+
732
+ return Array.from({ length: maxDepth + 2 }, (_, i) => {
733
+ const depth = i
734
+ return {
735
+ id: `__depth_${depth}`,
736
+ type: 'depthBoundary',
737
+ position: { x: startX, y: depth * (CELL_H + GAP_V) - GAP_V / 2 - 8 },
738
+ data: {
739
+ width: totalW,
740
+ depth,
741
+ isReparenting: true,
742
+ onLevelClick: () => handleSetLevel(depth),
743
+ isActive: activeLevel === depth || activeLevel === depth - 1,
744
+ },
745
+ draggable: false,
746
+ selectable: false,
747
+ focusable: false,
748
+ style: { zIndex: 0 },
749
+ } as RFNode
750
+ })
751
+ }, [maxDepth, layoutPositions, levelEditingNodeId, flatTree, handleSetLevel])
752
+
753
+ const allRfNodes = useMemo(
754
+ () => levelEditingNodeId !== null ? [...depthBoundaryNodes, ...rfNodes] : rfNodes,
755
+ [rfNodes, depthBoundaryNodes, levelEditingNodeId]
756
+ )
757
+
758
+ // ── RF edges ────────────────────────────────────────────────────────────────
759
+ const rfEdges = useMemo((): RFEdge[] =>
760
+ flatTree
761
+ .filter((n) => n.parent_view_id)
762
+ .map((n) => ({
763
+ id: `${n.parent_view_id}-${n.id}`,
764
+ source: String(n.parent_view_id!),
765
+ target: String(n.id),
766
+ type: 'floating',
767
+ animated: false,
768
+ data: { color: HIERARCHY_EDGE_COLOR, dashed: false },
769
+ })),
770
+ [flatTree]
771
+ )
772
+
773
+ const allRfEdges = rfEdges
774
+
775
+ // ── WASD keyboard navigation ────────────────────────────────────────────────
776
+ useEffect(() => {
777
+ const handler = (e: KeyboardEvent) => {
778
+ const tag = (e.target as HTMLElement).tagName
779
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return
780
+
781
+ if (e.key === 'Escape') {
782
+ if (levelEditingNodeId !== null) {
783
+ setLevelEditingNodeId(null)
784
+ } else {
785
+ setFocusedId(null)
786
+ }
787
+ return
788
+ }
789
+
790
+ if (e.key === 'Enter' && focusedId) { navigate(`/views/${focusedId}`); return }
791
+
792
+ const isNav = ['w', 'W', 's', 'S', 'a', 'A', 'd', 'D'].includes(e.key)
793
+ if (!isNav) return
794
+
795
+ // Auto-select first card if nothing is focused yet
796
+ if (!focusedId) {
797
+ if (flatTree.length > 0) setFocusedId(flatTree[0].id)
798
+ return
799
+ }
800
+
801
+ const node = flatTree.find((n) => n.id === focusedId)
802
+ if (!node) return
803
+
804
+ let nextId: number | null = null
805
+ if (e.key === 'w' || e.key === 'W') {
806
+ nextId = node.parent_view_id ?? null
807
+ } else if (e.key === 's' || e.key === 'S') {
808
+ nextId = flatTree.find((n) => n.parent_view_id === focusedId)?.id ?? null
809
+ } else if (e.key === 'a' || e.key === 'A') {
810
+ const siblings = flatTree.filter((n) => n.parent_view_id === node.parent_view_id)
811
+ const idx = siblings.findIndex((n) => n.id === focusedId)
812
+ nextId = idx > 0 ? siblings[idx - 1].id : null
813
+ } else if (e.key === 'd' || e.key === 'D') {
814
+ const siblings = flatTree.filter((n) => n.parent_view_id === node.parent_view_id)
815
+ const idx = siblings.findIndex((n) => n.id === focusedId)
816
+ nextId = idx < siblings.length - 1 ? siblings[idx + 1].id : null
817
+ }
818
+
819
+ if (nextId) setFocusedId(nextId)
820
+ }
821
+
822
+ window.addEventListener('keydown', handler)
823
+ return () => window.removeEventListener('keydown', handler)
824
+ }, [focusedId, flatTree, navigate, levelEditingNodeId])
825
+
826
+ // ── Camera: pan to focused node only when it's out of view ──────────────────
827
+ useEffect(() => {
828
+ if (!focusedId) return
829
+ const pos = layoutPositions.get(focusedId)
830
+ if (!pos) return
831
+ const t = setTimeout(() => {
832
+ const { x: vpX, y: vpY, zoom } = getViewport()
833
+ // Convert node screen-space bounds and check if comfortably inside the viewport
834
+ const margin = 80
835
+ const sl = pos.x * zoom + vpX
836
+ const st = pos.y * zoom + vpY
837
+ const sr = (pos.x + CELL_W) * zoom + vpX
838
+ const sb = (pos.y + CELL_H) * zoom + vpY
839
+ const cw = window.innerWidth
840
+ const ch = window.innerHeight
841
+ const inView = sl > margin && st > margin && sr < cw - margin && sb < ch - margin
842
+ if (inView) return
843
+ setCenter(
844
+ pos.x + CELL_W / 2,
845
+ pos.y + CELL_H / 2,
846
+ { duration: 650, zoom: Math.max(zoom, 0.75) }
847
+ )
848
+ }, 30)
849
+ return () => clearTimeout(t)
850
+ }, [focusedId, layoutPositions, setCenter, getViewport])
851
+
852
+ // ── Render ──────────────────────────────────────────────────────────────────
853
+ if (loading) {
854
+ return <Flex h="full" align="center" justify="center"><Spinner size="xl" /></Flex>
855
+ }
856
+
857
+ return (
858
+ <Box h="full" display="flex" flexDir="column" position="relative">
859
+ {/* Canvas */}
860
+ <Box flex={1} position="relative">
861
+ {/* Floating Search Menu - bottom on desktop, top on mobile */}
862
+ <Box
863
+ position="absolute"
864
+ {...(isMobileLayout
865
+ ? { top: "66px", left: "50%", transform: "translateX(-50%)" }
866
+ : { bottom: "calc(env(safe-area-inset-bottom, 0px) + var(--topbar-h-total) + 60px)", left: "50%", transform: "translateX(-50%)" }
867
+ )}
868
+ zIndex={100}
869
+ pointerEvents="auto"
870
+ >
871
+ <motion.div
872
+ initial={{ y: isMobileLayout ? -20 : 20, opacity: 0 }}
873
+ animate={{ y: 0, opacity: 1 }}
874
+ transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
875
+ >
876
+ <AnimatePresence>
877
+ {searchResults.length > 0 && (
878
+ <motion.div
879
+ initial={{ opacity: 0, y: 8, scale: 0.98 }}
880
+ animate={{ opacity: 1, y: 0, scale: 1 }}
881
+ exit={{ opacity: 0, y: 8, scale: 0.98 }}
882
+ transition={{ duration: 0.2, ease: "easeOut" }}
883
+ style={{
884
+ position: 'absolute',
885
+ ...(isMobileLayout
886
+ ? { top: '100%', marginTop: '8px' }
887
+ : { bottom: '100%', marginBottom: '12px' }
888
+ ),
889
+ left: 0,
890
+ right: 0,
891
+ zIndex: 110,
892
+ }}
893
+ >
894
+ <Box
895
+ bg="var(--bg-panel)"
896
+ backdropFilter="blur(24px) saturate(180%)"
897
+ border="1px solid"
898
+ borderColor="var(--border-main)"
899
+ borderRadius="10px"
900
+ overflow="hidden"
901
+ boxShadow="0 20px 50px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05)"
902
+ >
903
+ {searchResults.map((result, idx) => (
904
+ <Flex
905
+ key={result.id}
906
+ px={4}
907
+ py={2.5}
908
+ align="center"
909
+ gap={3}
910
+ cursor="pointer"
911
+ bg={idx === activeSearchIndex ? 'whiteAlpha.100' : 'transparent'}
912
+ _hover={{ bg: 'whiteAlpha.50' }}
913
+ onClick={() => {
914
+ setFocusedId(result.id)
915
+ navigate(`/views/${result.id}`)
916
+ setSearchResults([])
917
+ }}
918
+ transition="all 0.15s ease"
919
+ >
920
+ <Box
921
+ w="6px"
922
+ h="6px"
923
+ borderRadius="full"
924
+ bg={idx === activeSearchIndex ? 'var(--accent)' : 'whiteAlpha.300'}
925
+ boxShadow={idx === activeSearchIndex ? `0 0 10px var(--accent)` : 'none'}
926
+ transition="all 0.2s"
927
+ />
928
+ <Box flex={1} minW={0}>
929
+ <Text color="white" fontSize="xs" fontWeight="600" isTruncated>
930
+ {result.name}
931
+ </Text>
932
+ <Text color="whiteAlpha.500" fontSize="10px" textTransform="uppercase" letterSpacing="0.05em">
933
+ Level {result.level} • {result.level_label || 'Diagram'}
934
+ </Text>
935
+ </Box>
936
+ {idx === activeSearchIndex && (
937
+ <HStack spacing={1} opacity={0.8}>
938
+ <Text color="var(--accent)" fontSize="9px" fontWeight="800" letterSpacing="0.1em">
939
+ OPEN
940
+ </Text>
941
+ <Text color="whiteAlpha.400" fontSize="9px">↵</Text>
942
+ </HStack>
943
+ )}
944
+ </Flex>
945
+ ))}
946
+ </Box>
947
+ </motion.div>
948
+ )}
949
+ </AnimatePresence>
950
+
951
+ <Flex
952
+ bg="var(--bg-header)"
953
+ backdropFilter="blur(24px) saturate(180%)"
954
+ border="1px solid"
955
+ borderColor="var(--border-main)"
956
+ borderRadius="10px"
957
+ pl={4}
958
+ pr={1.5}
959
+ py={1.5}
960
+ gap={3}
961
+ boxShadow="0 10px 30px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.05)"
962
+ align="center"
963
+ minW={isMobileLayout ? "280px" : "380px"}
964
+ w={isMobileLayout ? "calc(100vw - 48px)" : undefined}
965
+ >
966
+ <InputGroup size="sm" flex={1}>
967
+ <InputLeftElement pointerEvents="none" h="full">
968
+ <SearchIcon color="whiteAlpha.400" fontSize="10px" />
969
+ </InputLeftElement>
970
+ <Input
971
+ placeholder="Jump to diagram..."
972
+ value={searchTerm}
973
+ onChange={(e) => handleSearch(e.target.value)}
974
+ onKeyDown={handleSearchKeyDown}
975
+ variant="unstyled"
976
+ fontSize="xs"
977
+ color="white"
978
+ h="32px"
979
+ _placeholder={{ color: 'whiteAlpha.300' }}
980
+ />
981
+ {searchTerm && (
982
+ <InputRightElement h="full">
983
+ <IconButton
984
+ aria-label="Clear search"
985
+ icon={<CloseIcon fontSize="8px" />}
986
+ size="xs"
987
+ variant="ghost"
988
+ color="whiteAlpha.400"
989
+ _hover={{ color: 'white', bg: 'transparent' }}
990
+ onClick={() => handleSearch('')}
991
+ />
992
+ </InputRightElement>
993
+ )}
994
+ </InputGroup>
995
+
996
+ {canEdit && (
997
+ <Button
998
+ size="sm"
999
+ h="32px"
1000
+ leftIcon={<AddIcon fontSize="9px" />}
1001
+ bg="var(--accent)"
1002
+ color="white"
1003
+ _hover={{
1004
+ bg: "var(--accent)",
1005
+ filter: "brightness(1.1)",
1006
+ transform: 'translateY(-1px)',
1007
+ boxShadow: `0 0 20px ${hexToRgba(accent, 0.4)}`
1008
+ }}
1009
+ _active={{ transform: 'translateY(0)', filter: "brightness(0.9)" }}
1010
+ variant="solid"
1011
+ borderRadius="lg"
1012
+ px={4}
1013
+ fontSize="xs"
1014
+ fontWeight="bold"
1015
+ letterSpacing="0.02em"
1016
+ onClick={() => {
1017
+ setNewName('')
1018
+ onCreateOpen()
1019
+ }}
1020
+ boxShadow={`0 4px 12px ${hexToRgba(accent, 0.2)}`}
1021
+ transition="all 0.2s cubic-bezier(0.4, 0, 0.2, 1)"
1022
+ >
1023
+ NEW
1024
+ </Button>
1025
+ )}
1026
+ </Flex>
1027
+ </motion.div>
1028
+ </Box>
1029
+
1030
+ {/* Level change overlay banner */}
1031
+ {levelEditingNodeId && (
1032
+ <Flex
1033
+ position="absolute"
1034
+ top={6}
1035
+ left="50%"
1036
+ transform="translateX(-50%)"
1037
+ bg="rgba(15, 23, 42, 0.85)"
1038
+ border="1px solid var(--accent)"
1039
+ boxShadow="0 8px 32px rgba(0,0,0,0.6), 0 0 24px rgba(var(--accent-rgb), 0.3)"
1040
+ borderRadius="full"
1041
+ px={6}
1042
+ py={3}
1043
+ zIndex={100}
1044
+ align="center"
1045
+ gap={6}
1046
+ backdropFilter="blur(12px)"
1047
+ >
1048
+ <Flex align="center" gap={3}>
1049
+ <Box w={2} h={2} borderRadius="full" bg="var(--accent)" boxShadow="0 0 8px var(--accent)" />
1050
+ <Text color="gray.200" fontSize="sm" fontWeight="medium">
1051
+ Changing level for <Text as="span" color="white" fontWeight="bold">"{flatTree.find(n => n.id === levelEditingNodeId)?.name}"</Text>
1052
+ </Text>
1053
+ </Flex>
1054
+ <Box w="1px" h="16px" bg="whiteAlpha.300" />
1055
+ <Text color="gray.400" fontSize="sm">
1056
+ Click an L0-L9 level band to set diagram depth
1057
+ </Text>
1058
+ <Flex gap={2}>
1059
+ <Button size="xs" variant="ghost" color="gray.400" _hover={{ color: 'white', bg: 'whiteAlpha.200' }} onClick={() => setLevelEditingNodeId(null)}>
1060
+ Cancel
1061
+ </Button>
1062
+ </Flex>
1063
+ </Flex>
1064
+ )}
1065
+
1066
+ {levelEditingNodeId !== null && (
1067
+ <ViewGridSidebar
1068
+ maxDepth={maxDepth}
1069
+ isReparenting={true}
1070
+ onLevelClick={handleSetLevel}
1071
+ activeLevel={flatTree.find((n) => n.id === levelEditingNodeId)?.level ?? null}
1072
+ />
1073
+ )}
1074
+
1075
+ <Box
1076
+ ref={rfContainerRef}
1077
+ position="relative"
1078
+ w="full"
1079
+ h="full"
1080
+ >
1081
+ <ReactFlow
1082
+ nodes={allRfNodes}
1083
+ edges={allRfEdges}
1084
+ nodeTypes={NODE_TYPES}
1085
+ edgeTypes={EDGE_TYPES}
1086
+ onlyRenderVisibleElements
1087
+ fitView
1088
+ fitViewOptions={{ padding: 0.15, minZoom: 0.8, maxZoom: 1.2 }}
1089
+ panOnScroll={!isMobileLayout}
1090
+ zoomOnScroll={false}
1091
+ zoomOnPinch
1092
+ minZoom={computedMinZoom}
1093
+ maxZoom={2}
1094
+ translateExtent={computedTranslateExtent}
1095
+ nodesDraggable={false}
1096
+ nodesConnectable={false}
1097
+ onPaneClick={() => {
1098
+ setFocusedId(null)
1099
+ }}
1100
+ style={{
1101
+ background: 'var(--bg-canvas)',
1102
+ boxShadow: 'inset 0 0 100px rgba(0,0,0,0.6)'
1103
+ }}
1104
+ >
1105
+ {/* Micro dots for high precision technical feel */}
1106
+ <SafeBackground id="micro" variant={BackgroundVariant.Dots} gap={20} size={1} color={hexToRgba(accent, 0.2)} />
1107
+ {/* Minor cell grid for regular structural spacing */}
1108
+ </ReactFlow>
1109
+ </Box>
1110
+
1111
+ {/* Empty state overlay */}
1112
+ {roots.length === 0 && (
1113
+ <Flex
1114
+ position="absolute"
1115
+ inset={0}
1116
+ align="center"
1117
+ justify="center"
1118
+ pointerEvents="none"
1119
+ >
1120
+ <Box textAlign="center">
1121
+ <Text color="gray.600" fontSize="sm" mb={1}>No views yet.</Text>
1122
+ {canEdit && (
1123
+ <>
1124
+ <Text color="gray.700" fontSize="xs" mb={4}>Click "New Diagram" to get started.</Text>
1125
+
1126
+ </>
1127
+ )}
1128
+ </Box>
1129
+ </Flex>
1130
+ )}
1131
+
1132
+ </Box>
1133
+
1134
+ {/* Legend + keyboard hint */}
1135
+ <Box
1136
+ position="fixed"
1137
+ bottom={0}
1138
+ left={0}
1139
+ right={0}
1140
+ zIndex={20}
1141
+ pointerEvents="none"
1142
+ pb={3}
1143
+ >
1144
+ {/* Edge type legend */}
1145
+ <Flex justify="center" align="center" gap={4} mb="3px">
1146
+ <HStack spacing={1}>
1147
+ <Box w="18px" style={{ borderTop: '1px solid rgba(255,255,255,0.2)' }} />
1148
+ <Text fontSize="9px" color="gray.700" letterSpacing="0.05em" lineHeight={1}>hierarchy link</Text>
1149
+ </HStack>
1150
+ </Flex>
1151
+ <Text fontSize="11px" color="gray.700" userSelect="none" letterSpacing="0.03em" textAlign="center">
1152
+ Click=Select · W↑ S↓ A← D→ · Enter=Open · Esc=Deselect
1153
+ </Text>
1154
+ </Box>
1155
+
1156
+ {/* Confirm Delete Dialog */}
1157
+ <ConfirmDialog
1158
+ isOpen={isDeleteOpen}
1159
+ onClose={onDeleteClose}
1160
+ onConfirm={handleDeleteConfirm}
1161
+ title="Delete diagram"
1162
+ body="Are you sure you want to delete this diagram? This action cannot be undone."
1163
+ confirmLabel="Delete"
1164
+ confirmColorScheme="red"
1165
+ />
1166
+
1167
+ {/* Create Diagram Modal */}
1168
+ <Modal
1169
+ isOpen={isCreateOpen}
1170
+ onClose={onCreateClose}
1171
+ isCentered
1172
+ size="sm"
1173
+ >
1174
+ <ModalOverlay bg="blackAlpha.700" backdropFilter="blur(4px)" />
1175
+ <ModalContent
1176
+ bg="var(--bg-panel)"
1177
+ border="1px solid"
1178
+ borderColor="var(--border-main)"
1179
+ borderRadius="xl"
1180
+ boxShadow="0 24px 64px rgba(0,0,0,0.8)"
1181
+ >
1182
+ <ModalHeader color="gray.100" pb={1} fontSize="md">Create New Diagram</ModalHeader>
1183
+ <ModalBody>
1184
+ <FormControl id="new-view-name">
1185
+ <FormLabel fontSize="xs" color="gray.500" textTransform="uppercase" letterSpacing="0.05em">
1186
+ Diagram Name
1187
+ </FormLabel>
1188
+ <Input
1189
+ name="name"
1190
+ value={newName}
1191
+ onChange={(e) => setNewName(e.target.value)}
1192
+ size="sm"
1193
+ bg="whiteAlpha.50"
1194
+ border="1px solid"
1195
+ borderColor="whiteAlpha.100"
1196
+ _hover={{ borderColor: 'whiteAlpha.300' }}
1197
+ _focus={{ borderColor: 'var(--accent)', boxShadow: '0 0 0 1px var(--accent)' }}
1198
+ autoFocus
1199
+ onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
1200
+ placeholder="My New Architecture"
1201
+ />
1202
+ </FormControl>
1203
+ </ModalBody>
1204
+ <ModalFooter gap={2} pt={6}>
1205
+ <Button size="sm" variant="ghost" color="gray.500" _hover={{ color: 'white', bg: 'whiteAlpha.100' }} onClick={onCreateClose}>
1206
+ Cancel
1207
+ </Button>
1208
+ <Button
1209
+ size="sm"
1210
+ bg="var(--accent)"
1211
+ color="white"
1212
+ _hover={{ bg: "var(--accent)", filter: "brightness(1.1)" }}
1213
+ _active={{ bg: "var(--accent)", filter: "brightness(0.9)" }}
1214
+ isLoading={isCreating}
1215
+ isDisabled={!newName.trim()}
1216
+ onClick={handleCreate}
1217
+ borderRadius="lg"
1218
+ px={6}
1219
+ >
1220
+ Create
1221
+ </Button>
1222
+ </ModalFooter>
1223
+ </ModalContent>
1224
+ </Modal>
1225
+
1226
+ {/* Details Drawer */}
1227
+ <ViewPanel
1228
+ isOpen={isDetailsOpen && !detailsLoading}
1229
+ onClose={onDetailsClose}
1230
+ view={detailsView}
1231
+ canEdit={canEdit}
1232
+ onSave={handleDetailsSave}
1233
+ hasBackdrop={isMobileLayout}
1234
+ />
1235
+
1236
+ {/* Feature tutorial */}
1237
+
1238
+ {/* Onboarding Wizard */}
1239
+ <Modal
1240
+ isOpen={onboardingStep === 1 || onboardingStep === 2}
1241
+ onClose={() => setOnboardingStep(0)}
1242
+ isCentered
1243
+ size="sm"
1244
+ >
1245
+ <ModalOverlay bg="blackAlpha.700" />
1246
+ <ModalContent bg="var(--bg-panel)" border="1px solid" borderColor="var(--border-main)">
1247
+ {onboardingStep === 1 && (
1248
+ <>
1249
+ <ModalHeader color="gray.100" pb={1}>Welcome to tldiagram!</ModalHeader>
1250
+ <ModalBody>
1251
+ <Text fontSize="sm" color="gray.400" mb={4}>
1252
+ Start by creating your first diagram.
1253
+ </Text>
1254
+ <FormControl id="onboarding-view-name">
1255
+ <FormLabel fontSize="xs" color="gray.500" textTransform="uppercase">
1256
+ Diagram Name
1257
+ </FormLabel>
1258
+ <Input
1259
+ name="name"
1260
+ value={onboardingName}
1261
+ onChange={(e) => setOnboardingName(e.target.value)}
1262
+ size="sm"
1263
+ autoFocus
1264
+ onKeyDown={(e) => e.key === 'Enter' && handleOnboardingCreate()}
1265
+ />
1266
+ </FormControl>
1267
+ </ModalBody>
1268
+ <ModalFooter gap={2}>
1269
+ <Button size="sm" variant="ghost" color="gray.500" onClick={() => setOnboardingStep(0)}>
1270
+ Skip
1271
+ </Button>
1272
+ <Button
1273
+ size="sm"
1274
+ colorScheme="blue"
1275
+ isLoading={onboardingCreating}
1276
+ isDisabled={!onboardingName.trim()}
1277
+ onClick={handleOnboardingCreate}
1278
+ >
1279
+ Create Diagram
1280
+ </Button>
1281
+ </ModalFooter>
1282
+ </>
1283
+ )}
1284
+ {onboardingStep === 2 && (
1285
+ <>
1286
+ <ModalHeader color="gray.100" pb={1}>Your diagram is ready!</ModalHeader>
1287
+ <ModalBody>
1288
+ <Text fontSize="sm" color="gray.400">
1289
+ Next, add elements to your diagram to start building your architecture.
1290
+ </Text>
1291
+ </ModalBody>
1292
+ <ModalFooter>
1293
+ <Button
1294
+ size="sm"
1295
+ colorScheme="blue"
1296
+ onClick={() => {
1297
+ setOnboardingStep(0)
1298
+ if (onboardingViewId !== null) navigate(`/views/${onboardingViewId}`)
1299
+ }}
1300
+ >
1301
+ Start Building
1302
+ </Button>
1303
+ </ModalFooter>
1304
+ </>
1305
+ )}
1306
+ </ModalContent>
1307
+ </Modal>
1308
+ </Box>
1309
+ )
1310
+ }