Flow canvas
The React Flow canvas: dotted grid, node cards, smooth-step edges and run states.
Draft, not run yet
import { Button } from "@oration/canon/components/button";import { FlowCanvas, FlowEdge, type FlowEdgeData, FlowNodeCard, type FlowRunState,} from "@oration/canon/components/flow";import { toast } from "@oration/canon/components/toast";import type { Edge, EdgeTypes, Node, NodeProps, NodeTypes } from "@xyflow/react";import { CalendarClockIcon, FileSearchIcon, InboxIcon, ListFilterIcon, PlayIcon, RotateCcwIcon,} from "lucide-react";import * as React from "react";export function Workflow() { type StepData = { icon: React.ReactNode; category: string; title: string; description: string; run: FlowRunState; target: boolean; }; type StepNode = Node<StepData, "step">; const nodeTypes = React.useMemo<NodeTypes>( () => ({ step: ({ data, selected }: NodeProps<StepNode>) => ( <FlowNodeCard icon={data.icon} category={data.category} title={data.title} description={data.description} run={data.run} target={data.target} selected={selected} /> ), }), [], ); const edgeTypes = React.useMemo<EdgeTypes>( () => ({ default: FlowEdge }), [], ); const steps = [ { id: "invoice", icon: <InboxIcon />, category: "Trigger", title: "Invoice received", description: "From ap@cedarline.com", }, { id: "match", icon: <FileSearchIcon />, category: "Matching", title: "Match PO", description: "Three-way match against the PO", }, { id: "approve", icon: <ListFilterIcon />, category: "Filter", title: "Approve under $5k", description: "Amount is less than $5,000", }, { id: "schedule", icon: <CalendarClockIcon />, category: "Payments", title: "Schedule payment", description: "Adds it to Friday's payment run", }, ]; const total = steps.length; // -1 is idle; 0 to 3 is the running step; 4 is finished. const [step, setStep] = React.useState(-1); const [selected, setSelected] = React.useState<string | null>("match"); const running = step >= 0 && step < total; React.useEffect(() => { if (step < 0 || step >= total) return; const id = window.setTimeout(() => { setStep(step + 1); if (step + 1 === total) toast.add({ type: "success", title: "Test run passed", description: "Northwind Freight's invoice INV-20931 would be paid Friday, Oct 2.", }); }, 1100); return () => window.clearTimeout(id); }, [step, total]); const runFor = (index: number): FlowRunState => { if (step < 0) return "idle"; if (index < step) return "success"; return index === step ? "running" : "queued"; }; const nodes: StepNode[] = steps.map((s, index) => ({ id: s.id, type: "step", position: { x: 0, y: index * 120 }, selected: s.id === selected, ariaLabel: `${s.title}, ${runFor(index) === "success" ? "passed" : runFor(index)}`, data: { icon: s.icon, category: s.category, title: s.title, description: s.description, run: runFor(index), target: index > 0, }, })); const edges: Edge<FlowEdgeData>[] = steps.flatMap((source, index) => { const target = steps[index + 1]; if (!target) return []; return [ { id: `${source.id}-${target.id}`, source: source.id, target: target.id, sourceHandle: "out", type: "default", ariaLabel: `From ${source.title} to ${target.title}`, data: { active: step === index + 1, onInsert: running ? undefined : () => toast.add({ title: "Add a step", description: `Between ${source.title} and ${target.title}.`, }), }, }, ]; }); return ( <div className="relative h-[420px] w-full overflow-hidden rounded-xl bg-surface shadow-border"> <FlowCanvas<StepNode, Edge<FlowEdgeData>> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} minimap={false} fitView fitViewOptions={{ padding: 0.12, maxZoom: 1 }} nodesDraggable={false} nodesConnectable={false} zoomOnScroll={false} preventScrolling={false} onNodeClick={(_, node) => setSelected(node.id)} onPaneClick={() => setSelected(null)} aria-label="Invoice approval workflow" /> <div className="absolute top-3 right-3 left-3 z-10 flex items-center justify-between gap-3"> <p aria-live="polite" className="rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground tabular-nums" > {step < 0 ? "Draft, not run yet" : running ? `Running step ${step + 1} of ${total}` : "Test run passed"} </p> <Button type="button" size="sm" disabled={running} onClick={() => setStep(0)} > {step >= total ? ( <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> ) : ( <PlayIcon data-icon="inline-start" aria-hidden="true" /> )} {step >= total ? "Run again" : "Run test"} </Button> </div> </div> );}Usage#
Flow canvas is the surface for building and test-running automations: a React Flow canvas on the dotted grid, 288px node cards, smooth-step edges and run states that light up step by step. FlowCanvas wraps React Flow with Canon's grid, controls and minimap, FlowNodeCard is what your custom node types render, and FlowEdge is the edge type. The usual mistake is treating run state as a color: every ring travels with a labelled badge, and the marching indigo dash belongs only to the edge the run is on right now.
When to use
- For a multi-step automation people build and read as a graph: Invoice received, Match PO, Approve under $5k, Schedule payment.
- To show a test run live, node by node, with the active edge marching.
- When the logic branches: a branch node with Yes and No outputs and labelled edges.
- For voice-agent flows and procedures, where each node is a stage of the conversation.
- To show one node card outside the canvas, such as in a palette preview or a run summary, with
target={false}andsourceHandles={[]}.
When not to use
- For a fixed sequence someone completes once, like supplier onboarding. Use Stepper
- For a record of what already happened, such as a run log or an audit trail. Use Timeline
- For an ordered list people reorder by dragging, with no branches. Use Sortable list
- For an agent's progress through a plan, shown as checkable tasks. Use Task list
- For where the milliseconds went in one call. Use Latency timeline
- For the whole editor page, with palette, inspector and test run. Use Builder canvas
Workflow Canvas (signature)
The Label-Beside-Color Rule
One marching edge
active only on the edge the run is traversing. The dashed indigo march means work in progress now; idle edges stay Firm Hairline, and selection turns an edge indigo without the dash.The Option Hue Rule
iconClassName only for identity, such as an agent's own color, never to color-code node kinds like triggers or integrations.Anatomy#
Runs before every payment
- Card. 288px wide (
w-72),rounded-xlon the card surface withshadow-border. Rings for selection and run state are drawn on it. - Target handle. A 10px circle with a 1.5px Firm Hairline stroke at the top center.
target={false}removes it for triggers. - Icon tile. 32px, 10px corners, Well Gray with a 16px icon. Decorative;
iconClassNameoverrides the tint. - Category. 12px muted text naming the kind of step: Trigger, Filter, Payments.
- Run badge.
FlowRunBadge, pushed to the end of the category row. Nothing renders while idle. - Title. 13px at weight 500, one line, truncates.
- Description. 12px muted text, clamped to two lines.
- Footer. Optional
childrenbelow a hairline, for conditions or an error. Padded 12px by 8px. - Source handles. One per entry in
sourceHandles, spaced evenly along the bottom edge. Eachidis the edge'ssourceHandle.
Examples#
Branches and edge labels
A branch node passes two sourceHandles, and each edge picks one with sourceHandle and names it with data.label. Click a node to select it.
import { FlowCanvas, FlowEdge, type FlowEdgeData, FlowNodeCard } from "@oration/canon/components/flow";import type { Edge, EdgeTypes, Node, NodeProps, NodeTypes } from "@xyflow/react";import { CalendarClockIcon, FileSearchIcon, SplitIcon, UserCheckIcon } from "lucide-react";import * as React from "react";export function Branches() { type StepData = { icon: React.ReactNode; category: string; title: string; description?: string; condition?: string; handles: { id: string }[]; target: boolean; }; type StepNode = Node<StepData, "step">; const nodeTypes = React.useMemo<NodeTypes>( () => ({ step: ({ data, selected }: NodeProps<StepNode>) => ( <FlowNodeCard icon={data.icon} category={data.category} title={data.title} description={data.description} sourceHandles={data.handles} target={data.target} selected={selected} > {data.condition ? ( <span className="block truncate rounded-md bg-muted px-1.5 py-0.5 text-xs text-foreground"> {data.condition} </span> ) : null} </FlowNodeCard> ), }), [], ); const edgeTypes = React.useMemo<EdgeTypes>( () => ({ default: FlowEdge }), [], ); const [selected, setSelected] = React.useState<string | null>(null); const steps: StepNode[] = [ { id: "match", type: "step", position: { x: 0, y: 0 }, data: { icon: <FileSearchIcon />, category: "Matching", title: "Match PO", description: "Three-way match against the PO", handles: [{ id: "out" }], target: false, }, }, { id: "approve", type: "step", position: { x: 0, y: 124 }, data: { icon: <SplitIcon />, category: "Branch", title: "Approve under $5k", condition: "Amount is less than $5,000", handles: [{ id: "yes" }, { id: "no" }], target: true, }, }, { id: "schedule", type: "step", position: { x: -170, y: 272 }, data: { icon: <CalendarClockIcon />, category: "Payments", title: "Schedule payment", description: "Adds it to Friday's payment run", handles: [{ id: "out" }], target: true, }, }, { id: "review", type: "step", position: { x: 170, y: 272 }, data: { icon: <UserCheckIcon />, category: "Approval", title: "Request approval", description: "Asks Maya Okafor to approve it", handles: [{ id: "out" }], target: true, }, }, ]; const nodes = steps.map((node) => ({ ...node, selected: node.id === selected, })); const edges: Edge<FlowEdgeData>[] = [ { id: "match-approve", source: "match", target: "approve", sourceHandle: "out", type: "default", }, { id: "approve-schedule", source: "approve", target: "schedule", sourceHandle: "yes", type: "default", data: { label: "Yes" }, }, { id: "approve-review", source: "approve", target: "review", sourceHandle: "no", type: "default", data: { label: "No" }, }, ]; return ( <div className="relative h-[380px] w-full overflow-hidden rounded-xl bg-surface shadow-border"> <FlowCanvas<StepNode, Edge<FlowEdgeData>> nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} minimap={false} fitView fitViewOptions={{ padding: 0.12, maxZoom: 1 }} nodesDraggable={false} nodesConnectable={false} zoomOnScroll={false} preventScrolling={false} onNodeClick={(_, node) => setSelected(node.id)} onPaneClick={() => setSelected(null)} aria-label="Approval branch" /> </div> );}Node content
children become a footer below a hairline, for conditions or a problem that blocks the step. Tint the icon tile only for identity, like an agent's own color.
Adds it to Friday's payment run
Connect a payment account to run this step.
Confirms the remittance address before the run
import { FlowNodeCard } from "@oration/canon/components/flow";import { BotIcon, CalendarClockIcon, CircleAlertIcon, ListFilterIcon } from "lucide-react";export function NodeContent() { return ( <div className="flex w-full flex-wrap items-start justify-center gap-4"> <FlowNodeCard target={false} sourceHandles={[]} icon={<ListFilterIcon />} category="Filter" title="Approve under $5k" > <div className="flex flex-col gap-1"> <span className="text-xs text-muted-foreground"> All of these </span> <span className="truncate rounded-md bg-muted px-1.5 py-0.5 text-xs text-foreground"> Amount is less than $5,000 </span> <span className="truncate rounded-md bg-muted px-1.5 py-0.5 text-xs text-foreground"> Supplier W-9 is on file </span> </div> </FlowNodeCard> <FlowNodeCard target={false} sourceHandles={[]} icon={<CalendarClockIcon />} category="Payments" title="Schedule payment" description="Adds it to Friday's payment run" > <p className="flex items-start gap-1.5 text-xs text-destructive"> <CircleAlertIcon aria-hidden="true" className="mt-px size-3.5 shrink-0" /> <span className="line-clamp-2"> Connect a payment account to run this step. </span> </p> </FlowNodeCard> <FlowNodeCard target={false} sourceHandles={[]} icon={<BotIcon />} iconClassName="bg-(--tag-violet) text-(--tag-violet-fg)" category="Nora" title="Call Northwind Freight" description="Confirms the remittance address before the run" /> </div> );}Run badge on its own
FlowRunBadge pushes itself to the end of a flex row, so it drops into a run summary beside the canvas.
Test run
Monday, Sep 28 at 10:42 AM
- 1Invoice received0.2 sPassed
- 2Match PO1.4 sFailed
- 3Approve under $5kSkipped
- 4Schedule paymentSkipped
Match PO failed: PO 4471 has no line for the $180 freight surcharge.
import { Button } from "@oration/canon/components/button";import { FlowRunBadge, type FlowRunState } from "@oration/canon/components/flow";import { toast } from "@oration/canon/components/toast";import { RotateCcwIcon } from "lucide-react";export function RunSummary() { const steps: { title: string; run: FlowRunState; time: string }[] = [ { title: "Invoice received", run: "success", time: "0.2 s" }, { title: "Match PO", run: "error", time: "1.4 s" }, { title: "Approve under $5k", run: "skipped", time: "" }, { title: "Schedule payment", run: "skipped", time: "" }, ]; return ( <div className="w-full max-w-sm overflow-hidden rounded-xl bg-card shadow-border"> <div className="flex items-center justify-between gap-3 px-4 pt-3 pb-2"> <div className="min-w-0"> <p className="text-13 font-medium text-foreground"> Test run </p> <p className="text-xs text-muted-foreground"> Monday, Sep 28 at 10:42 AM </p> </div> <Button type="button" variant="outline" size="sm" onClick={() => toast.add({ title: "Test run started", description: "Using invoice INV-20931 from Northwind Freight.", }) } > <RotateCcwIcon data-icon="inline-start" aria-hidden="true" /> Run again </Button> </div> <ol className="flex flex-col divide-y divide-border border-t border-border"> {steps.map((s, index) => ( <li key={s.title} className="flex items-center gap-3 px-4 py-2.5" > <span className="w-4 text-xs text-muted-foreground tabular-nums"> {index + 1} </span> <span className="min-w-0 truncate text-13 text-foreground"> {s.title} </span> {s.time ? ( <span className="text-xs text-muted-foreground tabular-nums"> {s.time} </span> ) : null} <FlowRunBadge run={s.run} /> </li> ))} </ol> <p className="border-t border-border bg-muted/50 px-4 py-2.5 text-xs text-pretty text-muted-foreground"> Match PO failed: PO 4471 has no line for the $180 freight surcharge. </p> </div> );}States#
Three-way match against the PO
Three-way match against the PO
Three-way match against the PO
Three-way match against the PO
Three-way match against the PO
Three-way match against the PO
import { FlowNodeCard, type FlowRunState } from "@oration/canon/components/flow";import { FileSearchIcon } from "lucide-react";export function RunStates() { const states: { run: FlowRunState; label: string }[] = [ { run: "idle", label: "Idle" }, { run: "queued", label: "Queued" }, { run: "running", label: "Running" }, { run: "success", label: "Passed" }, { run: "error", label: "Failed" }, { run: "skipped", label: "Skipped" }, ]; return ( <div className="grid w-full grid-cols-1 justify-items-center gap-x-4 gap-y-5 sm:grid-cols-2"> {states.map((state) => ( <figure key={state.run} className="flex flex-col items-center gap-2" > <FlowNodeCard target={false} sourceHandles={[]} icon={<FileSearchIcon />} category="Matching" title="Match PO" description="Three-way match against the PO" run={state.run} /> <figcaption className="text-xs text-muted-foreground"> {state.label} <span className="font-mono">{` run="${state.run}"`}</span> </figcaption> </figure> ))} </div> );}| State | Treatment |
|---|---|
| Idle | No ring and no badge. The resting state while editing. |
| Queued | No ring; a muted Queued label. The run will reach this step. |
| Running | 2px Focus Indigo ring at 60% and an indigo spinner with Running. The spinner only turns when motion is allowed. |
| Passed | 1px Ledger Green ring at 50% and a check with Passed. The value is success. |
| Failed | 2px Signal Red ring at 60% and an x with Failed. The value is error. Put the reason in the footer. |
| Skipped | The whole card fades to 60% opacity with a muted Skipped label, for steps a branch didn't take. |
| Selected | 2px indigo ring from data-selected. It wins over the run ring's color, so a selected failed node relies on its badge. |
| Edge active | 1.75px indigo dashes (5 5) that march at 0.9s linear. Under reduced motion the dashes hold still and stay indigo. |
| Edge selected | Solid indigo at 1.5px. Stroke color changes over 200ms. |
| Insert button hover and focus | The plus darkens to ink and scales to 1.1 on hover, 0.95 while pressed. Keyboard focus adds a 3px ring at 40%. |
Behavior#
FlowCanvaspasses every React Flow prop through. Its defaults (hidden attribution, zoom from 0.4 to 1.6,fitViewOptionsof 0.25 padding and zoom 1 at most) are spread first, so your props override them.- The wrapper is
relative size-full. Give its parent an explicit height; React Flow draws nothing in a box with no height. - It always draws the dotted grid (18px gap, 1.2px dots in Firm Hairline) and zoom controls at the bottom left without the lock button.
minimapadds a pannable, zoomable minimap at the bottom right; the workflows editor turns it off. - Register the pieces once, outside render or memoized:
edgeTypes={{ default: FlowEdge }}and anodeTypesmap whose components renderFlowNodeCard. New objects on every render make React Flow warn and remount nodes. FlowEdgereadsdata.active,data.labelanddata.onInsert. The label chip and insert button sit at the path midpoint withnodrag nopan, so clicking them never pans the canvas. DroponInsertwhile a run is in progress.- Edges are smooth-steps with 10px bends. Stroke color eases over 200ms on the house curve,
cubic-bezier(0.23, 1, 0.32, 1); card rings and opacity ease over 200ms. - Pass
selectedfromNodePropsinto the card. Branches passsourceHandles={[{ id: "yes" }, { id: "no" }]}and edges pick one withsourceHandle. FlowNodeCardrenders React FlowHandles, which only work inside a node. For a static card outside the canvas, passtarget={false}andsourceHandles={[]}.- On a long page, set
preventScrolling={false}andzoomOnScroll={false}so the wheel scrolls the page instead of zooming the canvas.
Do and don't#
Three-way match against the PO
PO 4471 has no line for the $180 freight surcharge.
Three-way match against the PO
From ap@cedarline.com
Adds it to Friday's payment run
From ap@cedarline.com
Adds it to Friday's payment run
Amount is less than $5,000
Checks a condition
Content#
- Titles are short verb phrases in sentence case: Match PO, Schedule payment, Request approval from Maya Okafor.
- The category names the kind of step in one or two words: Trigger, Filter, Payments, Agent.
- Descriptions say what this instance does with real values: Amount is less than $5,000, not Checks a condition.
- Error footers state the problem and the fix: Connect a payment account to run this step.
- Edge labels are one word, usually Yes and No from a branch.
Accessibility#
- Give the canvas an
aria-label, such as Invoice approval workflow. React Flow makes nodes and edges focusable and gives them a role description. - Set
ariaLabelon each node and edge with the step and its run state, like Match PO, passed. The card itself has no name. - Run states are written words, so they don't depend on the ring color. The spinner and the marching dash sit behind
motion-safe. - The icon tile is
aria-hidden. Handles are pointer-only: offer another way to connect steps, such as the insert button or an inspector field. - Every insert button is named Add step here. On a canvas with several edges they all sound the same; see Known gaps.
| Keys | Action |
|---|---|
| Tab | Moves through nodes, then edges, then the insert buttons and zoom controls. |
| Enter | Selects the focused node or edge, or activates the focused insert button. |
| Arrow keys | Move the selected nodes when nodesDraggable is on. Hold Shift to move further. |
| Backspace | Deletes the selected nodes and edges. Pass deleteKeyCode={null} to turn this off, as the workflows editor does. |
Design tokens#
| Token | Used for |
|---|---|
--border-strong | Grid dots, edge stroke, handle stroke, minimap node stroke |
--card | Node card surface |
shadow-border | Hairline lift on cards, edge labels, insert buttons, controls and minimap |
--muted | Icon tile and minimap nodes |
--primary | Running ring at 60%, selected ring, active and selected edge stroke, Running badge |
--success | Passed ring at 50% and badge |
--destructive | Failed ring at 60% and badge |
--muted-foreground | Category, description, Queued and Skipped labels, edge label text |
--background | Handle fill, edge label chip, insert button, minimap mask at 60% |
--ring | Insert button focus ring at 40% |
animate-dash | 0.9s linear march on the active edge |
--radius-xl | Card corners; icon tile, controls and minimap use rounded-lg |
API reference#
FlowCanvas
React Flow with Canon's grid, controls and optional minimap. Generic over your node and edge types.
Other props spread onto React Flow ReactFlowProps<NodeType, EdgeType>, spread onto <ReactFlow>.
| Prop | Type | Default | Description |
|---|---|---|---|
minimap | boolean | true | Shows the minimap at the bottom right. |
nodes | NodeType[] | No default | Controlled nodes. Pair with onNodesChange, or use defaultNodes. |
edges | EdgeType[] | No default | Controlled edges. Give them type: "default" and register FlowEdge. |
nodeTypes | NodeTypes | No default | Your node components, each rendering FlowNodeCard. Define once. |
edgeTypes | EdgeTypes | No default | { default: FlowEdge }. Define once. |
minZoom | number | 0.4 | Smallest zoom. |
maxZoom | number | 1.6 | Largest zoom. |
fitViewOptions | FitViewOptions | { padding: 0.25, maxZoom: 1 } | Used by fitView and the fit button. Never zooms past 100% by default. |
proOptions | ProOptions | { hideAttribution: true } | Hides the React Flow attribution. |
className | string | No default | On the wrapping div, which is relative size-full. |
children | ReactNode | No default | Rendered inside React Flow after the grid, controls and minimap, e.g. a <Panel>. |
FlowNodeCard
The 288px node body. Render it from a custom node component.
Other props spread onto Doesn't spread props; renders <div data-slot="flow-node" data-run data-selected>.
| Prop | Type | Default | Description |
|---|---|---|---|
iconRequired | ReactNode | No default | A Lucide icon or avatar for the 32px tile. Sized to 16px. |
iconClassName | string | No default | Overrides the tile's Well Gray, for identity tints only. |
categoryRequired | string | No default | The kind of step, above the title. |
titleRequired | string | No default | The step's name. Truncates to one line. |
description | string | No default | What this step does. Clamps to two lines. |
selected | boolean | No default | Draws the 2px indigo ring. Pass selected from NodeProps. |
run | FlowRunState | "idle" | Run state: ring, opacity and badge. |
sourceHandles | { id: string; label?: string }[] | [{ id: "out" }] | Outputs along the bottom edge. [] for none. label is not drawn. |
target | boolean | true | Draws the input handle. false for triggers and static cards. |
children | ReactNode | No default | Footer content below a hairline: conditions, an error. |
className | string | No default | Merged onto the card, e.g. a drop-target ring. |
FlowEdge
Smooth-step edge with an optional label chip, insert button and active march. Register it as an edge type.
Other props spread onto React Flow EdgeProps<Edge<FlowEdgeData>>.
| Prop | Type | Default | Description |
|---|---|---|---|
data.active | boolean | No default | Dashed indigo, 1.75px, marching. Only for the edge a run is on. |
data.label | string | No default | A small chip at the midpoint, like Yes or No. |
data.onInsert | (edgeId: string) => void | No default | Shows the 20px plus at the midpoint. Called with the edge id. |
selected | boolean | No default | From React Flow. Turns the stroke indigo. |
markerEnd | string | No default | From React Flow, passed to the path. The product draws none. |
FlowRunBadge
The icon and label for a run state. Renders nothing for idle. Pushes itself right with ml-auto.
| Prop | Type | Default | Description |
|---|---|---|---|
runRequired | FlowRunState | No default | Which state to show. |
FlowRunState
Type export.
| Prop | Type | Default | Description |
|---|---|---|---|
FlowRunState | "idle" | "queued" | "running" | "success" | "error" | "skipped" | No default | success reads Passed and error reads Failed. |
FlowEdgeData
Type export for Edge<FlowEdgeData>.
| Prop | Type | Default | Description |
|---|---|---|---|
FlowEdgeData | { label?: string; active?: boolean; onInsert?: (edgeId: string) => void } | No default | The edge data that FlowEdge reads. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The insert button is 20px, as DESIGN.md specifies, which is under the 24px minimum hit area. Every one is named Add step here, with no hint of which two steps it sits between.
sourceHandles[].label is in the type but never drawn. Branch labels come from the edge's data.label instead.
Run state values are success and error, while the badge and DESIGN.md say Passed and Failed.
The selected ring overrides the run ring's color, so a selected node that failed shows an indigo ring and only its badge says Failed.
FlowNodeCard doesn't spread props, so it takes no aria-*, data-* or ref. Names have to go on the React Flow node as ariaLabel.
The workflows editor tints trigger tiles with --tag-indigo through iconClassName. That color-codes a node kind; DESIGN.md specifies Well Gray tiles.
FlowRunBadge isn't used on its own anywhere in the app; the run log draws its own status icons.