Action Type Appearance Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Action nodes show a host-chosen icon and accent color, chosen per action type, identically in
WorkflowEditor and WorkflowViewer.
Architecture: ActionTypeDescriptor gains optional icon and color. A pure lookup maps action nodes
to an ActionAppearance from the loaded catalog. Both components attach it to React Flow node data
(FlowNodeData.actionAppearance), and ActionNode renders it. The Viewer gains an actionTypes prop loaded
with the existing useActionTypeCatalog hook.
Tech Stack: TypeScript, React 19, @xyflow/react v12, PatternFly icons, vitest.
Spec: docs/superpowers/specs/2026-10-18-action-type-appearance-design.md
Global Constraints
- All commands run from
ui/. Tests:npx vitest run <path>; typecheck:npx tsc --noEmit; lint:npm run lint. - No
@testing-library/react/ jsdom: only pure logic gets unit tests. - Appearance is never written into the workflow document.
- Only Action nodes change. Other node types and
WorkflowDiffViewerare out of scope. - Color is one accent: it sets border and icon color only; background and text stay theme-controlled.
- Nodes without an appearance keep today's look and (in the Editor) their object identity.
- Files in
ui/src/utilsandui/src/hooksuse 4-space indentation; components use 2-space. Match the file you edit. - Public functions get JSDoc.
Task 1: Descriptor fields and pure appearance lookup
Files:
- Modify: ui/src/types/spi.ts:13-19
- Create: ui/src/utils/actionAppearance.ts
- Test: ui/src/utils/actionAppearance.test.ts
- Modify: ui/src/index.ts:8 (export the new type)
Interfaces:
- Consumes: ActionTypeCatalog from ui/src/hooks/actionTypeCatalogState.ts
({ actionTypes: ActionTypeDescriptor[]; status: 'none' | 'loading' | 'ready' }), WorkflowNode from
ui/src/types/workflow.ts.
- Produces:
- interface ActionAppearance { icon?: ReactNode; color?: string }
- actionAppearanceFor(actionType: unknown, catalog: ActionTypeCatalog): ActionAppearance | undefined
- actionAppearanceByNodeId(nodes: WorkflowNode[], catalog: ActionTypeCatalog): Map<string, ActionAppearance>
- [ ] Step 1: Add descriptor fields
In ui/src/types/spi.ts, extend ActionTypeDescriptor (ReactNode is already imported):
export interface ActionTypeDescriptor {
value: string;
label: string;
description?: string;
inputs?: ActionTypeField[];
outputs?: ActionTypeField[];
/** Icon shown on Action nodes of this type (and in the type picker), 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;
}
- [ ] Step 2: Write the failing test
ui/src/utils/actionAppearance.test.ts:
import { describe, expect, it } from 'vitest';
import { createElement } from 'react';
import type { ActionTypeCatalog } from '../hooks/actionTypeCatalogState.ts';
import type { ActionTypeDescriptor } from '../types/spi.ts';
import type { WorkflowNode } from '../types/workflow.ts';
import { actionAppearanceByNodeId, actionAppearanceFor } from './actionAppearance.ts';
const icon = createElement('svg', { 'data-testid': 'mail' });
const email: ActionTypeDescriptor = { value: 'email', label: 'Email', icon, color: '#c9190b' };
const slack: ActionTypeDescriptor = { value: 'slack', label: 'Slack', color: 'var(--brand)' };
const plain: ActionTypeDescriptor = { value: 'plain', label: 'Plain' };
const blank: ActionTypeDescriptor = { value: 'blank', label: 'Blank', color: ' ' };
const ready: ActionTypeCatalog = { actionTypes: [email, slack, plain, blank], status: 'ready' };
describe('actionAppearanceFor', () => {
it('returns icon and color for a styled match', () => {
expect(actionAppearanceFor('email', ready)).toEqual({ icon, color: '#c9190b' });
});
it('returns color only when the descriptor has no icon', () => {
expect(actionAppearanceFor('slack', ready)).toEqual({ color: 'var(--brand)' });
});
it('returns undefined for an unknown type', () => {
expect(actionAppearanceFor('nope', ready)).toBeUndefined();
});
it('returns undefined for a descriptor with neither icon nor color', () => {
expect(actionAppearanceFor('plain', ready)).toBeUndefined();
});
it('ignores a blank color', () => {
expect(actionAppearanceFor('blank', ready)).toBeUndefined();
});
it('ignores a non-string color', () => {
const odd = { value: 'odd', label: 'Odd', color: 42 } as unknown as ActionTypeDescriptor;
expect(actionAppearanceFor('odd', { actionTypes: [odd], status: 'ready' })).toBeUndefined();
});
it('returns undefined for non-string action types', () => {
expect(actionAppearanceFor(7, ready)).toBeUndefined();
expect(actionAppearanceFor(null, ready)).toBeUndefined();
expect(actionAppearanceFor(undefined, ready)).toBeUndefined();
});
it('uses whatever descriptors are present regardless of status', () => {
expect(actionAppearanceFor('email', { actionTypes: [], status: 'loading' })).toBeUndefined();
expect(actionAppearanceFor('email', { actionTypes: [], status: 'none' })).toBeUndefined();
expect(actionAppearanceFor('email', { actionTypes: [email], status: 'loading' }))
.toEqual({ icon, color: '#c9190b' });
});
});
describe('actionAppearanceByNodeId', () => {
it('maps only styled action nodes', () => {
const nodes = [
{ id: 's', type: 'start', name: 'Start', config: {} },
{ id: 'a1', type: 'action', name: 'Mail', config: { actionType: 'email' } },
{ id: 'a2', type: 'action', name: 'Plain', config: { actionType: 'plain' } },
{ id: 'a3', type: 'action', name: 'None', config: {} },
{ id: 'a4', type: 'action', name: 'Slack', config: { actionType: 'slack' } },
{ id: 'e', type: 'end', name: 'End', config: {} },
] as WorkflowNode[];
const result = actionAppearanceByNodeId(nodes, ready);
expect([...result.keys()]).toEqual(['a1', 'a4']);
expect(result.get('a4')).toEqual({ color: 'var(--brand)' });
});
it('is empty when the catalog is empty', () => {
const nodes = [{ id: 'a', type: 'action', name: 'A', config: { actionType: 'email' } }] as WorkflowNode[];
expect(actionAppearanceByNodeId(nodes, { actionTypes: [], status: 'none' }).size).toBe(0);
});
});
Note: during a catalog reload the status is ready with the previous list kept, and on first async load the
list is empty, so the lookup intentionally uses whatever descriptors are present and ignores status.
- [ ] Step 3: Run test to verify it fails
Run: npx vitest run src/utils/actionAppearance.test.ts
Expected: FAIL — cannot resolve ./actionAppearance.ts.
- [ ] Step 4: Implement
ui/src/utils/actionAppearance.ts:
import type { ReactNode } from 'react';
import type { ActionTypeCatalog } from '../hooks/actionTypeCatalogState.ts';
import type { WorkflowNode } from '../types/workflow.ts';
/** Host-chosen look for an Action node, taken from its Action Type descriptor. */
export interface ActionAppearance {
/** Replaces the default play icon. */
icon?: ReactNode;
/** Accent color (any CSS color) for the node border and icon. */
color?: string;
}
/**
* Finds the appearance for one Action Type value.
*
* @param actionType the node's `config.actionType`, of any type
* @param catalog the loaded Action Type catalog
* @returns the descriptor's icon and/or color, or undefined when there is no match or nothing to style
*/
export function actionAppearanceFor(actionType: unknown, catalog: ActionTypeCatalog): ActionAppearance | undefined {
if (typeof actionType !== 'string') return undefined;
const descriptor = catalog.actionTypes.find(candidate => candidate.value === actionType);
if (!descriptor) return undefined;
const icon = descriptor.icon ?? undefined;
const color = typeof descriptor.color === 'string' && descriptor.color.trim() !== ''
? descriptor.color : undefined;
if (icon === undefined && color === undefined) return undefined;
const appearance: ActionAppearance = {};
if (icon !== undefined) appearance.icon = icon;
if (color !== undefined) appearance.color = color;
return appearance;
}
/**
* Computes appearances for every styled Action node.
*
* @param nodes the workflow nodes
* @param catalog the loaded Action Type catalog
* @returns node id to appearance, containing only Action nodes whose descriptor has an icon or color
*/
export function actionAppearanceByNodeId(nodes: WorkflowNode[], catalog: ActionTypeCatalog): Map<string, ActionAppearance> {
const result = new Map<string, ActionAppearance>();
if (catalog.actionTypes.length === 0) return result;
for (const node of nodes) {
if (node.type !== 'action') continue;
const appearance = actionAppearanceFor(node.config.actionType, catalog);
if (appearance) result.set(node.id, appearance);
}
return result;
}
- [ ] Step 5: Export the type
In ui/src/index.ts, add after line 8:
- [ ] Step 6: Verify
Run: npx vitest run src/utils/actionAppearance.test.ts src/dev/libraryExports.test.ts && npx tsc --noEmit
Expected: PASS, no type errors. If libraryExports.test.ts snapshots the export list, update it to include
the new type (type-only exports do not appear at runtime, so it most likely needs no change).
- [ ] Step 7: Commit
git add ui/src/types/spi.ts ui/src/utils/actionAppearance.ts ui/src/utils/actionAppearance.test.ts ui/src/index.ts
git commit -m "Add icon and color to ActionTypeDescriptor with appearance lookup"
Task 2: Render the appearance on ActionNode and wire it into Editor and Viewer
Files:
- Modify: ui/src/utils/conversion.ts:6-17
- Modify: ui/src/components/nodes/ActionNode.tsx
- Modify: ui/src/components/nodes/ActionNode.css
- Modify: ui/src/components/WorkflowEditor.tsx:271-291
- Modify: ui/src/components/WorkflowViewer.tsx:40-55, 106-130
Interfaces:
- Consumes: ActionAppearance, actionAppearanceByNodeId (Task 1); useActionTypeCatalog(provider:
ActionTypeProvider | undefined): ActionTypeCatalog from ui/src/hooks/useActionTypeCatalog.ts.
- Produces: FlowNodeData.actionAppearance?: ActionAppearance; WorkflowViewerProps.actionTypes?:
ActionTypeProvider.
- [ ] Step 1: Extend
FlowNodeData
In ui/src/utils/conversion.ts, add the import and field (after unresolvedActionType):
/** Host-chosen icon/color for an Action node, from its Action Type descriptor. */
actionAppearance?: ActionAppearance;
- [ ] Step 2: Render in
ActionNode.tsx
Replace the file with:
import { type CSSProperties } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { PlayIcon } from '@patternfly/react-icons';
import { type FlowNodeData } from '../../utils/conversion.ts';
import './ActionNode.css';
export function ActionNode({ data, selected }: NodeProps) {
const nodeData = data as FlowNodeData;
const appearance = nodeData.actionAppearance;
const style = appearance?.color
? ({ '--flow-node-action-accent': appearance.color } as CSSProperties)
: undefined;
return (
<div className={`flow-node-action ${selected ? 'selected' : ''}`} style={style}>
<Handle type="target" position={Position.Left} />
<span className="flow-node-action-icon" aria-hidden="true">{appearance?.icon ?? <PlayIcon />}</span>
<span>{nodeData.name}</span>
<Handle type="source" position={Position.Right} />
</div>
);
}
- [ ] Step 3: CSS
In ui/src/components/nodes/ActionNode.css, replace the border: line and append the icon rule:
.flow-node-action-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 16px;
height: 16px;
color: var(--flow-node-action-accent, inherit);
}
.flow-node-action-icon > svg,
.flow-node-action-icon > img { width: 100%; height: 100%; }
- [ ] Step 4: Editor wiring
In ui/src/components/WorkflowEditor.tsx, import
import { actionAppearanceByNodeId } from '../utils/actionAppearance.ts'; and replace lines 271-291 with:
const unresolvedIds = useMemo(() => unresolvedActionTypeIds(semanticWorkflow, actionTypeCatalog),
[semanticWorkflow, actionTypeCatalog]);
const appearances = useMemo(() => actionAppearanceByNodeId(semanticWorkflow.nodes, actionTypeCatalog),
[semanticWorkflow, actionTypeCatalog]);
const nodesWithValidation = useMemo(() => {
if (!validationProblems?.length && !parallelAnalysis && !unresolvedIds.size && !appearances.size) return nodes;
return nodes.map(node => {
const problems = validationProblems.filter(p => p.nodeId === node.id);
const role = parallelRole(node.id, parallelAnalysis);
const unresolved = unresolvedIds.has(node.id)
? String((node.data.config as { actionType?: unknown }).actionType) : undefined;
const appearance = appearances.get(node.id);
return (problems.length || role || unresolved || appearance) ? {
...node,
data: {
...node.data,
validationProblems: problems.length ? problems : undefined,
parallelRole: role,
unresolvedActionType: unresolved,
actionAppearance: appearance,
},
} : node;
});
}, [nodes, validationProblems, parallelAnalysis, unresolvedIds, appearances]);
Confirm semanticWorkflow updates when a node's actionType changes in the Properties panel (it is the
semantic revision, so it does); this makes the appearance follow type edits.
- [ ] Step 5: Viewer wiring
In ui/src/components/WorkflowViewer.tsx:
Imports:
import { type ActionTypeProvider } from '../types/spi.ts';
import { useActionTypeCatalog } from '../hooks/useActionTypeCatalog.ts';
import { actionAppearanceByNodeId } from '../utils/actionAppearance.ts';
Add to WorkflowViewerProps:
/**
* The Action Type catalog, used for Action node icons and colors. Pass the same value as the editor's
* `spi.actionTypes` so both components render action types identically.
*/
actionTypes?: ActionTypeProvider;
Destructure it: function WorkflowViewerInner({ workflow, instance, theme = 'light', nodeContextMenuItems, actionTypes }: WorkflowViewerProps).
Before the nodes memo:
const actionTypeCatalog = useActionTypeCatalog(actionTypes);
const appearances = useMemo(() => actionAppearanceByNodeId(laidOutNodes, actionTypeCatalog),
[laidOutNodes, actionTypeCatalog]);
In the nodes memo change the data line and dependency list:
data: { ...node.data, isCurrent: isCurrent && !isTerminal, actionAppearance: appearances.get(node.id) },
}, [laidOutNodes, activeIds, instance.status, isTerminal, visitedNodeIds, selectedNodeId, appearances]);
- [ ] Step 6: Verify
Run: npx tsc --noEmit && npm run lint && npx vitest run
Expected: no errors; all tests pass.
- [ ] Step 7: Commit
git add ui/src/utils/conversion.ts ui/src/components/nodes/ActionNode.tsx ui/src/components/nodes/ActionNode.css ui/src/components/WorkflowEditor.tsx ui/src/components/WorkflowViewer.tsx
git commit -m "Render action type icon and color on Action nodes in editor and viewer"
Task 3: Icons in the Action Type picker
Files:
- Modify: ui/src/components/panels/ActionTypeSelect.tsx:72-76
Interfaces:
- Consumes: ActionTypeDescriptor.icon (Task 1).
- [ ] Step 1: Pass the icon to options
PatternFly SelectOption supports an icon prop. Change the option render to:
{filteredOptions.map(action => (
<SelectOption key={action.value} value={action.value} description={action.description}
icon={action.icon}>
{action.label}
</SelectOption>
))}
- [ ] Step 2: Verify
Run: npx tsc --noEmit && npm run lint
Expected: no errors.
- [ ] Step 3: Commit
git add ui/src/components/panels/ActionTypeSelect.tsx
git commit -m "Show action type icons in the Action Type picker"
Task 4: Dev app demo, docs, and visual check
Files:
- Modify: ui/src/dev/demoCatalog.ts
- Modify: ui/src/dev/App.tsx:313-318
- Modify: docs/user-guide/visual-editor.md:382-388
- Modify: docs/user-guide/workflow-viewer.md:29 and a new section before ## Styling
- [ ] Step 1: Style demo catalog entries
demoCatalog.ts is a .ts file, so use createElement rather than JSX. Add imports:
import { createElement } from 'react';
import { EnvelopeIcon, GlobeIcon, BugIcon, TicketAltIcon } from '@patternfly/react-icons';
Add to the descriptors (leave analyze-cve and close-tracker unstyled to show the default):
send-email:icon: createElement(EnvelopeIcon), color: '#c9190b',http-request:icon: createElement(GlobeIcon),lookup-cve:icon: createElement(BugIcon), color: '#8476d1',create-jira-ticket:icon: createElement(TicketAltIcon), color: '#f0ab00',
If an icon name does not exist in @patternfly/react-icons (tsc will say so), pick another that does.
- [ ] Step 2: Pass the catalog to the demo Viewer
In ui/src/dev/App.tsx, add actionTypes={demoCatalog} to the <WorkflowViewer ... /> element.
- [ ] Step 3: Docs — editor
In docs/user-guide/visual-editor.md, add to the ActionTypeDescriptor block:
icon?: ReactNode; // shown on Action nodes of this type and in the dropdown
color?: string; // accent color (any CSS color) for the node border and icon
and after the code block that ends the descriptor section add (wrap at 110 chars):
##### Action node icons and colors
Set `icon` and/or `color` on a descriptor to make Action nodes of that type easy to tell apart. `icon`
replaces the default play icon (it is sized to 16px). `color` is a single accent applied to the node border
and icon; the background and text keep their theme colors so nodes stay readable in light and dark mode.
Nodes whose type has no descriptor, or whose catalog is still loading, use the default look. Pass the same
catalog to `WorkflowViewer`'s `actionTypes` prop so running workflows look the same.
- [ ] Step 4: Docs — viewer
In docs/user-guide/workflow-viewer.md, add a props table row after nodeContextMenuItems:
| `actionTypes` | `ActionTypeProvider` | No | Action Type catalog used for Action node icons and colors. Pass the same value as the editor's `spi.actionTypes`. See [Action Type Icons and Colors](#action-type-icons-and-colors) |
And a section before ## Styling:
### Action Type Icons and Colors
When `actionTypes` is set, Action nodes use the `icon` and `color` of their Action Type descriptor, exactly
as in the editor. Share one catalog between the two components:
```tsx
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} ... />
```
A function provider is loaded the same way as in the editor; nodes use the default look until it resolves.
- [ ] Step 5: Full verification
Run: npx tsc --noEmit && npm run lint && npx vitest run && npm run build
Expected: all succeed.
- [ ] Step 6: Visual check
Run npm run dev and open http://localhost:5173/. In the Editor, with a scenario containing
send-email/lookup-cve actions:
- styled nodes show the host icon and accent border; unstyled ones look as before;
- changing a node's action type in the Properties panel updates the icon/color immediately;
- the action-type dropdown shows icons;
- selection ring, validation badge and unresolved "?" marker still appear.
In the Viewer: the same nodes show the same icons/colors, and visited/current/failed highlighting still shows. Repeat both in the dark theme.
- [ ] Step 7: Commit