From d47e69d7c43b75fd7842147bd6dc3b935cb9d16b Mon Sep 17 00:00:00 2001 From: Wojciech Krzesaj Date: Wed, 30 Sep 2026 13:34:20 +0200 Subject: [PATCH 1/5] feat(ui): node icons take an accent color and a smaller size --- .changeset/node-icon-accent-ui.md | 9 ++ .changeset/node-icon-accent.md | 5 + .changeset/node-icon-size.md | 7 ++ .../node-template/human-decision-template.tsx | 5 +- .../multi-port-node-template.tsx | 5 +- .../src/components/ui-examples/node-icon.tsx | 14 ++- .../content/docs/guides/add-a-custom-node.mdx | 31 ++++-- .../diagram-components/node-icon.mdx | 17 ++++ apps/icons/assets/ai-agent.svg | 4 +- apps/icons/src/icon.tsx | 4 +- .../features/diagram/hooks/use-node-accent.ts | 7 ++ .../diagram/hooks/use-node-types.spec.ts | 30 +++++- .../features/diagram/hooks/use-node-types.tsx | 5 +- .../ai-agent-node-template.module.css | 8 -- .../ai-agent-node-template.tsx | 9 +- .../diagram/nodes/ai-node-container.tsx | 3 + .../diagram/nodes/decision-node-container.tsx | 5 +- .../decision-node-template.tsx | 8 +- .../features/diagram/nodes/node-container.tsx | 5 +- .../diagram/nodes/start-node-container.tsx | 5 +- .../start-node-template.tsx | 8 +- .../workflow-node-template.tsx | 11 ++- .../palette/node-preview-container.spec.tsx | 15 ++- .../palette/node-preview-container.tsx | 3 +- packages/sdk/src/index.ts | 1 + packages/sdk/src/node/common.ts | 9 ++ packages/sdk/src/node/node-data.ts | 8 +- .../node/node-icon/node-icon.module.css | 96 ++++++++++++++++++- .../node/node-icon/node-icon.spec.tsx | 42 ++++++++ .../components/node/node-icon/node-icon.tsx | 24 ++++- .../node/node-panel/handle.module.css | 5 +- 31 files changed, 355 insertions(+), 53 deletions(-) create mode 100644 .changeset/node-icon-accent-ui.md create mode 100644 .changeset/node-icon-accent.md create mode 100644 .changeset/node-icon-size.md create mode 100644 packages/sdk/src/features/diagram/hooks/use-node-accent.ts create mode 100644 packages/ui/src/components/node/node-icon/node-icon.spec.tsx diff --git a/.changeset/node-icon-accent-ui.md b/.changeset/node-icon-accent-ui.md new file mode 100644 index 000000000..9ac0655be --- /dev/null +++ b/.changeset/node-icon-accent-ui.md @@ -0,0 +1,9 @@ +--- +'@workflowbuilder/ui': minor +--- + +`NodeIcon` takes an optional `accent` (`'blue' | 'green' | 'orange' | 'violet' | 'neutral' | 'ai'`) that tints its container and colors the glyph, and `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..c39bd55dd --- /dev/null +++ b/.changeset/node-icon-accent.md @@ -0,0 +1,5 @@ +--- +'@workflowbuilder/sdk': minor +--- + +Node definitions take an optional `accent` (`'blue' | 'green' | 'orange' | 'violet' | 'neutral' | 'ai'`) that colors the node's icon on the canvas and in the palette; it is read by node type and never saved into the diagram. `WorkflowNodeTemplateProps` gains `accent`, `NodeIconAccent` is exported, and `Icon` takes `size="inherit"` to follow the surrounding font size. diff --git a/.changeset/node-icon-size.md b/.changeset/node-icon-size.md new file mode 100644 index 000000000..84878aa90 --- /dev/null +++ b/.changeset/node-icon-size.md @@ -0,0 +1,7 @@ +--- +'@workflowbuilder/sdk': minor +--- + +Node icons are a 36px square with an 18px glyph (46px and 24px before), and the AI Agent icon uses the `ai` accent gradient. `--wb-public-node-icon-padding` defaults to 8px (10px before) and the new `--wb-public-node-icon-glyph-size` sets the glyph. + +Migration for 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..cd8636c08 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="ai" /> + 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..59439e6f2 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'`, `'neutral'`, or `'ai'` for a gradient. 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. + 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..33f924510 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,23 @@ function NodeHeader({ label, description }) { } ``` +## Accent + +`accent` colors the icon by node type: a tinted container without a border and a glyph in +the accent color. `blue`, `green`, `orange`, `violet` and `neutral` are named by hue; `ai` +is a gradient with a white glyph. Without `accent` the icon keeps the default color. + +```tsx +} accent="violet" /> +``` + +## 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. Override the variables on a wrapping +element to render a larger icon, for example in a panel header. + ## 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..28b5f1bd1 100644 --- a/apps/icons/src/icon.tsx +++ b/apps/icons/src/icon.tsx @@ -88,10 +88,12 @@ function IconFallback({ size = 'medium' }: Pick) { return ; } -type Size = 'extra-large' | 'large' | 'medium' | 'small'; +// `inherit` follows the surrounding font-size, so a container such as `NodeIcon` sets the glyph size. +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/hooks/use-node-accent.ts b/packages/sdk/src/features/diagram/hooks/use-node-accent.ts new file mode 100644 index 000000000..bc5625cb9 --- /dev/null +++ b/packages/sdk/src/features/diagram/hooks/use-node-accent.ts @@ -0,0 +1,7 @@ +import type { NodeIconAccent } from '@workflowbuilder/ui'; + +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..a6becda62 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 of the definition found by node type, not stored in the node data', () => { + 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 (