From 1b51abb32cfd9594e034df842f954c2d4e3f0f51 Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Mon, 25 May 2026 20:25:02 +0200 Subject: [PATCH 1/3] fix(sdk): fix hardcoded source handle position --- .../nodes/decision-node-template/decision-node-template.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx b/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx index 07b6c2162..9f5529533 100644 --- a/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx @@ -1,5 +1,5 @@ import { NodeDescription, NodeIcon, NodePanel, Status } from '@synergycodes/overflow-ui'; -import { Handle, Position } from '@xyflow/react'; +import { Handle } from '@xyflow/react'; import { memo, useMemo } from 'react'; import { Icon } from '@workflow-builder/icons'; @@ -47,6 +47,7 @@ export const DecisionNodeTemplate = memo( const handleSourceId = getHandleId({ nodeId: id, handleType: 'source' }); const handleTargetPosition = getHandlePosition({ direction: layoutDirection, handleType: 'target' }); + const handleSourcePosition = getHandlePosition({ direction: layoutDirection, handleType: 'source' }); const isCanvasNode = showHandles; @@ -70,7 +71,7 @@ export const DecisionNodeTemplate = memo( - + ); From 685ee58eb9746693238f3c665628eb83e52a959a Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Tue, 26 May 2026 07:13:02 +0200 Subject: [PATCH 2/3] fix(sdk): proper port positions for long descriptions --- .../handles/get-handles-alignment.spec.ts | 11 ++++++++ .../diagram/handles/get-handles-alignment.ts | 13 ++++++++++ .../ai-agent-node-template.tsx | 3 ++- .../decision-node-template.tsx | 3 ++- .../start-node-template.tsx | 3 ++- .../workflow-node-template.tsx | 3 ++- packages/sdk/src/index.css | 26 +++++++++++++++++++ 7 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/src/features/diagram/handles/get-handles-alignment.spec.ts create mode 100644 packages/sdk/src/features/diagram/handles/get-handles-alignment.ts diff --git a/packages/sdk/src/features/diagram/handles/get-handles-alignment.spec.ts b/packages/sdk/src/features/diagram/handles/get-handles-alignment.spec.ts new file mode 100644 index 000000000..32c06a3ff --- /dev/null +++ b/packages/sdk/src/features/diagram/handles/get-handles-alignment.spec.ts @@ -0,0 +1,11 @@ +import { getHandlesAlignment } from './get-handles-alignment'; + +describe('getHandlesAlignment', () => { + it('returns "header" for horizontal flow so handles align with the header section', () => { + expect(getHandlesAlignment({ layoutDirection: 'RIGHT' })).toBe('header'); + }); + + it('returns "center" for vertical flow so handles sit on the node body axis', () => { + expect(getHandlesAlignment({ layoutDirection: 'DOWN' })).toBe('center'); + }); +}); diff --git a/packages/sdk/src/features/diagram/handles/get-handles-alignment.ts b/packages/sdk/src/features/diagram/handles/get-handles-alignment.ts new file mode 100644 index 000000000..387eac502 --- /dev/null +++ b/packages/sdk/src/features/diagram/handles/get-handles-alignment.ts @@ -0,0 +1,13 @@ +import type { LayoutDirection } from '../../../node/common'; + +export type HandlesAlignment = 'header' | 'center'; + +// Where the handles sit vertically on a node. For horizontal flow ('RIGHT') +// every built-in template aligns handles to the header section so edges +// connect at the same Y regardless of how tall the node grows; for vertical +// flow ('DOWN') we center them on the node body. Keep all node templates +// routed through this helper so new node types inherit aligned handles +// instead of re-deriving the rule and drifting (see WB-192). +export function getHandlesAlignment({ layoutDirection }: { layoutDirection: LayoutDirection }): HandlesAlignment { + return layoutDirection === 'RIGHT' ? 'header' : 'center'; +} diff --git a/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/ai-agent-node-template.tsx b/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/ai-agent-node-template.tsx index d6763d376..0d88f9ecd 100644 --- a/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/ai-agent-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/ai-agent-node-template/ai-agent-node-template.tsx @@ -12,6 +12,7 @@ import type { ItemOption } from '../../../../node/node-schema'; import type { AiAgentTool } from '../../../json-form/types/controls'; import { getHandleId } from '../../handles/get-handle-id'; import { getHandlePosition } from '../../handles/get-handle-position'; +import { getHandlesAlignment } from '../../handles/get-handles-alignment'; import { ConnectableItem } from '../components/connectable-item/connectable-item'; import { SettingInfo } from './components/setting-info/setting-info'; import { ToolInfo } from './components/tool-info/tool-info'; @@ -56,7 +57,7 @@ export const AiAgentNodeTemplate = memo( const iconElement = useMemo(() => , [icon]); - const handlesAlignment = layoutDirection === 'RIGHT' ? 'header' : 'center'; + const handlesAlignment = getHandlesAlignment({ layoutDirection }); return ( diff --git a/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx b/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx index 9f5529533..0ae8f1185 100644 --- a/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/decision-node-template/decision-node-template.tsx @@ -12,6 +12,7 @@ import type { DecisionBranch } from '../../../json-form/types/controls'; import { OptionalNodeContent } from '../../../plugins-core/components/diagram/optional-node-content'; import { getHandleId } from '../../handles/get-handle-id'; import { getHandlePosition } from '../../handles/get-handle-position'; +import { getHandlesAlignment } from '../../handles/get-handles-alignment'; import { BranchesContainer } from './components/branches-container'; type Props = { @@ -51,7 +52,7 @@ export const DecisionNodeTemplate = memo( const isCanvasNode = showHandles; - const handlesAlignment = layoutDirection === 'RIGHT' ? 'header' : 'center'; + const handlesAlignment = getHandlesAlignment({ layoutDirection }); return ( diff --git a/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx b/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx index 06c78fc1e..823395934 100644 --- a/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx @@ -12,6 +12,7 @@ import { withOptionalComponentPlugins } from '../../../plugins-core/adapters/ada import { OptionalNodeContent } from '../../../plugins-core/components/diagram/optional-node-content'; import { getHandleId } from '../../handles/get-handle-id'; import { getHandlePosition } from '../../handles/get-handle-position'; +import { getHandlesAlignment } from '../../handles/get-handles-alignment'; type StartNodeTemplateProps = { id: string; @@ -49,7 +50,7 @@ const StartNodeTemplateComponent = memo( const hasContent = !!children; - const handlesAlignment = hasContent && layoutDirection === 'RIGHT' ? 'header' : 'center'; + const handlesAlignment = getHandlesAlignment({ layoutDirection }); return ( diff --git a/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx b/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx index a52f15a3e..9c83004cc 100644 --- a/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx @@ -13,6 +13,7 @@ import { withOptionalComponentPlugins } from '../../../plugins-core/adapters/ada import { OptionalNodeContent } from '../../../plugins-core/components/diagram/optional-node-content'; import { getHandleId } from '../../handles/get-handle-id'; import { getHandlePosition } from '../../handles/get-handle-position'; +import { getHandlesAlignment } from '../../handles/get-handles-alignment'; /** * Props for the editor's default workflow-node template. A custom node @@ -75,7 +76,7 @@ const WorkflowNodeTemplateComponent = memo( const hasContent = !!children; - const handlesAlignment = hasContent && layoutDirection === 'RIGHT' ? 'header' : 'center'; + const handlesAlignment = getHandlesAlignment({ layoutDirection }); return ( diff --git a/packages/sdk/src/index.css b/packages/sdk/src/index.css index df096b9bc..a9fe30a89 100644 --- a/packages/sdk/src/index.css +++ b/packages/sdk/src/index.css @@ -103,3 +103,29 @@ body:has(.base-Modal-root) { z-index: 1001 !important; } } + +/* WB-192: Pin the main node handles to the NodeIcon's vertical center so + multi-line descriptions don't shift port positions and edges between + adjacent nodes stay horizontal. Without this, overflow-ui's NodePanel + anchors handles via React Flow's default `top: 50%` of the header + wrapper — which grows with subtitle wrap, sliding handles by a few + pixels and crooking the edge. + + Scoped to header-aligned handles only: `[class*='_handle-wrapper_']` is + overflow-ui's outer NodePanel wrapper, and `[class*='_header_']` is the + alignment class applied to header-wrapper when `` (it does NOT match `_header-wrapper_` or + `_header-container_` — underscore boundary). This excludes per-row + handles rendered inside Content (decision branches' source ports, + ai-agent tool ports) — those already align to their own row. + + `--ax-public-node-icon-padding` + `--ax-public-node-icon-border-size` + come from overflow-ui tokens; `0.75rem` is half of `` + (1.5rem) used by every built-in template. Unlayered so it beats React + Flow's unlayered `top: 50%` default via the cascade. */ +.workflow-builder-root + [class*='_handle-wrapper_'] + [class*='_header_'] + :is(.react-flow__handle-left, .react-flow__handle-right) { + top: calc(var(--ax-public-node-icon-padding) + var(--ax-public-node-icon-border-size) + 0.75rem); +} From 4e8ee5454b622b7223bc915e7513df311bee5605 Mon Sep 17 00:00:00 2001 From: Kacper Cierzniewski Date: Tue, 26 May 2026 11:23:41 +0200 Subject: [PATCH 3/3] chore(sdk): tighten WB-192 fix scope, drop duplicate type, cross-ref icon size, add changeset --- .changeset/wb-192-stable-handle-y.md | 5 ++++ .../handles/get-handles-alignment.spec.ts | 11 -------- .../diagram/handles/get-handles-alignment.ts | 24 ++++++++++++----- .../start-node-template.tsx | 4 +-- .../workflow-node-template.tsx | 4 +-- packages/sdk/src/index.css | 26 ------------------- 6 files changed, 24 insertions(+), 50 deletions(-) create mode 100644 .changeset/wb-192-stable-handle-y.md delete mode 100644 packages/sdk/src/features/diagram/handles/get-handles-alignment.spec.ts diff --git a/.changeset/wb-192-stable-handle-y.md b/.changeset/wb-192-stable-handle-y.md new file mode 100644 index 000000000..4e3501b47 --- /dev/null +++ b/.changeset/wb-192-stable-handle-y.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': patch +--- + +fix: stabilize horizontal port Y on built-in node templates so multi-line descriptions no longer shift the port and bend edges between adjacent nodes. Unifies `` selection through one helper across all four built-in templates and pins the resulting port to the NodeIcon's vertical center via a global CSS rule scoped to a SDK-owned anchor class. Also fixes a separate latent bug where `DecisionNodeTemplate` hardcoded `Position.Right` on the source handle instead of honoring `layoutDirection` (broke decision nodes in `DOWN` layout). diff --git a/packages/sdk/src/features/diagram/handles/get-handles-alignment.spec.ts b/packages/sdk/src/features/diagram/handles/get-handles-alignment.spec.ts deleted file mode 100644 index 32c06a3ff..000000000 --- a/packages/sdk/src/features/diagram/handles/get-handles-alignment.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { getHandlesAlignment } from './get-handles-alignment'; - -describe('getHandlesAlignment', () => { - it('returns "header" for horizontal flow so handles align with the header section', () => { - expect(getHandlesAlignment({ layoutDirection: 'RIGHT' })).toBe('header'); - }); - - it('returns "center" for vertical flow so handles sit on the node body axis', () => { - expect(getHandlesAlignment({ layoutDirection: 'DOWN' })).toBe('center'); - }); -}); diff --git a/packages/sdk/src/features/diagram/handles/get-handles-alignment.ts b/packages/sdk/src/features/diagram/handles/get-handles-alignment.ts index 387eac502..b64b83367 100644 --- a/packages/sdk/src/features/diagram/handles/get-handles-alignment.ts +++ b/packages/sdk/src/features/diagram/handles/get-handles-alignment.ts @@ -1,13 +1,23 @@ +import type { NodePanel } from '@synergycodes/overflow-ui'; +import type { ComponentProps } from 'react'; + import type { LayoutDirection } from '../../../node/common'; -export type HandlesAlignment = 'header' | 'center'; +// Source-of-truth: overflow-ui's `` prop, so adding a new +// alignment in overflow-ui surfaces here as a type error instead of silently +// drifting. +type HandlesAlignment = NonNullable['alignment']>; -// Where the handles sit vertically on a node. For horizontal flow ('RIGHT') -// every built-in template aligns handles to the header section so edges -// connect at the same Y regardless of how tall the node grows; for vertical -// flow ('DOWN') we center them on the node body. Keep all node templates -// routed through this helper so new node types inherit aligned handles -// instead of re-deriving the rule and drifting (see WB-192). +// Unifies how built-in node templates choose the `alignment` prop they pass to +// ``, so the formula has one place to evolve instead of +// being re-derived per template. +// +// This helper alone does NOT make ports align across nodes. The visual +// stability of the resulting port Y depends on a companion global CSS rule in +// `packages/sdk/src/index.css` (search WB-192): the formula picks 'header' for +// horizontal flow, then the CSS pin anchors the port to the NodeIcon's +// vertical center so multi-line descriptions don't shift it. Both layers must +// stay in sync; removing either reintroduces the bug. export function getHandlesAlignment({ layoutDirection }: { layoutDirection: LayoutDirection }): HandlesAlignment { return layoutDirection === 'RIGHT' ? 'header' : 'center'; } diff --git a/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx b/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx index 823395934..fe57c81f0 100644 --- a/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/start-node-template/start-node-template.tsx @@ -48,8 +48,6 @@ const StartNodeTemplateComponent = memo( const iconElement = useMemo(() => , [icon]); - const hasContent = !!children; - const handlesAlignment = getHandlesAlignment({ layoutDirection }); return ( @@ -58,7 +56,7 @@ const StartNodeTemplateComponent = memo( - {hasContent && } + {!!children && } diff --git a/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx b/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx index 9c83004cc..b20debcbc 100644 --- a/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx +++ b/packages/sdk/src/features/diagram/nodes/workflow-node-template/workflow-node-template.tsx @@ -74,8 +74,6 @@ const WorkflowNodeTemplateComponent = memo( const iconElement = useMemo(() => , [icon]); - const hasContent = !!children; - const handlesAlignment = getHandlesAlignment({ layoutDirection }); return ( @@ -84,7 +82,7 @@ const WorkflowNodeTemplateComponent = memo( - {hasContent && } + {!!children && } diff --git a/packages/sdk/src/index.css b/packages/sdk/src/index.css index a9fe30a89..df096b9bc 100644 --- a/packages/sdk/src/index.css +++ b/packages/sdk/src/index.css @@ -103,29 +103,3 @@ body:has(.base-Modal-root) { z-index: 1001 !important; } } - -/* WB-192: Pin the main node handles to the NodeIcon's vertical center so - multi-line descriptions don't shift port positions and edges between - adjacent nodes stay horizontal. Without this, overflow-ui's NodePanel - anchors handles via React Flow's default `top: 50%` of the header - wrapper — which grows with subtitle wrap, sliding handles by a few - pixels and crooking the edge. - - Scoped to header-aligned handles only: `[class*='_handle-wrapper_']` is - overflow-ui's outer NodePanel wrapper, and `[class*='_header_']` is the - alignment class applied to header-wrapper when `` (it does NOT match `_header-wrapper_` or - `_header-container_` — underscore boundary). This excludes per-row - handles rendered inside Content (decision branches' source ports, - ai-agent tool ports) — those already align to their own row. - - `--ax-public-node-icon-padding` + `--ax-public-node-icon-border-size` - come from overflow-ui tokens; `0.75rem` is half of `` - (1.5rem) used by every built-in template. Unlayered so it beats React - Flow's unlayered `top: 50%` default via the cascade. */ -.workflow-builder-root - [class*='_handle-wrapper_'] - [class*='_header_'] - :is(.react-flow__handle-left, .react-flow__handle-right) { - top: calc(var(--ax-public-node-icon-padding) + var(--ax-public-node-icon-border-size) + 0.75rem); -}