@twoux/ui 0.0.0-stage → 0.1.1

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 (325) hide show
  1. package/LICENSE.md +61 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/NOTICE.md +64 -0
  4. package/README.md +211 -2
  5. package/SHADCN-DIFF.md +228 -0
  6. package/agent/AGENTS.md +358 -0
  7. package/agent/docs-md/accessibility.md +49 -0
  8. package/agent/docs-md/action-catalogue.md +81 -0
  9. package/agent/docs-md/actions.md +74 -0
  10. package/agent/docs-md/agent-activity-log.md +70 -0
  11. package/agent/docs-md/agent-permissions.md +82 -0
  12. package/agent/docs-md/agent-run.md +90 -0
  13. package/agent/docs-md/agents-on-behalf.md +74 -0
  14. package/agent/docs-md/ai-column.md +85 -0
  15. package/agent/docs-md/ai-origin-label.md +70 -0
  16. package/agent/docs-md/announcing-features.md +73 -0
  17. package/agent/docs-md/answer-actions.md +81 -0
  18. package/agent/docs-md/app-shell.md +114 -0
  19. package/agent/docs-md/artifact-card.md +89 -0
  20. package/agent/docs-md/ask-on-screen.md +72 -0
  21. package/agent/docs-md/assistant-page.md +100 -0
  22. package/agent/docs-md/assistant-panel.md +142 -0
  23. package/agent/docs-md/avatar.md +82 -0
  24. package/agent/docs-md/banner.md +106 -0
  25. package/agent/docs-md/board.md +163 -0
  26. package/agent/docs-md/breadcrumb.md +89 -0
  27. package/agent/docs-md/breakdown-table.md +97 -0
  28. package/agent/docs-md/bulk-action-bar.md +112 -0
  29. package/agent/docs-md/button.md +293 -0
  30. package/agent/docs-md/card.md +115 -0
  31. package/agent/docs-md/change-list.md +99 -0
  32. package/agent/docs-md/change-request.md +166 -0
  33. package/agent/docs-md/chart-area.md +87 -0
  34. package/agent/docs-md/chart-bar.md +100 -0
  35. package/agent/docs-md/chart-box-plot.md +88 -0
  36. package/agent/docs-md/chart-bullet.md +89 -0
  37. package/agent/docs-md/chart-candlestick.md +112 -0
  38. package/agent/docs-md/chart-card-line.md +120 -0
  39. package/agent/docs-md/chart-donut.md +90 -0
  40. package/agent/docs-md/chart-drill-down.md +72 -0
  41. package/agent/docs-md/chart-funnel.md +71 -0
  42. package/agent/docs-md/chart-heatmap.md +76 -0
  43. package/agent/docs-md/chart-histogram.md +81 -0
  44. package/agent/docs-md/chart-map.md +165 -0
  45. package/agent/docs-md/chart-mixed.md +88 -0
  46. package/agent/docs-md/chart-price.md +92 -0
  47. package/agent/docs-md/chart-radial-bars.md +109 -0
  48. package/agent/docs-md/chart-radial-lines.md +111 -0
  49. package/agent/docs-md/chart-sankey.md +104 -0
  50. package/agent/docs-md/chart-scatter.md +87 -0
  51. package/agent/docs-md/chart-share-bar.md +89 -0
  52. package/agent/docs-md/chart-treemap.md +98 -0
  53. package/agent/docs-md/chart-waterfall.md +93 -0
  54. package/agent/docs-md/checkbox.md +131 -0
  55. package/agent/docs-md/choosing-selection-control.md +71 -0
  56. package/agent/docs-md/citations-sources.md +79 -0
  57. package/agent/docs-md/citing-sources.md +63 -0
  58. package/agent/docs-md/clarify-before-acting.md +69 -0
  59. package/agent/docs-md/clarifying-question.md +92 -0
  60. package/agent/docs-md/code-block.md +174 -0
  61. package/agent/docs-md/colours.md +87 -0
  62. package/agent/docs-md/columns-menu.md +140 -0
  63. package/agent/docs-md/combobox.md +161 -0
  64. package/agent/docs-md/command-palette.md +126 -0
  65. package/agent/docs-md/confidence-note.md +87 -0
  66. package/agent/docs-md/configurable-dashboard.md +76 -0
  67. package/agent/docs-md/confirmation-dialog.md +93 -0
  68. package/agent/docs-md/connected-tools.md +80 -0
  69. package/agent/docs-md/connector-picker.md +127 -0
  70. package/agent/docs-md/consistency.md +53 -0
  71. package/agent/docs-md/content-density.md +61 -0
  72. package/agent/docs-md/conversation-history.md +78 -0
  73. package/agent/docs-md/create-record.md +80 -0
  74. package/agent/docs-md/csv-import.md +88 -0
  75. package/agent/docs-md/dashboard-items.md +78 -0
  76. package/agent/docs-md/data-export.md +61 -0
  77. package/agent/docs-md/data-grid.md +137 -0
  78. package/agent/docs-md/data-vis-colours.md +76 -0
  79. package/agent/docs-md/data-visualisation.md +76 -0
  80. package/agent/docs-md/date-picker.md +178 -0
  81. package/agent/docs-md/delete-record.md +75 -0
  82. package/agent/docs-md/dense-table.md +495 -0
  83. package/agent/docs-md/design-tokens.md +53 -0
  84. package/agent/docs-md/disabled-read-only.md +75 -0
  85. package/agent/docs-md/drag-and-drop.md +74 -0
  86. package/agent/docs-md/dropdown-menu.md +137 -0
  87. package/agent/docs-md/edit-record.md +73 -0
  88. package/agent/docs-md/empty-loading-error.md +78 -0
  89. package/agent/docs-md/empty-state.md +128 -0
  90. package/agent/docs-md/error-messages.md +75 -0
  91. package/agent/docs-md/error-state.md +123 -0
  92. package/agent/docs-md/export-menu.md +86 -0
  93. package/agent/docs-md/feedback-prompt.md +88 -0
  94. package/agent/docs-md/field-mapping.md +78 -0
  95. package/agent/docs-md/file-drop.md +110 -0
  96. package/agent/docs-md/filter-bar.md +138 -0
  97. package/agent/docs-md/filter-panel.md +103 -0
  98. package/agent/docs-md/filtering-search.md +79 -0
  99. package/agent/docs-md/follow-ups-starters.md +79 -0
  100. package/agent/docs-md/form-validation.md +95 -0
  101. package/agent/docs-md/generated-columns.md +65 -0
  102. package/agent/docs-md/generated-drafts-files.md +71 -0
  103. package/agent/docs-md/grouped-list.md +89 -0
  104. package/agent/docs-md/guide-cue.md +170 -0
  105. package/agent/docs-md/help-panel.md +103 -0
  106. package/agent/docs-md/help-system.md +74 -0
  107. package/agent/docs-md/iconography.md +49 -0
  108. package/agent/docs-md/inbox.md +108 -0
  109. package/agent/docs-md/inline-editing-validation.md +80 -0
  110. package/agent/docs-md/inline-input-in-answer.md +70 -0
  111. package/agent/docs-md/items-palette.md +116 -0
  112. package/agent/docs-md/keyboard-model.md +47 -0
  113. package/agent/docs-md/keyboard-navigation.md +68 -0
  114. package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
  115. package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
  116. package/agent/docs-md/labelling-ai-output.md +66 -0
  117. package/agent/docs-md/layout.md +47 -0
  118. package/agent/docs-md/list-table.md +254 -0
  119. package/agent/docs-md/loading-skeleton.md +91 -0
  120. package/agent/docs-md/managing-agents.md +71 -0
  121. package/agent/docs-md/message-composer.md +105 -0
  122. package/agent/docs-md/migrating.md +49 -0
  123. package/agent/docs-md/motion.md +93 -0
  124. package/agent/docs-md/navigation.md +76 -0
  125. package/agent/docs-md/notification-channels.md +65 -0
  126. package/agent/docs-md/notifications-decision.md +100 -0
  127. package/agent/docs-md/onboarding-step.md +114 -0
  128. package/agent/docs-md/order-book.md +108 -0
  129. package/agent/docs-md/page-header.md +96 -0
  130. package/agent/docs-md/pagination.md +88 -0
  131. package/agent/docs-md/pane-layout.md +105 -0
  132. package/agent/docs-md/period-comparison.md +66 -0
  133. package/agent/docs-md/plan-card.md +120 -0
  134. package/agent/docs-md/popover-rules.md +88 -0
  135. package/agent/docs-md/prompt-editor.md +90 -0
  136. package/agent/docs-md/propose-apply.md +65 -0
  137. package/agent/docs-md/provider-logo.md +97 -0
  138. package/agent/docs-md/radio.md +83 -0
  139. package/agent/docs-md/ranked-list.md +102 -0
  140. package/agent/docs-md/reasoning-progress.md +72 -0
  141. package/agent/docs-md/record-detail.md +71 -0
  142. package/agent/docs-md/regenerating-answers.md +70 -0
  143. package/agent/docs-md/run-status-bar.md +94 -0
  144. package/agent/docs-md/saved-tasks-schedules.md +63 -0
  145. package/agent/docs-md/saved-views.md +149 -0
  146. package/agent/docs-md/saving-views.md +68 -0
  147. package/agent/docs-md/scheduler.md +258 -0
  148. package/agent/docs-md/secondary-panels.md +72 -0
  149. package/agent/docs-md/segmented-control.md +88 -0
  150. package/agent/docs-md/selection-bulk-actions.md +70 -0
  151. package/agent/docs-md/separator.md +75 -0
  152. package/agent/docs-md/setup-checklist.md +92 -0
  153. package/agent/docs-md/shape-elevation.md +55 -0
  154. package/agent/docs-md/sheet-grid.md +211 -0
  155. package/agent/docs-md/side-panel.md +134 -0
  156. package/agent/docs-md/sign-in.md +114 -0
  157. package/agent/docs-md/spacing.md +55 -0
  158. package/agent/docs-md/split-button.md +100 -0
  159. package/agent/docs-md/stat-compare.md +82 -0
  160. package/agent/docs-md/static-dashboard.md +75 -0
  161. package/agent/docs-md/status-badge.md +140 -0
  162. package/agent/docs-md/step-card.md +75 -0
  163. package/agent/docs-md/stepper.md +92 -0
  164. package/agent/docs-md/suggested-changes.md +91 -0
  165. package/agent/docs-md/switch.md +78 -0
  166. package/agent/docs-md/table-settings.md +81 -0
  167. package/agent/docs-md/tabs.md +85 -0
  168. package/agent/docs-md/tag-input.md +108 -0
  169. package/agent/docs-md/tag.md +79 -0
  170. package/agent/docs-md/task-builder.md +87 -0
  171. package/agent/docs-md/team-instructions.md +72 -0
  172. package/agent/docs-md/templates-gallery.md +85 -0
  173. package/agent/docs-md/text-input.md +160 -0
  174. package/agent/docs-md/thinking-steps.md +89 -0
  175. package/agent/docs-md/timeline.md +189 -0
  176. package/agent/docs-md/timestamp.md +84 -0
  177. package/agent/docs-md/timestamps.md +70 -0
  178. package/agent/docs-md/toast.md +117 -0
  179. package/agent/docs-md/tooltip.md +90 -0
  180. package/agent/docs-md/typography.md +62 -0
  181. package/agent/docs-md/undo-destructive.md +66 -0
  182. package/agent/docs-md/unsaved-changes.md +71 -0
  183. package/agent/docs-md/usage-meter.md +80 -0
  184. package/agent/docs-md/user-feedback.md +71 -0
  185. package/agent/docs-md/voice-and-tone.md +60 -0
  186. package/agent/docs-md/why-choice.md +93 -0
  187. package/agent/docs-md/workspace-onboarding.md +74 -0
  188. package/agent/docs-md/writing.md +48 -0
  189. package/agent/llms-full.txt +18369 -0
  190. package/agent/llms.txt +198 -0
  191. package/agent/skills/twoux-audit/SKILL.md +49 -0
  192. package/agent/skills/twoux-build/SKILL.md +39 -0
  193. package/agent/skills/twoux-design/SKILL.md +54 -0
  194. package/agent/skills/twoux-write/SKILL.md +32 -0
  195. package/bin/check.mjs +123 -0
  196. package/bin/docs.mjs +24 -0
  197. package/bin/guard.mjs +27 -0
  198. package/bin/init.mjs +113 -0
  199. package/bin/knowledge.mjs +87 -0
  200. package/bin/mcp.mjs +116 -0
  201. package/bin/theme.mjs +168 -0
  202. package/bin/twoux.mjs +25 -0
  203. package/package.json +80 -3
  204. package/registry/jobs.json +1028 -0
  205. package/registry/palette.json +770 -0
  206. package/registry/rules.json +913 -0
  207. package/src/components/agent-review.tsx +364 -0
  208. package/src/components/ai-basics.tsx +212 -0
  209. package/src/components/ai-work.tsx +304 -0
  210. package/src/components/ai.tsx +227 -0
  211. package/src/components/alert-dialog.tsx +203 -0
  212. package/src/components/alert.tsx +86 -0
  213. package/src/components/avatar.tsx +114 -0
  214. package/src/components/badge.tsx +59 -0
  215. package/src/components/board.tsx +397 -0
  216. package/src/components/breadcrumb.tsx +114 -0
  217. package/src/components/button-group.tsx +90 -0
  218. package/src/components/button.tsx +109 -0
  219. package/src/components/calendar.tsx +247 -0
  220. package/src/components/card.tsx +102 -0
  221. package/src/components/chart-map.tsx +336 -0
  222. package/src/components/charts-more.tsx +1331 -0
  223. package/src/components/charts-radial.tsx +328 -0
  224. package/src/components/charts.tsx +390 -0
  225. package/src/components/checkbox.tsx +40 -0
  226. package/src/components/code.tsx +139 -0
  227. package/src/components/collapsible.tsx +37 -0
  228. package/src/components/combobox.tsx +339 -0
  229. package/src/components/command-extras.tsx +11 -0
  230. package/src/components/command.tsx +196 -0
  231. package/src/components/data-grid.tsx +213 -0
  232. package/src/components/data-table.tsx +334 -0
  233. package/src/components/data-views.tsx +740 -0
  234. package/src/components/date-picker.tsx +686 -0
  235. package/src/components/dense-table.tsx +207 -0
  236. package/src/components/dialog.tsx +162 -0
  237. package/src/components/dropdown-menu.tsx +264 -0
  238. package/src/components/empty.tsx +114 -0
  239. package/src/components/feedback-prompt.tsx +80 -0
  240. package/src/components/field.tsx +254 -0
  241. package/src/components/fields.tsx +112 -0
  242. package/src/components/flow.tsx +840 -0
  243. package/src/components/guide-cue.tsx +273 -0
  244. package/src/components/inbox.tsx +113 -0
  245. package/src/components/input-group.tsx +176 -0
  246. package/src/components/input.tsx +26 -0
  247. package/src/components/kbd.tsx +34 -0
  248. package/src/components/label.tsx +27 -0
  249. package/src/components/labels.tsx +176 -0
  250. package/src/components/motion.tsx +136 -0
  251. package/src/components/onboarding.tsx +454 -0
  252. package/src/components/order-book.tsx +185 -0
  253. package/src/components/pane-layout.tsx +417 -0
  254. package/src/components/popover.tsx +96 -0
  255. package/src/components/provider-logos.data.ts +49 -0
  256. package/src/components/radio-group.tsx +52 -0
  257. package/src/components/scheduler.tsx +997 -0
  258. package/src/components/segmented-control.tsx +28 -0
  259. package/src/components/select.tsx +195 -0
  260. package/src/components/separator.tsx +31 -0
  261. package/src/components/sheet.tsx +148 -0
  262. package/src/components/shell.tsx +426 -0
  263. package/src/components/shortcut-sheet.tsx +83 -0
  264. package/src/components/skeleton.tsx +18 -0
  265. package/src/components/sonner.tsx +107 -0
  266. package/src/components/spinner.tsx +13 -0
  267. package/src/components/split-button.tsx +58 -0
  268. package/src/components/stepper.tsx +78 -0
  269. package/src/components/switch.tsx +43 -0
  270. package/src/components/table.tsx +126 -0
  271. package/src/components/tabs.tsx +118 -0
  272. package/src/components/tag-input.tsx +253 -0
  273. package/src/components/textarea.tsx +21 -0
  274. package/src/components/theme.tsx +80 -0
  275. package/src/components/timeline.tsx +408 -0
  276. package/src/components/toggle-group.tsx +87 -0
  277. package/src/components/toggle.tsx +54 -0
  278. package/src/components/tooltip.tsx +65 -0
  279. package/src/components/workspace-actions.tsx +294 -0
  280. package/src/components/workspace-import.tsx +465 -0
  281. package/src/components/workspace.tsx +1884 -0
  282. package/src/index.ts +80 -0
  283. package/src/lib/active-indicator.tsx +53 -0
  284. package/src/lib/chart-kit.tsx +72 -0
  285. package/src/lib/cn.ts +6 -0
  286. package/src/lib/shortcuts.tsx +61 -0
  287. package/src/lib/workspace.tsx +10 -0
  288. package/src/shadcn/alert-dialog.tsx +196 -0
  289. package/src/shadcn/alert.tsx +65 -0
  290. package/src/shadcn/avatar.tsx +108 -0
  291. package/src/shadcn/badge.tsx +47 -0
  292. package/src/shadcn/breadcrumb.tsx +108 -0
  293. package/src/shadcn/button-group.tsx +83 -0
  294. package/src/shadcn/button.tsx +63 -0
  295. package/src/shadcn/calendar.tsx +220 -0
  296. package/src/shadcn/card.tsx +91 -0
  297. package/src/shadcn/checkbox.tsx +31 -0
  298. package/src/shadcn/collapsible.tsx +33 -0
  299. package/src/shadcn/command.tsx +184 -0
  300. package/src/shadcn/dialog.tsx +158 -0
  301. package/src/shadcn/dropdown-menu.tsx +256 -0
  302. package/src/shadcn/empty.tsx +103 -0
  303. package/src/shadcn/field.tsx +248 -0
  304. package/src/shadcn/input-group.tsx +170 -0
  305. package/src/shadcn/input.tsx +20 -0
  306. package/src/shadcn/kbd.tsx +28 -0
  307. package/src/shadcn/label.tsx +23 -0
  308. package/src/shadcn/popover.tsx +88 -0
  309. package/src/shadcn/radio-group.tsx +44 -0
  310. package/src/shadcn/select.tsx +189 -0
  311. package/src/shadcn/separator.tsx +27 -0
  312. package/src/shadcn/sheet.tsx +142 -0
  313. package/src/shadcn/skeleton.tsx +13 -0
  314. package/src/shadcn/sonner.tsx +40 -0
  315. package/src/shadcn/switch.tsx +34 -0
  316. package/src/shadcn/table.tsx +115 -0
  317. package/src/shadcn/tabs.tsx +90 -0
  318. package/src/shadcn/textarea.tsx +17 -0
  319. package/src/shadcn/toggle-group.tsx +83 -0
  320. package/src/shadcn/toggle.tsx +46 -0
  321. package/src/shadcn/tooltip.tsx +56 -0
  322. package/src/styles/palette.css +127 -0
  323. package/src/styles/theme.css +35 -0
  324. package/src/styles/twoux.css +236 -0
  325. package/theme.json +14 -0
@@ -0,0 +1,840 @@
1
+ /**
2
+ * TwoUX Flow canvas: build an automation as connected steps, set each one up, and watch a test run move through it.
3
+ * Built on @xyflow/react (MIT) for pan, zoom, edge paths and the minimap. TwoUX changes to the reference tools (n8n, Vapi):
4
+ * - Type and state are written as words on every step; status colours only carry status, never the type of a step
5
+ * - Keyboard: arrows move focus to the nearest step in that direction; Space picks a step up, arrows move it,
6
+ * Enter drops, Esc puts it back (the TwoUX drag-and-drop pattern). Shift+F10 opens the step menu
7
+ * - Connections can be selected (click, or Tab and Enter): delete them, insert a step in them, or drag either end
8
+ * to another step. Every drag has a menu path in the step's ⋮ (WCAG 2.5.7)
9
+ * - FlowStepList shows the same flow as a nested list beside the canvas, with the same menu
10
+ * - Vertical or horizontal: `direction` turns the handles, and layoutFlow tidies the steps into layers
11
+ * - Moves, connections and deletions are announced in words in a polite live region (WCAG 4.1.3)
12
+ * - Selecting a step calls onSelect; the app opens its settings in SidePanel (record-detail pattern)
13
+ * - Deleting applies at once and the app offers Undo; the canvas never asks for confirmation
14
+ * - Locking stops moving and connecting, but steps can still be opened
15
+ */
16
+ import {
17
+ Background,
18
+ BackgroundVariant,
19
+ EdgeLabelRenderer,
20
+ Handle,
21
+ MarkerType,
22
+ MiniMap,
23
+ Panel,
24
+ Position,
25
+ ReactFlow,
26
+ ReactFlowProvider,
27
+ getSmoothStepPath,
28
+ useNodesInitialized,
29
+ useReactFlow,
30
+ useUpdateNodeInternals,
31
+ type Connection,
32
+ type Edge,
33
+ type EdgeProps,
34
+ type Node,
35
+ type NodeChange,
36
+ type NodeProps,
37
+ } from "@xyflow/react"
38
+ import { AlertTriangle, CornerDownRight, Lock, LockOpen, Maximize, MoreVertical, Plus, Trash2, Users, ZoomIn, ZoomOut } from "lucide-react"
39
+ import * as React from "react"
40
+ import { cn } from "../lib/cn"
41
+ import { Badge } from "./badge"
42
+ import { Button } from "./button"
43
+ import {
44
+ DropdownMenu,
45
+ DropdownMenuContent,
46
+ DropdownMenuItem,
47
+ DropdownMenuSeparator,
48
+ DropdownMenuShortcut,
49
+ DropdownMenuSub,
50
+ DropdownMenuSubContent,
51
+ DropdownMenuSubTrigger,
52
+ DropdownMenuTrigger,
53
+ } from "./dropdown-menu"
54
+ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "./empty"
55
+ import { Kbd } from "./kbd"
56
+ import { ShimmerText } from "./motion"
57
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"
58
+
59
+ /* ---------------------------------------------------------------- model */
60
+
61
+ export type FlowStepKind = "trigger" | "action" | "condition" | "review" | "end"
62
+ export type FlowRunState = "waiting" | "running" | "done" | "skipped" | "failed"
63
+ export type FlowBranch = "yes" | "no"
64
+ export type FlowDirection = "vertical" | "horizontal"
65
+
66
+ export type FlowStep = {
67
+ id: string
68
+ kind: FlowStepKind
69
+ /** What happens, verb first: "Screen CV", "Offer interview times" */
70
+ title: string
71
+ /** The step type the title came from, shown small above it: "Send email", "Application received" */
72
+ type?: string
73
+ /** One or two lines: what it reads, who it reaches, what it decides */
74
+ summary?: string
75
+ icon?: React.ReactNode
76
+ position: { x: number; y: number }
77
+ /** Why the step can't run yet: "choose an email template". Shown as "Needs set-up: …" */
78
+ issue?: string
79
+ /** Reaches people outside the workspace, such as emailing a candidate: "Emails the candidate" */
80
+ reaches?: string
81
+ /** State in the current or last run, with a note: "12 candidates", or the reason it failed */
82
+ run?: { state: FlowRunState; note?: string }
83
+ }
84
+
85
+ export type FlowLink = { id: string; from: string; to: string; branch?: FlowBranch; /** e.g. "12 candidates" after a run */ label?: string }
86
+ export type FlowValue = { steps: FlowStep[]; links: FlowLink[] }
87
+ /** What changed, in words, for a toast with Undo: "Screen CV deleted" */
88
+ export type FlowChange = { type: "move" | "connect" | "disconnect" | "delete"; text: string }
89
+
90
+ export const FLOW_KIND_WORD: Record<FlowStepKind, string> = { trigger: "Trigger", action: "Action", condition: "Condition", review: "Review by a person", end: "End" }
91
+ const RUN_WORD: Record<FlowRunState, string> = { waiting: "Waiting", running: "Running", done: "Done", skipped: "Skipped", failed: "Failed" }
92
+ const BRANCH_WORD: Record<FlowBranch, string> = { yes: "Yes", no: "No" }
93
+ const STEP_W = 256
94
+ const GAP_Y = 168
95
+
96
+ /** The small word above the title: the step type, or its kind when the title already says the type. */
97
+ const typeWord = (s: FlowStep) => (s.type && s.type !== s.title && !s.title.startsWith(s.type) ? s.type : FLOW_KIND_WORD[s.kind])
98
+ const hasInput = (s: FlowStep) => s.kind !== "trigger"
99
+ const hasOutput = (s: FlowStep) => s.kind !== "end"
100
+ const linkId = (from: string, to: string, branch?: FlowBranch) => `${from}-${branch ?? "out"}-${to}`
101
+
102
+ /** Steps in flow order: triggers first, then breadth-first along the links (Yes before No); unreached steps last. */
103
+ export function flowOrder(value: FlowValue): FlowStep[] {
104
+ const byId = new Map(value.steps.map((s) => [s.id, s]))
105
+ const seen = new Set<string>()
106
+ const queue = value.steps.filter((s) => s.kind === "trigger").map((s) => s.id)
107
+ const out: FlowStep[] = []
108
+ while (queue.length) {
109
+ const id = queue.shift()!
110
+ if (seen.has(id) || !byId.has(id)) continue
111
+ seen.add(id)
112
+ out.push(byId.get(id)!)
113
+ const next = value.links.filter((l) => l.from === id).sort((a, b) => (a.branch === "no" ? 1 : 0) - (b.branch === "no" ? 1 : 0))
114
+ queue.push(...next.map((l) => l.to))
115
+ }
116
+ return [...out, ...value.steps.filter((s) => !seen.has(s.id))]
117
+ }
118
+
119
+ /** A free position for a new step after `fromId` (beside it for a condition's No branch). */
120
+ export function placeAfter(value: FlowValue, fromId?: string, branch?: FlowBranch, direction: FlowDirection = "vertical"): { x: number; y: number } {
121
+ const from = value.steps.find((s) => s.id === fromId)
122
+ const across = branch === "no" ? 1 : 0
123
+ const base = !from
124
+ ? { x: 0, y: 0 }
125
+ : direction === "vertical"
126
+ ? { x: from.position.x + across * (STEP_W + 48), y: from.position.y + GAP_Y }
127
+ : { x: from.position.x + STEP_W + 96, y: from.position.y + across * 200 }
128
+ const taken = (p: { x: number; y: number }) => value.steps.some((s) => Math.abs(s.position.x - p.x) < STEP_W && Math.abs(s.position.y - p.y) < GAP_Y - 24)
129
+ const p = { ...base }
130
+ while (taken(p)) direction === "vertical" ? (p.x += STEP_W + 48) : (p.y += 200)
131
+ return p
132
+ }
133
+
134
+ /**
135
+ * Tidy the steps into layers along the flow: each step one layer after its furthest parent, placed across the layer
136
+ * near its parents (Yes before No), never overlapping. Vertical runs top to bottom, horizontal left to right.
137
+ */
138
+ export function layoutFlow<V extends FlowValue>(value: V, direction: FlowDirection = "vertical"): V {
139
+ const MAIN = direction === "vertical" ? 200 : STEP_W + 112
140
+ const CROSS = direction === "vertical" ? STEP_W + 64 : 200
141
+ const order = flowOrder(value)
142
+ const rank = new Map(order.map((s, i) => [s.id, i]))
143
+ const depth = new Map<string, number>()
144
+ for (let pass = 0; pass < order.length; pass++) {
145
+ let changed = false
146
+ for (const s of order) {
147
+ const parents = value.links.filter((l) => l.to === s.id && rank.get(l.from)! < rank.get(s.id)!)
148
+ const d = parents.length ? Math.max(...parents.map((l) => (depth.get(l.from) ?? 0) + 1)) : 0
149
+ if (depth.get(s.id) !== d) { depth.set(s.id, d); changed = true }
150
+ }
151
+ if (!changed) break
152
+ }
153
+ const layers: FlowStep[][] = []
154
+ for (const s of order) (layers[depth.get(s.id) ?? 0] ??= []).push(s)
155
+ const cross = new Map<string, number>()
156
+ layers.forEach((layer, d) => {
157
+ if (!layer) return
158
+ const want = layer.map((s) => {
159
+ const ins = value.links.filter((l) => l.to === s.id && cross.has(l.from))
160
+ if (!ins.length) return 0
161
+ return ins.reduce((sum, l) => sum + cross.get(l.from)! + (l.branch === "yes" ? -CROSS / 2 : l.branch === "no" ? CROSS / 2 : 0), 0) / ins.length
162
+ })
163
+ const items = layer.map((s, i) => ({ s, want: d === 0 ? (i - (layer.length - 1) / 2) * CROSS : want[i] })).sort((a, b) => a.want - b.want)
164
+ const at = items.map((it) => it.want)
165
+ for (let i = 1; i < at.length; i++) at[i] = Math.max(at[i], at[i - 1] + CROSS)
166
+ const shift = items.reduce((sum, it, i) => sum + it.want - at[i], 0) / items.length
167
+ items.forEach((it, i) => cross.set(it.s.id, Math.round(at[i] + shift)))
168
+ })
169
+ return {
170
+ ...value,
171
+ steps: value.steps.map((s) => {
172
+ const c = cross.get(s.id) ?? 0
173
+ const m = (depth.get(s.id) ?? 0) * MAIN
174
+ return { ...s, position: direction === "vertical" ? { x: c, y: m } : { x: m, y: c } }
175
+ }),
176
+ }
177
+ }
178
+
179
+ /** The step's full accessible name: title, type, set-up and run state, and how many connections leave it. */
180
+ function describe(step: FlowStep, value: FlowValue) {
181
+ const outs = value.links.filter((l) => l.from === step.id).length
182
+ return [
183
+ step.title,
184
+ FLOW_KIND_WORD[step.kind].toLowerCase(),
185
+ step.issue ? `needs set-up: ${step.issue}` : null,
186
+ step.run ? `${RUN_WORD[step.run.state].toLowerCase()}${step.run.note ? `, ${step.run.note}` : ""}` : null,
187
+ hasOutput(step) ? `${outs} ${outs === 1 ? "connection" : "connections"} out` : null,
188
+ ].filter(Boolean).join(", ")
189
+ }
190
+
191
+ /* ---------------------------------------------------------------- actions shared by the canvas and the step list */
192
+
193
+ type Actions = {
194
+ value: FlowValue
195
+ locked: boolean
196
+ open: (id: string) => void
197
+ addAfter?: (id: string, branch?: FlowBranch) => void
198
+ insert?: (link: FlowLink) => void
199
+ connect: (from: string, to: string, branch?: FlowBranch) => void
200
+ disconnect: (link: FlowLink) => void
201
+ remove: (id: string) => void
202
+ }
203
+
204
+ function useActions({ value, onValueChange, onSelect, onAddAfter, onInsert, locked, announce }: {
205
+ value: FlowValue
206
+ onValueChange: (next: FlowValue, change?: FlowChange) => void
207
+ onSelect: (id: string | null) => void
208
+ onAddAfter?: (id: string, branch?: FlowBranch) => void
209
+ onInsert?: (link: FlowLink) => void
210
+ locked: boolean
211
+ announce: (text: string) => void
212
+ }): Actions {
213
+ const title = (id: string) => value.steps.find((s) => s.id === id)?.title ?? "step"
214
+ const emit = (next: FlowValue, change: FlowChange) => { onValueChange(next, change); announce(change.text) }
215
+ return {
216
+ value,
217
+ locked,
218
+ open: onSelect,
219
+ addAfter: onAddAfter,
220
+ insert: onInsert,
221
+ connect: (from, to, branch) => {
222
+ if (from === to || value.links.some((l) => l.from === from && l.to === to && l.branch === branch)) return
223
+ const target = value.steps.find((s) => s.id === to)
224
+ if (!target || !hasInput(target)) return
225
+ const text = `Connected ${title(from)}${branch ? ` (${BRANCH_WORD[branch]})` : ""} to ${title(to)}`
226
+ emit({ ...value, links: [...value.links, { id: linkId(from, to, branch), from, to, branch }] }, { type: "connect", text })
227
+ },
228
+ disconnect: (link) => {
229
+ emit({ ...value, links: value.links.filter((l) => l.id !== link.id) }, { type: "disconnect", text: `Disconnected ${title(link.from)} from ${title(link.to)}` })
230
+ },
231
+ remove: (id) => {
232
+ onSelect(null)
233
+ emit({ steps: value.steps.filter((s) => s.id !== id), links: value.links.filter((l) => l.from !== id && l.to !== id) }, { type: "delete", text: `${title(id)} deleted` })
234
+ },
235
+ }
236
+ }
237
+
238
+ /** The step's ⋮ menu: the non-drag path for everything the canvas does by dragging. No icons: words carry every item. */
239
+ function StepMenuItems({ step, actions }: { step: FlowStep; actions: Actions }) {
240
+ const { value, locked } = actions
241
+ const targets = value.steps.filter((s) => s.id !== step.id && hasInput(s))
242
+ const outs = value.links.filter((l) => l.from === step.id)
243
+ const branches: (FlowBranch | undefined)[] = step.kind === "condition" ? ["yes", "no"] : [undefined]
244
+ const name = (id: string) => value.steps.find((s) => s.id === id)?.title ?? id
245
+ return (
246
+ <>
247
+ <DropdownMenuItem onSelect={() => actions.open(step.id)}>Open settings<DropdownMenuShortcut>Enter</DropdownMenuShortcut></DropdownMenuItem>
248
+ {hasOutput(step) && actions.addAfter && !locked && branches.map((b) => (
249
+ <DropdownMenuItem key={b ?? "out"} onSelect={() => actions.addAfter!(step.id, b)}>Add step after{b ? ` ${BRANCH_WORD[b]}` : ""}</DropdownMenuItem>
250
+ ))}
251
+ {hasOutput(step) && !locked && targets.length > 0 && branches.map((b) => (
252
+ <DropdownMenuSub key={`c-${b ?? "out"}`}>
253
+ <DropdownMenuSubTrigger>{b ? `Connect ${BRANCH_WORD[b]} to` : "Connect to"}</DropdownMenuSubTrigger>
254
+ <DropdownMenuSubContent className="max-h-72 overflow-y-auto">
255
+ {targets.map((t) => {
256
+ const linked = outs.some((l) => l.to === t.id && l.branch === b)
257
+ return <DropdownMenuItem key={t.id} disabled={linked} onSelect={() => actions.connect(step.id, t.id, b)}>{t.title}{linked && <span className="ml-auto text-sm text-ink-3">Connected</span>}</DropdownMenuItem>
258
+ })}
259
+ </DropdownMenuSubContent>
260
+ </DropdownMenuSub>
261
+ ))}
262
+ {!locked && outs.length > 0 && (
263
+ <DropdownMenuSub>
264
+ <DropdownMenuSubTrigger>Disconnect</DropdownMenuSubTrigger>
265
+ <DropdownMenuSubContent>
266
+ {outs.map((l) => <DropdownMenuItem key={l.id} onSelect={() => actions.disconnect(l)}>{l.branch ? `${BRANCH_WORD[l.branch]}: ` : ""}{name(l.to)}</DropdownMenuItem>)}
267
+ </DropdownMenuSubContent>
268
+ </DropdownMenuSub>
269
+ )}
270
+ {!locked && (
271
+ <>
272
+ <DropdownMenuSeparator />
273
+ <DropdownMenuItem variant="destructive" onSelect={() => actions.remove(step.id)}>Delete step<DropdownMenuShortcut>Delete</DropdownMenuShortcut></DropdownMenuItem>
274
+ </>
275
+ )}
276
+ </>
277
+ )
278
+ }
279
+
280
+ /* ---------------------------------------------------------------- step parts */
281
+
282
+ /** Run state as a word: Running shimmers (off under reduced motion), the rest are badges. */
283
+ export function FlowRunBadge({ run }: { run: NonNullable<FlowStep["run"]> }) {
284
+ return (
285
+ <span className="flex min-w-0 items-center gap-1.5">
286
+ {run.state === "running" ? (
287
+ <ShimmerText className="text-sm font-medium">Running</ShimmerText>
288
+ ) : (
289
+ <Badge variant={run.state === "done" ? "success" : run.state === "failed" ? "destructive" : "secondary"}>{RUN_WORD[run.state]}</Badge>
290
+ )}
291
+ {run.note && <span className={cn("min-w-0 truncate text-sm", run.state === "failed" ? "text-bad" : "text-ink-2")}>{run.note}</span>}
292
+ </span>
293
+ )
294
+ }
295
+
296
+ function StepStatus({ step }: { step: FlowStep }) {
297
+ if (step.run) return <FlowRunBadge run={step.run} />
298
+ if (step.issue)
299
+ return (
300
+ <span className="flex min-w-0 items-start gap-1.5 text-sm text-warn">
301
+ <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
302
+ <span className="min-w-0">Needs set-up: {step.issue}</span>
303
+ </span>
304
+ )
305
+ if (step.reaches)
306
+ return (
307
+ <span className="flex min-w-0 items-center gap-1.5 text-sm text-ink-3">
308
+ <Users className="size-3.5 shrink-0" aria-hidden="true" />
309
+ <span className="min-w-0 truncate">{step.reaches}</span>
310
+ </span>
311
+ )
312
+ return null
313
+ }
314
+
315
+ type Ctx = Actions & { menuFor: string | null; setMenuFor: (id: string | null) => void; movingId: string | null; direction: FlowDirection; edgeSel: string | null; setEdgeSel: (id: string | null) => void }
316
+ const ActionsCtx = React.createContext<Ctx | null>(null)
317
+
318
+ const HANDLE = "size-2.5! min-w-0! rounded-full! border! border-control! bg-surface! before:absolute before:-inset-2 before:content-[''] data-[locked=true]:pointer-events-none"
319
+
320
+ function StepNode({ data, selected }: NodeProps<Node<{ step: FlowStep }>>) {
321
+ const { step } = data
322
+ const ctx = React.useContext(ActionsCtx)!
323
+ const failed = step.run?.state === "failed"
324
+ const vertical = ctx.direction === "vertical"
325
+ const moving = ctx.movingId === step.id
326
+ return (
327
+ <div
328
+ data-slot="flow-step"
329
+ data-moving={moving || undefined}
330
+ className={cn(
331
+ "relative grid w-64 gap-2 rounded-sm border bg-surface p-3 text-left shadow-card transition-[background-color,border-color,box-shadow] duration-(--duration-fast)",
332
+ selected ? "border-line-strong bg-selected" : "border-line",
333
+ step.run?.state === "running" && "border-primary",
334
+ failed && "border-bad-line",
335
+ step.run?.state === "skipped" && "opacity-70",
336
+ moving && "border-primary shadow-pop",
337
+ )}
338
+ >
339
+ {hasInput(step) && <Handle type="target" position={vertical ? Position.Top : Position.Left} className={HANDLE} data-locked={ctx.locked} aria-label={`Input of ${step.title}`} />}
340
+ <div className="flex items-start gap-2">
341
+ <span aria-hidden="true" className="grid size-7 shrink-0 place-items-center rounded-xs border border-line bg-surface-2 text-ink-2 [&>svg]:size-4">{step.icon}</span>
342
+ <span className="grid min-w-0 flex-1">
343
+ <span className="truncate text-sm text-ink-3">{typeWord(step)}</span>
344
+ <span className="truncate text-base font-medium text-ink">{step.title}</span>
345
+ </span>
346
+ <DropdownMenu open={ctx.menuFor === step.id} onOpenChange={(o) => ctx.setMenuFor(o ? step.id : null)}>
347
+ <DropdownMenuTrigger asChild>
348
+ <Button variant="ghost" size="icon-xs" tabIndex={-1} className="nodrag -mr-1 -mt-0.5" aria-label={`Actions for ${step.title}`}><MoreVertical /></Button>
349
+ </DropdownMenuTrigger>
350
+ <DropdownMenuContent align="start" className="w-56">
351
+ <StepMenuItems step={step} actions={ctx} />
352
+ </DropdownMenuContent>
353
+ </DropdownMenu>
354
+ </div>
355
+ {step.summary && <p className="line-clamp-2 text-sm text-ink-2">{step.summary}</p>}
356
+ <StepStatus step={step} />
357
+ {step.kind === "condition" ? (
358
+ <>
359
+ {vertical ? (
360
+ <span className="flex justify-between px-6 text-sm font-medium text-ink-2" aria-hidden="true"><span>Yes</span><span>No</span></span>
361
+ ) : (
362
+ <>
363
+ <span aria-hidden="true" className="absolute top-1/3 left-full ml-2 -translate-y-full text-sm font-medium text-ink-2">Yes</span>
364
+ <span aria-hidden="true" className="absolute top-2/3 left-full ml-2 -translate-y-full text-sm font-medium text-ink-2">No</span>
365
+ </>
366
+ )}
367
+ <Handle type="source" id="yes" position={vertical ? Position.Bottom : Position.Right} className={cn(HANDLE, vertical ? "left-1/4!" : "top-1/3!")} data-locked={ctx.locked} aria-label={`Yes output of ${step.title}`} />
368
+ <Handle type="source" id="no" position={vertical ? Position.Bottom : Position.Right} className={cn(HANDLE, vertical ? "left-3/4!" : "top-2/3!")} data-locked={ctx.locked} aria-label={`No output of ${step.title}`} />
369
+ </>
370
+ ) : (
371
+ hasOutput(step) && <Handle type="source" position={vertical ? Position.Bottom : Position.Right} className={HANDLE} data-locked={ctx.locked} aria-label={`Output of ${step.title}`} />
372
+ )}
373
+ </div>
374
+ )
375
+ }
376
+
377
+ function LinkEdge({ id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, markerEnd, data, selected }: EdgeProps<Edge<{ text?: string; active?: boolean; link: FlowLink; name: string }>>) {
378
+ const ctx = React.useContext(ActionsCtx)!
379
+ const [path, labelX, labelY] = getSmoothStepPath({ sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition, borderRadius: 8 })
380
+ const showTools = selected && !ctx.locked && data
381
+ return (
382
+ <>
383
+ <path id={id} d={path} markerEnd={markerEnd} className={cn("react-flow__edge-path fill-none", selected ? "stroke-primary stroke-2" : data?.active ? "stroke-primary stroke-[1.5px]" : "stroke-control stroke-[1.5px]")} />
384
+ <path d={path} className="react-flow__edge-interaction cursor-pointer fill-none stroke-transparent stroke-[20px]" />
385
+ {(data?.text || showTools) && (
386
+ <EdgeLabelRenderer>
387
+ <span
388
+ className={cn("nodrag nopan absolute top-0 left-0 flex translate-x-[calc(var(--lx)-50%)] translate-y-[calc(var(--ly)-50%)] items-center", showTools ? "pointer-events-auto z-10 gap-0.5 rounded-md border border-line bg-popover p-0.5 shadow-pop" : "pointer-events-none")}
389
+ style={{ "--lx": `${labelX}px`, "--ly": `${labelY}px` } as React.CSSProperties}
390
+ >
391
+ {data?.text && <span className={cn("text-sm text-ink-2", showTools ? "px-1.5" : "rounded-xs border border-line bg-surface px-1.5")}>{data.text}</span>}
392
+ {showTools && (
393
+ <>
394
+ {ctx.insert && (
395
+ <Tooltip>
396
+ <TooltipTrigger asChild><Button variant="ghost" size="icon-xs" aria-label={`Insert a step between ${data.name}`} onClick={() => ctx.insert!(data.link)}><Plus /></Button></TooltipTrigger>
397
+ <TooltipContent>Insert step</TooltipContent>
398
+ </Tooltip>
399
+ )}
400
+ <Tooltip>
401
+ <TooltipTrigger asChild><Button variant="ghost" size="icon-xs" aria-label={`Delete the connection ${data.name}`} onClick={() => { ctx.setEdgeSel(null); ctx.disconnect(data.link) }}><Trash2 /></Button></TooltipTrigger>
402
+ <TooltipContent className="flex items-center gap-2">Delete connection<Kbd>Delete</Kbd></TooltipContent>
403
+ </Tooltip>
404
+ </>
405
+ )}
406
+ </span>
407
+ </EdgeLabelRenderer>
408
+ )}
409
+ </>
410
+ )
411
+ }
412
+
413
+ const NODE_TYPES = { step: StepNode }
414
+ const EDGE_TYPES = { link: LinkEdge }
415
+
416
+ /* ---------------------------------------------------------------- canvas */
417
+
418
+ const reduced = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches
419
+ const isMac = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform)
420
+ const ARROW: Record<string, [number, number]> = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
421
+ const DIR_WORD: Record<string, string> = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right" }
422
+
423
+ function ToolButton({ label, keys, onClick, pressed, children }: { label: string; keys?: string; onClick: () => void; pressed?: boolean; children: React.ReactNode }) {
424
+ return (
425
+ <Tooltip>
426
+ <TooltipTrigger asChild>
427
+ <Button variant="ghost" size="icon-sm" aria-label={label} aria-pressed={pressed} aria-keyshortcuts={keys} onClick={onClick}>{children}</Button>
428
+ </TooltipTrigger>
429
+ <TooltipContent className="flex items-center gap-2">{label}{keys && <Kbd>{keys.replace("Meta", "⌘").replace("Control", "Ctrl").replace(/\+/g, " ")}</Kbd>}</TooltipContent>
430
+ </Tooltip>
431
+ )
432
+ }
433
+
434
+ export type FlowCanvasProps = {
435
+ value: FlowValue
436
+ /** Every change, including each position while dragging. `change` is set once the move, connection or deletion is done: use it for a toast with Undo. */
437
+ onValueChange: (next: FlowValue, change?: FlowChange) => void
438
+ selectedId?: string | null
439
+ /** A step was selected (open its settings in SidePanel), or the canvas was clicked (null). */
440
+ onSelect: (id: string | null) => void
441
+ /** Add step after, from a step's menu or the step list. The app opens its step picker and places the step with placeAfter. */
442
+ onAddAfter?: (id: string, branch?: FlowBranch) => void
443
+ /** Insert step, from a selected connection. The app opens its step picker and puts the new step between link.from and link.to. */
444
+ onInsert?: (link: FlowLink) => void
445
+ /** Shown in the empty state as Add trigger. */
446
+ onAddTrigger?: () => void
447
+ /** Which way the flow runs: handles sit top and bottom (vertical) or left and right (horizontal). Use layoutFlow to rearrange. */
448
+ direction?: FlowDirection
449
+ locked?: boolean
450
+ onLockedChange?: (locked: boolean) => void
451
+ "aria-label": string
452
+ className?: string
453
+ }
454
+
455
+ function CanvasInner({ value, onValueChange, selectedId = null, onSelect, onAddAfter, onInsert, onAddTrigger, direction = "vertical", locked = false, onLockedChange, className, "aria-label": label }: FlowCanvasProps) {
456
+ const flow = useReactFlow()
457
+ const [message, setMessage] = React.useState("")
458
+ const [menuFor, setMenuFor] = React.useState<string | null>(null)
459
+ const [edgeSel, setEdgeSel] = React.useState<string | null>(null)
460
+ const [moving, setMoving] = React.useState<{ id: string; from: { x: number; y: number } } | null>(null)
461
+ const announce = React.useCallback((text: string) => setMessage(text), [])
462
+ const actions = useActions({ value, onValueChange, onSelect, onAddAfter, onInsert, locked, announce })
463
+ const valueRef = React.useRef(value)
464
+ valueRef.current = value
465
+ const dragStart = React.useRef<FlowValue | null>(null)
466
+ const descId = React.useId()
467
+ // Controlled nodes must carry the sizes React Flow measures, or the minimap and framing have nothing to work with.
468
+ const [dims, setDims] = React.useState<Record<string, { width: number; height: number }>>({})
469
+ const [ready, setReady] = React.useState(false)
470
+ const box = React.useRef<HTMLDivElement>(null)
471
+
472
+ const nodes = React.useMemo<Node<{ step: FlowStep }>[]>(
473
+ () =>
474
+ flowOrder(value).map((s) => ({
475
+ id: s.id,
476
+ type: "step",
477
+ position: s.position,
478
+ data: { step: s },
479
+ selected: s.id === selectedId,
480
+ ariaLabel: describe(s, value),
481
+ deletable: false,
482
+ // Locked: a drag on a step does nothing (without nopan it would pan the canvas, so the step still seems to move).
483
+ className: locked ? "nopan cursor-default" : undefined,
484
+ measured: dims[s.id],
485
+ domAttributes: { "aria-describedby": descId },
486
+ })),
487
+ [value, selectedId, dims, descId, locked],
488
+ )
489
+ const edges = React.useMemo<Edge<{ text?: string; active?: boolean; link: FlowLink; name: string }>[]>(
490
+ () =>
491
+ value.links.map((l) => {
492
+ const to = value.steps.find((s) => s.id === l.to)
493
+ const from = value.steps.find((s) => s.id === l.from)
494
+ const text = [l.branch && BRANCH_WORD[l.branch], l.label].filter(Boolean).join(" · ") || undefined
495
+ const active = !!to?.run && to.run.state !== "waiting" && to.run.state !== "skipped"
496
+ const name = `${from?.title ?? l.from}${l.branch ? ` (${BRANCH_WORD[l.branch]})` : ""} and ${to?.title ?? l.to}`
497
+ return {
498
+ id: l.id,
499
+ type: "link",
500
+ source: l.from,
501
+ target: l.to,
502
+ sourceHandle: l.branch ?? null,
503
+ deletable: false,
504
+ selected: l.id === edgeSel,
505
+ ariaLabel: `Connection from ${from?.title ?? l.from}${l.branch ? `, ${BRANCH_WORD[l.branch]},` : ""} to ${to?.title ?? l.to}${l.label ? `, ${l.label}` : ""}`,
506
+ markerEnd: { type: MarkerType.ArrowClosed, width: 16, height: 16, color: l.id === edgeSel || active ? "var(--color-primary)" : "var(--color-control)" },
507
+ data: { text, active, link: l, name },
508
+ }
509
+ }),
510
+ [value, edgeSel],
511
+ )
512
+
513
+ const onNodesChange = (changes: NodeChange<Node<{ step: FlowStep }>>[]) => {
514
+ let next = valueRef.current
515
+ for (const c of changes) {
516
+ if (c.type === "position" && c.position) {
517
+ const p = c.position
518
+ next = { ...next, steps: next.steps.map((s) => (s.id === c.id ? { ...s, position: { x: Math.round(p.x), y: Math.round(p.y) } } : s)) }
519
+ } else if (c.type === "dimensions" && c.dimensions) {
520
+ const d = c.dimensions
521
+ setDims((m) => (m[c.id]?.width === d.width && m[c.id]?.height === d.height ? m : { ...m, [c.id]: d }))
522
+ } else if (c.type === "select" && c.selected) {
523
+ setEdgeSel(null)
524
+ onSelect(c.id)
525
+ }
526
+ }
527
+ if (next !== valueRef.current) onValueChange(next)
528
+ }
529
+
530
+ const fit = () => flow.fitView({ padding: 0.1, duration: reduced() ? 0 : 200 })
531
+ const zoom = (dir: 1 | -1) => (dir > 0 ? flow.zoomIn({ duration: reduced() ? 0 : 120 }) : flow.zoomOut({ duration: reduced() ? 0 : 120 }))
532
+ const mod = isMac() ? "Meta" : "Control"
533
+
534
+ // First view: the whole flow when it fits at a readable size (75% or more); otherwise its start, readable.
535
+ // Runs again when the direction changes, because the steps have moved.
536
+ const framedFor = React.useRef<string | null>(null)
537
+ React.useEffect(() => {
538
+ if (!ready || !box.current || framedFor.current === direction || value.steps.some((s) => !dims[s.id])) return
539
+ framedFor.current = direction
540
+ const b = flow.getNodesBounds(flow.getNodes())
541
+ const { clientWidth: w, clientHeight: h } = box.current
542
+ const whole = Math.min(w / (b.width + 96), h / (b.height + 96))
543
+ const z = Math.min(1, Math.max(0.75, whole))
544
+ const fits = whole >= 0.75
545
+ const x = direction === "vertical" || fits ? w / 2 - (b.x + b.width / 2) * z : 32 - b.x * z
546
+ const y = direction === "horizontal" || fits ? (h - b.height * z) / 2 - b.y * z : 32 - b.y * z
547
+ flow.setViewport({ x, y, zoom: z }, { duration: framedFor.current && !reduced() ? 200 : 0 })
548
+ }, [ready, dims, flow, value.steps, direction])
549
+
550
+ // Handles move to other sides when the direction changes; React Flow must measure them again or links keep the old sides.
551
+ const updateInternals = useUpdateNodeInternals()
552
+ React.useEffect(() => {
553
+ updateInternals(valueRef.current.steps.map((s) => s.id))
554
+ }, [direction, updateInternals])
555
+
556
+ // A step chosen elsewhere (the step list, a problem link) is brought into view.
557
+ React.useEffect(() => {
558
+ if (!selectedId || !box.current) return
559
+ const n = flow.getInternalNode(selectedId)
560
+ if (!n?.measured.width) return
561
+ const { x, y, zoom: z } = flow.getViewport()
562
+ const p = n.internals.positionAbsolute
563
+ const left = p.x * z + x, top = p.y * z + y, right = left + n.measured.width * z, bottom = top + (n.measured.height ?? 0) * z
564
+ const { clientWidth: w, clientHeight: h } = box.current
565
+ if (left < 0 || top < 0 || right > w || bottom > h) flow.setCenter(p.x + n.measured.width / 2, p.y + (n.measured.height ?? 0) / 2, { zoom: z, duration: reduced() ? 0 : 200 })
566
+ }, [selectedId, flow])
567
+
568
+ const focusStep = (id: string) => box.current?.querySelector<HTMLElement>(`.react-flow__node[data-id="${CSS.escape(id)}"]`)?.focus()
569
+
570
+ /** The nearest step in the arrow's direction, judged by what people see: distance along it, with sideways drift costing double. */
571
+ const nearest = (fromId: string, key: string) => {
572
+ const [dx, dy] = ARROW[key]
573
+ // Along the arrow, distance between the steps' leading edges (so a shorter card isn't "nearer"); sideways, between centres.
574
+ const centre = (s: FlowStep) => ({ x: s.position.x + (dx ? 0 : (dims[s.id]?.width ?? STEP_W) / 2), y: s.position.y + (dy ? 0 : (dims[s.id]?.height ?? 100) / 2) })
575
+ const from = value.steps.find((s) => s.id === fromId)
576
+ if (!from) return undefined
577
+ const c = centre(from)
578
+ let best: { id: string; score: number } | undefined
579
+ for (const s of flowOrder(value)) {
580
+ if (s.id === fromId) continue
581
+ const p = centre(s)
582
+ const along = (p.x - c.x) * dx + (p.y - c.y) * dy
583
+ if (along <= 8) continue
584
+ const side = Math.abs((p.x - c.x) * dy) + Math.abs((p.y - c.y) * dx)
585
+ const score = along + 2 * side
586
+ if (!best || score < best.score) best = { id: s.id, score }
587
+ }
588
+ return best?.id
589
+ }
590
+
591
+ const finishMove = (cancel: boolean) => {
592
+ if (!moving) return
593
+ const s = valueRef.current.steps.find((x) => x.id === moving.id)
594
+ if (cancel) {
595
+ onValueChange({ ...valueRef.current, steps: valueRef.current.steps.map((x) => (x.id === moving.id ? { ...x, position: moving.from } : x)) })
596
+ announce(`Move cancelled. ${s?.title ?? "Step"} is back where it was.`)
597
+ } else if (s) {
598
+ onValueChange(valueRef.current, { type: "move", text: `${s.title} moved` })
599
+ announce(`${s.title} dropped`)
600
+ }
601
+ setMoving(null)
602
+ }
603
+
604
+ // Capture phase, so these keys win over React Flow's own (which would move a selected step with the arrows).
605
+ const onKeyDownCapture = (e: React.KeyboardEvent) => {
606
+ const t = e.target as HTMLElement
607
+ if (t.closest("input, textarea, select, [contenteditable=true], [role=menu], [role=menuitem]")) return
608
+ const nodeEl = t.closest<HTMLElement>(".react-flow__node")
609
+ const edgeEl = t.closest<SVGElement>(".react-flow__edge")
610
+ const id = nodeEl?.dataset.id
611
+ const stop = () => { e.preventDefault(); e.stopPropagation() }
612
+
613
+ if (moving && id === moving.id) {
614
+ if (e.key in ARROW) {
615
+ stop()
616
+ const [dx, dy] = ARROW[e.key]
617
+ const step = e.shiftKey ? 40 : 8
618
+ onValueChange({ ...valueRef.current, steps: valueRef.current.steps.map((s) => (s.id === id ? { ...s, position: { x: s.position.x + dx * step, y: s.position.y + dy * step } } : s)) })
619
+ announce(`Moved ${DIR_WORD[e.key]}`)
620
+ } else if (e.key === "Enter" || e.key === " ") { stop(); finishMove(false) }
621
+ else if (e.key === "Escape") { stop(); finishMove(true) }
622
+ else if (e.key === "Tab") finishMove(false)
623
+ return
624
+ }
625
+ if ((e.metaKey || e.ctrlKey) && (e.key === "=" || e.key === "+")) { stop(); zoom(1) }
626
+ else if ((e.metaKey || e.ctrlKey) && e.key === "-") { stop(); zoom(-1) }
627
+ else if (e.shiftKey && (e.key === "!" || e.code === "Digit1")) { stop(); fit() }
628
+ else if (id && e.shiftKey && e.key === "F10") { stop(); setMenuFor(id) }
629
+ else if (id && e.key === "Enter") { stop(); setEdgeSel(null); onSelect(id) }
630
+ else if (id && e.key === " " && !locked) {
631
+ stop()
632
+ const s = value.steps.find((x) => x.id === id)
633
+ if (!s) return
634
+ setMoving({ id, from: s.position })
635
+ announce(`Picked up ${s.title}. Use the arrow keys to move it, Enter to drop, Escape to cancel.`)
636
+ } else if (id && e.key in ARROW && !e.altKey && !e.metaKey && !e.ctrlKey) {
637
+ stop()
638
+ const to = nearest(id, e.key)
639
+ if (to) focusStep(to)
640
+ } else if (id && (e.key === "Home" || e.key === "End")) {
641
+ stop()
642
+ const order = flowOrder(value)
643
+ const ends = order.filter((s) => s.kind === "end")
644
+ const to = e.key === "Home" ? order[0] : (ends.at(-1) ?? order[order.length - 1])
645
+ if (to) focusStep(to.id)
646
+ } else if (edgeEl && e.key === "Enter") { stop(); setEdgeSel(edgeEl.dataset.id ?? null) }
647
+ else if (!locked && (e.key === "Delete" || e.key === "Backspace")) {
648
+ const link = value.links.find((l) => l.id === (edgeEl?.dataset.id ?? edgeSel))
649
+ if (link) { stop(); setEdgeSel(null); actions.disconnect(link) }
650
+ else if (id || selectedId) { stop(); actions.remove(id ?? selectedId!) }
651
+ } else if (e.key === "Escape" && edgeSel) { stop(); setEdgeSel(null) }
652
+ }
653
+
654
+ return (
655
+ <ActionsCtx.Provider value={{ ...actions, menuFor, setMenuFor, movingId: moving?.id ?? null, direction, edgeSel, setEdgeSel }}>
656
+ <div ref={box} data-slot="flow-canvas" className={cn("relative h-full min-h-0 w-full bg-bg", className)} onKeyDownCapture={onKeyDownCapture}>
657
+ <ReactFlow
658
+ aria-label={label}
659
+ nodes={nodes}
660
+ edges={edges}
661
+ nodeTypes={NODE_TYPES}
662
+ edgeTypes={EDGE_TYPES}
663
+ onNodesChange={onNodesChange}
664
+ onInit={() => setReady(true)}
665
+ onNodeDragStart={() => { dragStart.current = valueRef.current }}
666
+ onNodeDragStop={(_, n) => {
667
+ const s = valueRef.current.steps.find((x) => x.id === n.id)
668
+ if (s && dragStart.current) onValueChange(valueRef.current, { type: "move", text: `${s.title} moved` })
669
+ dragStart.current = null
670
+ }}
671
+ onEdgeClick={(_, edge) => { setEdgeSel(edge.id); if (selectedId) onSelect(null) }}
672
+ onConnect={(c: Connection) => c.source && c.target && actions.connect(c.source, c.target, (c.sourceHandle as FlowBranch | null) ?? undefined)}
673
+ onReconnect={(old, c) => {
674
+ const v = valueRef.current
675
+ const link = v.links.find((l) => l.id === old.id)
676
+ if (!link || !c.source || !c.target || c.source === c.target) return
677
+ const branch = (c.sourceHandle as FlowBranch | null) ?? undefined
678
+ const title = (sid: string) => v.steps.find((s) => s.id === sid)?.title ?? "step"
679
+ const text = `Connection now runs from ${title(c.source)}${branch ? ` (${BRANCH_WORD[branch]})` : ""} to ${title(c.target)}`
680
+ onValueChange({ ...v, links: v.links.map((l) => (l.id === old.id ? { id: linkId(c.source, c.target, branch), from: c.source, to: c.target, branch } : l)) }, { type: "connect", text })
681
+ announce(text)
682
+ setEdgeSel(null)
683
+ }}
684
+ isValidConnection={(c) => c.source !== c.target && !!value.steps.find((s) => s.id === c.target && hasInput(s))}
685
+ onPaneClick={() => { setEdgeSel(null); onSelect(null) }}
686
+ nodesDraggable={!locked}
687
+ nodesConnectable={!locked}
688
+ edgesReconnectable={!locked}
689
+ reconnectRadius={16}
690
+ disableKeyboardA11y
691
+ deleteKeyCode={null}
692
+ selectionKeyCode={null}
693
+ multiSelectionKeyCode={null}
694
+ panActivationKeyCode={null}
695
+ snapToGrid
696
+ snapGrid={[8, 8]}
697
+ minZoom={0.25}
698
+ maxZoom={1.5}
699
+ proOptions={{ hideAttribution: false }}
700
+ attributionPosition="bottom-left"
701
+ ariaLabelConfig={{ "edge.a11yDescription.default": "Press Enter to select, then Delete to remove it.", "minimap.ariaLabel": "Overview of the flow", "handle.ariaLabel": "Connection point" }}
702
+ >
703
+ <Background variant={BackgroundVariant.Dots} gap={16} size={1} color="var(--color-line-strong)" />
704
+ <MiniMap pannable zoomable ariaLabel="Overview of the flow" className="rounded-sm border border-line max-md:hidden" nodeBorderRadius={4} />
705
+ <Panel position="bottom-center">
706
+ <div role="toolbar" aria-label="Canvas" className="flex items-center gap-0.5 rounded-md border border-line bg-popover p-1 shadow-pop">
707
+ <ToolButton label="Fit view" keys="Shift+1" onClick={fit}><Maximize /></ToolButton>
708
+ <ToolButton label="Zoom out" keys={`${mod}+-`} onClick={() => zoom(-1)}><ZoomOut /></ToolButton>
709
+ <ToolButton label="Zoom in" keys={`${mod}+=`} onClick={() => zoom(1)}><ZoomIn /></ToolButton>
710
+ {onLockedChange && (
711
+ <>
712
+ <span className="mx-0.5 h-4 w-px bg-line" aria-hidden="true" />
713
+ <ToolButton label={locked ? "Unlock canvas" : "Lock canvas"} pressed={locked} onClick={() => { onLockedChange(!locked); announce(locked ? "Canvas unlocked" : "Canvas locked. Steps can be opened but not moved, connected or deleted.") }}>
714
+ {locked ? <Lock /> : <LockOpen />}
715
+ </ToolButton>
716
+ {locked && <span aria-hidden="true" className="pr-2 pl-0.5 text-sm text-ink-2">Locked</span>}
717
+ </>
718
+ )}
719
+ </div>
720
+ </Panel>
721
+ </ReactFlow>
722
+ {value.steps.length === 0 && (
723
+ <div className="pointer-events-none absolute inset-0 grid place-items-center p-4">
724
+ <Empty className="pointer-events-auto max-w-sm rounded-sm border border-line bg-surface shadow-card">
725
+ <EmptyHeader>
726
+ <EmptyTitle level={2}>No steps yet</EmptyTitle>
727
+ <EmptyDescription>Add a trigger to say what starts this workflow, such as a new application.</EmptyDescription>
728
+ </EmptyHeader>
729
+ {onAddTrigger && <EmptyContent><Button size="sm" onClick={onAddTrigger}><Plus />Add trigger</Button></EmptyContent>}
730
+ </Empty>
731
+ </div>
732
+ )}
733
+ <p id={descId} className="sr-only">
734
+ {locked
735
+ ? "Arrow keys go to the next step in that direction. Enter opens settings, Shift F10 opens actions."
736
+ : "Arrow keys go to the next step in that direction. Enter opens settings, Space picks the step up to move it, Shift F10 opens actions, Delete deletes."}
737
+ </p>
738
+ <p role="status" aria-live="polite" className="sr-only">{message}</p>
739
+ </div>
740
+ </ActionsCtx.Provider>
741
+ )
742
+ }
743
+
744
+ /** The flow as connected steps on a canvas people pan and zoom. Fills its parent: give it a height. */
745
+ export function FlowCanvas(props: FlowCanvasProps) {
746
+ return (
747
+ <ReactFlowProvider>
748
+ <CanvasInner {...props} />
749
+ </ReactFlowProvider>
750
+ )
751
+ }
752
+
753
+ /* ---------------------------------------------------------------- step list */
754
+
755
+ export type FlowStepListProps = Omit<FlowCanvasProps, "onLockedChange" | "aria-label" | "direction" | "onInsert"> & { "aria-label": string }
756
+
757
+ /**
758
+ * The same flow as a nested list, shown beside the canvas: steps in flow order, a condition's steps under If yes and
759
+ * If no, a step reached twice written as "Continues to …". Choosing a row selects that step on the canvas too. Every row
760
+ * has the step menu, so adding, connecting and deleting never need the canvas.
761
+ */
762
+ export function FlowStepList({ value, onValueChange, selectedId = null, onSelect, onAddAfter, onAddTrigger, locked = false, className, "aria-label": label }: FlowStepListProps) {
763
+ const [message, setMessage] = React.useState("")
764
+ const actions = useActions({ value, onValueChange, onSelect, onAddAfter, locked, announce: setMessage })
765
+ const byId = new Map(value.steps.map((s) => [s.id, s]))
766
+ const placed = new Set<string>()
767
+
768
+ const row = (s: FlowStep) => (
769
+ <div className={cn("flex items-start gap-1 rounded-xs py-1 pr-1 pl-2", s.id === selectedId ? "bg-selected" : "hover:bg-hover")}>
770
+ <button type="button" onClick={() => onSelect(s.id)} aria-current={s.id === selectedId || undefined} className="flex min-w-0 flex-1 items-start gap-2 rounded-xs py-0.5 text-left outline-none focus-visible:ring-3 focus-visible:ring-primary/20">
771
+ <span aria-hidden="true" className="mt-0.5 grid size-5 shrink-0 place-items-center text-ink-3 [&>svg]:size-3.5">{s.icon}</span>
772
+ <span className="grid min-w-0 flex-1">
773
+ <span className="truncate text-base text-ink">{s.title}</span>
774
+ {(s.run || s.issue) && <StepStatus step={s} />}
775
+ </span>
776
+ </button>
777
+ <DropdownMenu>
778
+ <DropdownMenuTrigger asChild>
779
+ <Button variant="ghost" size="icon-xs" aria-label={`Actions for ${s.title}`}><MoreVertical /></Button>
780
+ </DropdownMenuTrigger>
781
+ <DropdownMenuContent align="end" className="w-56"><StepMenuItems step={s} actions={actions} /></DropdownMenuContent>
782
+ </DropdownMenu>
783
+ </div>
784
+ )
785
+
786
+ const branch = (s: FlowStep, b: FlowBranch | undefined): React.ReactNode =>
787
+ value.links.filter((l) => l.from === s.id && l.branch === b).map((l) => {
788
+ const t = byId.get(l.to)
789
+ if (!t) return null
790
+ if (placed.has(t.id))
791
+ return (
792
+ <li key={l.id} className="flex items-center gap-1.5 py-1 pl-2 text-sm text-ink-2">
793
+ <CornerDownRight className="size-3.5 text-ink-3" aria-hidden="true" />Continues to <button type="button" className="rounded-xs font-medium text-primary-ink hover:underline" onClick={() => onSelect(t.id)}>{t.title}</button>
794
+ </li>
795
+ )
796
+ return <React.Fragment key={l.id}>{item(t)}</React.Fragment>
797
+ })
798
+
799
+ const item = (s: FlowStep): React.ReactNode => {
800
+ placed.add(s.id)
801
+ return (
802
+ <li key={s.id} className="grid">
803
+ {row(s)}
804
+ {s.kind === "condition" ? (
805
+ (["yes", "no"] as const).map((b) => (
806
+ <div key={b} className="ml-4 grid border-l border-line pl-2">
807
+ <p className="pt-1 pl-2 text-sm font-medium text-ink-2">If {BRANCH_WORD[b].toLowerCase()}</p>
808
+ <ul className="grid">{value.links.some((l) => l.from === s.id && l.branch === b) ? branch(s, b) : <li className="py-1 pl-2 text-sm text-ink-3">No step yet</li>}</ul>
809
+ </div>
810
+ ))
811
+ ) : (
812
+ <ul className="grid">{branch(s, undefined)}</ul>
813
+ )}
814
+ </li>
815
+ )
816
+ }
817
+
818
+ const tree = value.steps.filter((s) => s.kind === "trigger").map((r) => item(r))
819
+ const loose = value.steps.filter((s) => !placed.has(s.id))
820
+
821
+ return (
822
+ <div data-slot="flow-step-list" className={cn("grid content-start gap-4", className)}>
823
+ {value.steps.length === 0 ? (
824
+ <Empty className="rounded-sm border border-line">
825
+ <EmptyHeader><EmptyTitle level={2}>No steps yet</EmptyTitle><EmptyDescription>Add a trigger to say what starts this workflow, such as a new application.</EmptyDescription></EmptyHeader>
826
+ {onAddTrigger && <EmptyContent><Button size="sm" onClick={onAddTrigger}><Plus />Add trigger</Button></EmptyContent>}
827
+ </Empty>
828
+ ) : (
829
+ <ul aria-label={label} className="grid">{tree}</ul>
830
+ )}
831
+ {loose.length > 0 && (
832
+ <section aria-label="Not connected" className="grid gap-1">
833
+ <h3 className="pl-2 text-sm font-medium text-ink-2">Not connected · {loose.length}</h3>
834
+ <ul className="grid">{loose.map((s) => <li key={s.id}>{row(s)}</li>)}</ul>
835
+ </section>
836
+ )}
837
+ <p role="status" aria-live="polite" className="sr-only">{message}</p>
838
+ </div>
839
+ )
840
+ }