Action Type Appearance Design
Goal
Let a host give Action nodes a distinct icon and/or accent color based on the node's action type (send an
email, post to Slack, call an AI agent, ...). Only the host knows how each action type should look. The
WorkflowEditor and WorkflowViewer must render the same appearance from the same host-supplied input.
Decisions
| Topic | Decision |
|---|---|
| Where appearance is defined | On ActionTypeDescriptor, the catalog the host already supplies. |
| What is customizable | An optional icon and an optional single accent color. |
| Persistence | None. Appearance is never written into the workflow document. |
| Viewer input | New optional actionTypes?: ActionTypeProvider prop, same type as spi.actionTypes. |
| Fallback | No catalog, catalog still loading, or no matching descriptor: the default Action look. |
| Other node types | Out of scope. Start, End, Human Task, Receive Event and Wait are unchanged. |
WorkflowDiffViewer |
Out of scope for now; it can adopt the same prop later. |
API
ui/src/types/spi.ts:
export interface ActionTypeDescriptor {
value: string;
label: string;
description?: string;
inputs?: ActionTypeField[];
outputs?: ActionTypeField[];
/** Icon shown on Action nodes of this type, replacing the default play icon. */
icon?: ReactNode;
/**
* Accent color for Action nodes of this type: any CSS color, including `var(...)`. It sets the node's
* border and icon color; background and text stay theme-controlled.
*/
color?: string;
}
ui/src/components/WorkflowViewer.tsx:
export interface WorkflowViewerProps {
// ...existing props
/** The Action Type catalog, used for Action node icons and colors. Pass the same value as `spi.actionTypes`. */
actionTypes?: ActionTypeProvider;
}
Hosts share one catalog between both components:
const actionTypes: ActionTypeDescriptor[] = [
{ value: 'email.send', label: 'Send email', icon: <EnvelopeIcon />, color: '#c9190b' },
{ value: 'slack.post', label: 'Post to Slack', icon: <SlackIcon /> },
];
<WorkflowEditor spi={{ actionTypes }} ... />
<WorkflowViewer actionTypes={actionTypes} ... />
Design
Lookup (pure)
New ui/src/utils/actionAppearance.ts:
export interface ActionAppearance { icon?: ReactNode; color?: string }
/** Appearance for an action node's `actionType`, or undefined when the catalog has no styled match. */
export function actionAppearanceFor(actionType: unknown, catalog: ActionTypeCatalog): ActionAppearance | undefined;
/** Map of node id to appearance for every action node with a styled descriptor. */
export function actionAppearanceByNodeId(nodes: WorkflowNode[], catalog: ActionTypeCatalog):
Map<string, ActionAppearance>;
The lookup uses whatever descriptors the catalog currently holds and ignores status. On a first async
load the list is empty, so nodes use the default look. During a reload the previous list is kept, so nodes
don't flash.
It returns undefined when the descriptor is missing or has neither icon nor color. A blank or
non-string color is ignored.
Data flow
Both components follow the existing pattern used for unresolvedActionType: compute per-node values from
the catalog and attach them to React Flow node data.
FlowNodeData(ui/src/utils/conversion.ts) gainsactionAppearance?: ActionAppearance.- Editor (
WorkflowEditor.tsx, thenodesWithValidationmemo around line 274): addsactionAppearancefromactionAppearanceByNodeId(semanticWorkflow.nodes, actionTypeCatalog). Nodes without an appearance keep their current object identity, as they do today. - Viewer (
WorkflowViewer.tsx): callsuseActionTypeCatalog(actionTypes)(the existing hook, so async providers, reloads and failures behave exactly as in the Editor) and addsactionAppearancein itsnodesmemo.
Because the appearance lives in node data, it updates live when the user changes a node's action type in the Properties panel, and when the host passes a new catalog.
Rendering
ActionNode.tsx:
- Renders
appearance.iconwhen present, elsePlayIcon. The icon sits in a fixed-size wrapper (.flow-node-action-icon, about 16px) so host icons of any size don't distort the node. - When
appearance.coloris present, sets an inline custom property on the node root:style={{ '--flow-node-action-accent': color }}.
ActionNode.css:
border-color: var(--flow-node-action-accent, var(--flow-node-action-border, #06c))..flow-node-action-icon { color: var(--flow-node-action-accent, inherit) }.
Background, text, the selection ring, the validation badge, the unresolved marker, the parallel hint and
the Viewer's run-state styles are unchanged. Those styles (WorkflowViewer.css) use opacity, box-shadow
rings and a failed overlay rather than the border, so they still apply on top of an accented node.
Properties panel (small extra)
ActionTypeSelect shows each descriptor's icon before its label in the dropdown options. No color is
applied there.
Error handling
- Invalid CSS color strings are passed through; the browser ignores them and the default border shows.
- A descriptor
iconthat throws while rendering is the host's bug and is not caught specially.
Testing
actionAppearance.test.ts: match, no match, descriptor without icon or color, blank color, non-stringactionType, catalognone/loading, and node-id map over a workflow with mixed node types.npx tsc --noEmitandnpm run lint.- Dev app: give a few demo catalog entries icons and colors. Check the Editor and Viewer side by side in light and dark themes, including changing an action type and an async catalog loading.
Documentation
docs/user-guide/visual-editor.md: describeiconandcolorin the SPI section.docs/user-guide/workflow-viewer.md: document the newactionTypesprop and the shared-catalog example above.