diff --git a/.changeset/node-icon-accent-ui.md b/.changeset/node-icon-accent-ui.md new file mode 100644 index 000000000..c9f873b45 --- /dev/null +++ b/.changeset/node-icon-accent-ui.md @@ -0,0 +1,9 @@ +--- +'@workflowbuilder/ui': minor +--- + +`NodeIcon` takes an optional `accent` that tints its container and colors the glyph: `'blue' | 'green' | 'orange' | 'violet' | 'gray' | 'violet-gradient'`, or a custom name read from `--wb-public-node-icon-color-` and `--wb-public-node-icon-container-background-color-`; `NodeIconAccent` is exported. The icon is 36px with an 18px glyph (46px and 24px before): `--wb-public-node-icon-padding` defaults to 8px and the new `--wb-public-node-icon-glyph-size` sets the glyph through `font-size`. + +Breaking changes: + +- Render the icon inside `NodeIcon` sized in `em`, for example a Phosphor icon, otherwise it keeps its own size and the header ports are not centred on it. diff --git a/.changeset/node-icon-accent.md b/.changeset/node-icon-accent.md new file mode 100644 index 000000000..e9dfac4ef --- /dev/null +++ b/.changeset/node-icon-accent.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +Node definitions take an optional `accent` that colors the node's icon on the canvas and in the palette: `'blue' | 'green' | 'orange' | 'violet' | 'gray' | 'violet-gradient'`, or a custom name backed by `--wb-public-node-icon-*-` variables. It is read by node type and never saved into the diagram; `WorkflowNodeTemplateProps` gains `accent`, `NodeIconAccent` is exported, and node definitions resolve as soon as `WorkflowBuilder.Root` mounts, so a layout without a Palette also shows node accents and the node Properties form. diff --git a/.changeset/node-icon-size.md b/.changeset/node-icon-size.md new file mode 100644 index 000000000..d1a2f86be --- /dev/null +++ b/.changeset/node-icon-size.md @@ -0,0 +1,9 @@ +--- +'@workflowbuilder/sdk': minor +--- + +Node icons are a 36px square with an 18px glyph (46px and 24px before): `--wb-public-node-icon-padding` defaults to 8px and the new `--wb-public-node-icon-glyph-size` sets the glyph. The AI Agent icon uses the `violet-gradient` accent, and `Icon` takes `size="inherit"` to follow the surrounding font size. + +Breaking changes: + +- In custom node templates render the icon inside `NodeIcon` with `size="inherit"` instead of `size="large"`, otherwise the glyph stays 24px. diff --git a/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.tsx b/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.tsx index a95701547..4d969e789 100644 --- a/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.tsx +++ b/apps/ai-studio/src/components/human-decision/node-template/human-decision-template.tsx @@ -34,6 +34,7 @@ export const HumanDecisionNodeTemplate = defineNodeTemplate) => { - const iconElement = useMemo(() => , [icon]); + const iconElement = useMemo(() => , [icon]); const decisionRequest = data?.properties.decisionRequest; const actions = useMemo(() => routedActions(decisionRequest), [decisionRequest]); @@ -53,7 +54,7 @@ export const HumanDecisionNodeTemplate = defineNodeTemplate - + diff --git a/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx b/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx index 595d0911c..0e553d980 100644 --- a/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx +++ b/apps/demo/src/app/components/multi-port-node/multi-port-node-template.tsx @@ -21,6 +21,7 @@ export const MultiPortNodeTemplate = defineNodeTemplate( memo( ({ icon, + accent, label, description, selected = false, @@ -30,7 +31,7 @@ export const MultiPortNodeTemplate = defineNodeTemplate( }: WorkflowNodeTemplateProps) => { const status = data?.properties.status ?? statusOptions.active.value; - const iconElement = useMemo(() => , [icon]); + const iconElement = useMemo(() => , [icon]); const barClassName = clsx(styles['status-bar'], statusClass[status] ?? styles['status-draft']); const handleTargetTopId = getHandleId({ handleType: 'target', innerId: 'top' }); @@ -43,7 +44,7 @@ export const MultiPortNodeTemplate = defineNodeTemplate(
- + diff --git a/apps/docs/src/components/ui-examples/node-icon.tsx b/apps/docs/src/components/ui-examples/node-icon.tsx index b527c3349..db5ddb1e6 100644 --- a/apps/docs/src/components/ui-examples/node-icon.tsx +++ b/apps/docs/src/components/ui-examples/node-icon.tsx @@ -1,4 +1,4 @@ -import { User } from '@phosphor-icons/react'; +import { Sparkle, User } from '@phosphor-icons/react'; import { NodeIcon, NodePanel } from '@workflowbuilder/ui'; import { ComponentPreview } from './component-preview'; @@ -12,6 +12,18 @@ export function NodeIconExample() { Node with Icon + + + } accent="violet" /> + Violet accent + + + + + } accent="violet-gradient" /> + AI accent + + ); } diff --git a/apps/docs/src/content/docs/guides/add-a-custom-node.mdx b/apps/docs/src/content/docs/guides/add-a-custom-node.mdx index 419620631..fc93597c1 100644 --- a/apps/docs/src/content/docs/guides/add-a-custom-node.mdx +++ b/apps/docs/src/content/docs/guides/add-a-custom-node.mdx @@ -18,12 +18,12 @@ Custom nodes let you extend Workflow Builder with node types that match your spe A node is a [`PaletteItem`](/api/types/paletteitem/) made of four pieces. File organisation is a suggestion — collapse them into one file if you prefer. -| Piece | Defined in | Purpose | -| ----------------------- | ---------------------------- | --------------------------------------------------------------------- | -| `schema` | `schema.ts` | Shape and validation of the node's properties. | -| `uischema` | `uischema.ts` | How those properties render in the property panel. | -| `defaultPropertiesData` | `default-properties-data.ts` | Initial values applied when the node is dropped. | -| Top-level fields | `.ts` | `type`, `label`, `description`, `icon` — plus optional `isStartNode`. | +| Piece | Defined in | Purpose | +| ----------------------- | ---------------------------- | ---------------------------------------------------------------------------------- | +| `schema` | `schema.ts` | Shape and validation of the node's properties. | +| `uischema` | `uischema.ts` | How those properties render in the property panel. | +| `defaultPropertiesData` | `default-properties-data.ts` | Initial values applied when the node is dropped. | +| Top-level fields | `.ts` | `type`, `label`, `description`, `icon` — plus optional `accent` and `isStartNode`. | ## 1. JSON Schema — `webhook/schema.ts` @@ -106,12 +106,15 @@ export const webhookNode: PaletteItem = { label: 'Webhook', description: 'Send data to an external HTTP endpoint', icon: 'Globe', // see the WBIcon name union for valid icon names + accent: 'blue', defaultPropertiesData, schema, uischema, }; ``` +`accent` colors the node's icon: `'blue'`, `'green'`, `'orange'`, `'violet'`, `'gray'`, `'violet-gradient'`, or a name of your own (see [`NodeIcon`](/ui-library/diagram-components/node-icon/#custom-accent)). The editor reads it from the palette item by node type every time it renders, and never saves it into the diagram, so changing it restyles existing diagrams too. Without it the icon keeps the default color, except on the built-in AI Agent template, which defaults to `'violet-gradient'`. + If this node is where a run begins, add `isStartNode: true`. The editor copies the flag onto every node dropped from this palette item, so it travels with the saved diagram as `data.isStartNode` and your execution engine can find the entry point directly: ```ts @@ -224,8 +227,16 @@ import { Handle, Position } from '@xyflow/react'; import { memo, useMemo } from 'react'; export const MyNodeTemplate = memo( - ({ icon, label, description, selected = false, disabled = false, showHandles = true }: WorkflowNodeTemplateProps) => { - const iconElement = useMemo(() => , [icon]); + ({ + icon, + accent, + label, + description, + selected = false, + disabled = false, + showHandles = true, + }: WorkflowNodeTemplateProps) => { + const iconElement = useMemo(() => , [icon]); const handleTargetTopId = getHandleId({ handleType: 'target', innerId: 'top' }); const handleTargetLeftId = getHandleId({ handleType: 'target', innerId: 'left' }); @@ -235,7 +246,7 @@ export const MyNodeTemplate = memo( return ( - + @@ -252,7 +263,7 @@ export const MyNodeTemplate = memo( The example composes the node from `@workflowbuilder/ui` primitives (`NodePanel.Root`, `NodePanel.Header`, `NodePanel.Handles`) — the same building blocks Workflow Builder uses for its own node renderers, so the result matches the editor's visual language out of the box. -The component receives [`WorkflowNodeTemplateProps`](/api/components/workflownodetemplateprops/). Use [`getHandleId`](/api/utilities/gethandleid/) for handle IDs and pass `innerId` when a node has more than one handle of the same type. If your template needs typed access to `data.properties`, wrap the component in [`defineNodeTemplate`](/api/components/definenodetemplate/) to bind a schema-derived properties type. +The component receives [`WorkflowNodeTemplateProps`](/api/components/workflownodetemplateprops/), including the palette item's `accent`; `NodeIcon` sets the glyph size, so render the icon with `size="inherit"`. Use [`getHandleId`](/api/utilities/gethandleid/) for handle IDs and pass `innerId` when a node has more than one handle of the same type. If your template needs typed access to `data.properties`, wrap the component in [`defineNodeTemplate`](/api/components/definenodetemplate/) to bind a schema-derived properties type. Wire it through the `nodeTemplates` prop on ``: diff --git a/apps/docs/src/content/docs/ui-library/diagram-components/node-icon.mdx b/apps/docs/src/content/docs/ui-library/diagram-components/node-icon.mdx index 346d3e94b..1fef3c8eb 100644 --- a/apps/docs/src/content/docs/ui-library/diagram-components/node-icon.mdx +++ b/apps/docs/src/content/docs/ui-library/diagram-components/node-icon.mdx @@ -31,6 +31,47 @@ function NodeHeader({ label, description }) { } ``` +## Accent + +`accent` tints the container, hides its border and colors the glyph. `blue`, `green`, +`orange`, `violet` and `gray` are named by hue; `violet-gradient` is a gradient with a white glyph. +Without `accent` the icon keeps the default color. A `disabled` icon keeps its border and the +disabled colors, whatever its accent. + +```tsx +} accent="violet" /> +``` + +## Custom accent + +Any other lowercase name works too: the icon reads `--wb-public-node-icon-color-` for the +glyph and `--wb-public-node-icon-container-background-color-` for the container. Define +both for each theme; a name without them falls back to the default color. + +```css +:root { + --wb-public-node-icon-color-teal: #0f766e; + --wb-public-node-icon-container-background-color-teal: #ccfbf1; +} + +html[data-theme='dark'] { + --wb-public-node-icon-color-teal: #5eead4; + --wb-public-node-icon-container-background-color-teal: #134e4a; +} +``` + +```tsx +} accent="teal" /> +``` + +## Size + +The container pads the icon with `--wb-public-node-icon-padding` and sets `font-size` to +`--wb-public-node-icon-glyph-size`, so an icon sized in `em` - such as a Phosphor icon, or +the SDK's `Icon` with `size="inherit"` - follows it. To render a larger icon, set the +variables on `NodePanel.Root` or an element above it, not on `NodePanel.Header` or the +icon: the header ports read both to stay centred on the icon. + ## Disabled `disabled` mutes the glyph and the container with the Node Disabled colors. diff --git a/apps/icons/assets/ai-agent.svg b/apps/icons/assets/ai-agent.svg index 92cbde6f2..c2078e314 100644 --- a/apps/icons/assets/ai-agent.svg +++ b/apps/icons/assets/ai-agent.svg @@ -1,4 +1,4 @@ - - + + diff --git a/apps/icons/src/icon.tsx b/apps/icons/src/icon.tsx index d1df7b94c..5c03f0d35 100644 --- a/apps/icons/src/icon.tsx +++ b/apps/icons/src/icon.tsx @@ -7,6 +7,7 @@ import { type WBIcon, iconMap } from '../dist'; type IconProps = { name: WBIcon; + /** `inherit` follows the surrounding font-size, so a container such as `NodeIcon` sets the glyph size. */ size?: Size; color?: string; } & React.SVGProps; @@ -88,10 +89,11 @@ function IconFallback({ size = 'medium' }: Pick) { return ; } -type Size = 'extra-large' | 'large' | 'medium' | 'small'; +type Size = 'extra-large' | 'large' | 'medium' | 'small' | 'inherit'; const iconSizeMap: Record = { small: '0.5rem', medium: '1rem', large: '1.5rem', 'extra-large': '2rem', + inherit: '1em', }; 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 8fb87c6f6..0e80c8f3e 100644 --- a/packages/sdk/src/features/diagram/handles/get-handles-alignment.ts +++ b/packages/sdk/src/features/diagram/handles/get-handles-alignment.ts @@ -13,9 +13,9 @@ type HandlesAlignment = NonNullable['al // 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 +// stability of the resulting port Y depends on the header port `top` rule in +// `packages/ui/src/components/node/node-panel/handle.module.css`: 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 { diff --git a/packages/sdk/src/features/diagram/hooks/use-node-accent.ts b/packages/sdk/src/features/diagram/hooks/use-node-accent.ts new file mode 100644 index 000000000..5010bf8c4 --- /dev/null +++ b/packages/sdk/src/features/diagram/hooks/use-node-accent.ts @@ -0,0 +1,6 @@ +import type { NodeIconAccent } from '../../../node/common'; +import { useStore } from '../../../store/store'; + +export function useNodeAccent(nodeType: string): NodeIconAccent | undefined { + return useStore((store) => store.getNodeDefinition(nodeType)?.accent); +} diff --git a/packages/sdk/src/features/diagram/hooks/use-node-types.spec.ts b/packages/sdk/src/features/diagram/hooks/use-node-types.spec.ts index 7475d3430..9eb9aace1 100644 --- a/packages/sdk/src/features/diagram/hooks/use-node-types.spec.ts +++ b/packages/sdk/src/features/diagram/hooks/use-node-types.spec.ts @@ -4,7 +4,7 @@ import { createElement } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { setCustomNodeTemplates } from '../../../data/node-templates'; -import type { LayoutDirection } from '../../../node/common'; +import type { LayoutDirection, PaletteItem } from '../../../node/common'; import { NodeType } from '../../../node/node-types'; import type { WorkflowNodeTemplateProps } from '../nodes/workflow-node-template/workflow-node-template'; @@ -14,9 +14,11 @@ vi.mock('../nodes/ai-node-container', () => ({ AiNodeContainer: () => null })); vi.mock('../nodes/decision-node-container', () => ({ DecisionNodeContainer: () => null })); let mockLayoutDirection: LayoutDirection = 'RIGHT'; +let mockNodeDefinitions: Record = {}; +type FakeState = { layoutDirection: LayoutDirection; getNodeDefinition: (type: string) => PaletteItem | undefined }; vi.mock('../../../store/store', () => ({ - useStore: (selector: (state: { layoutDirection: LayoutDirection }) => T) => - selector({ layoutDirection: mockLayoutDirection }), + useStore: (selector: (state: FakeState) => T) => + selector({ layoutDirection: mockLayoutDirection, getNodeDefinition: (type) => mockNodeDefinitions[type] }), })); const { useNodeTypes } = await import('./use-node-types'); @@ -44,6 +46,7 @@ describe('useNodeTypes', () => { afterEach(() => { setCustomNodeTemplates(null); mockLayoutDirection = 'RIGHT'; + mockNodeDefinitions = {}; vi.restoreAllMocks(); }); @@ -111,4 +114,25 @@ describe('useNodeTypes', () => { expect(received).toEqual([{ layoutDirection: 'DOWN' }]); }); + + it('forwards the accent from the definition of data.type to the custom template', () => { + const received: { accent?: string }[] = []; + function Recorder(props: WorkflowNodeTemplateProps) { + received.push({ accent: props.accent }); + return null; + } + setCustomNodeTemplates({ 'multi-port': Recorder }); + mockNodeDefinitions = { 'multi-port': { type: 'multi-port', accent: 'violet' } as PaletteItem }; + + const { result } = renderHook(() => useNodeTypes()); + const Adapter = result.current['multi-port'] as ComponentType; + + renderAdapter(Adapter, { + type: 'multi-port', + icon: 'Star', + properties: { errors: [], customErrors: [] }, + }); + + expect(received).toEqual([{ accent: 'violet' }]); + }); }); diff --git a/packages/sdk/src/features/diagram/hooks/use-node-types.tsx b/packages/sdk/src/features/diagram/hooks/use-node-types.tsx index 7dc51c8f3..a46f1f7af 100644 --- a/packages/sdk/src/features/diagram/hooks/use-node-types.tsx +++ b/packages/sdk/src/features/diagram/hooks/use-node-types.tsx @@ -11,6 +11,7 @@ import { DecisionNodeContainer } from '../nodes/decision-node-container'; import { NodeContainer } from '../nodes/node-container'; import { StartContainer } from '../nodes/start-node-container'; import type { WorkflowNodeTemplateProps } from '../nodes/workflow-node-template/workflow-node-template'; +import { useNodeAccent } from './use-node-accent'; const BUILT_IN_KEYS: ReadonlySet = new Set([ NodeType.Node, @@ -27,14 +28,16 @@ const BUILT_IN_KEYS: ReadonlySet = new Set([ // that need drag-to-create connections on the node body. function adaptCustomNodeTemplate(Template: ComponentType) { const Adapter = memo(({ id, data, selected }: NodeProps) => { - const { icon, properties } = data; + const { icon, properties, type } = data; const { label = '', description = '' } = properties; const isValid = getIsValidFromProperties(properties); + const accent = useNodeAccent(type); const layoutDirection = useStore((store) => store.layoutDirection); return (