Workflow builder
Workflow screens. A canvas with draggable steps and live connections, a linear builder, a step inspector with data from earlier steps and a test run that lights up step by step.
One of 3 components in Automation and builders
Variants
Details
What Workflow builder brings along, read from the registry and the source.
- Category
- Automation and builders
- Area
- App
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/workflow-builder.tsx
- npm packages
- None
- Languages
- English and German, 51 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The WorkflowBuilderProps interface from components/ui/workflow-builder.tsx, with the comments from the code.
variantoptionalWorkflowBuilderVariantcanvasstepsinspectorrun-trace
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringnodesoptionalWorkflowNode[]selectedoptionalstringinspector: the node on screen and its fields
fieldsoptionalWorkflowField[]variablesoptionalWorkflowVariable[]traceoptionalTraceStep[]run-trace: the steps of one run
libraryoptional{ kind: NodeKind; title: string; icon: IconName }[]steps: kinds of steps that can be added, with a title and an icon
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on save, publish and test. Without it the block simulates the request.
Built-in texts
Workflow builder writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 51 texts
| Key | English | German |
|---|---|---|
| trigger | English Trigger | German Auslöser |
| action | English Action | German Aktion |
| condition | English Condition | German Bedingung |
| delay | English Wait | German Warten |
| yes | English Yes | German Ja |
| no | English No | German Nein |
| zoomIn | English Zoom in | German Vergrößern |
| zoomOut | English Zoom out | German Verkleinern |
| fit | English Fit to screen | German Einpassen |
| canvas | English Workflow canvas. Pick a step, then move it with the arrow keys. | German Workflow-Fläche. Wähle einen Schritt und verschiebe ihn mit den Pfeiltasten. |
| moveHint | English Drag a step or move it with the arrow keys. | German Schritt ziehen oder mit den Pfeiltasten verschieben. |
| selected | English Selected step | German Gewählter Schritt |
| comesAfter | English Comes after | German Kommt nach |
| leadsTo | English Leads to | German Führt zu |
| library | English Add a step | German Schritt hinzufügen |
| libraryText | English Click to add it at the end. | German Klicken, um ihn am Ende anzufügen. |
| publish | English Publish | German Veröffentlichen |
| publishing | English Publishing… | German Wird veröffentlicht… |
| published | English Published. The workflow runs from now on. | German Veröffentlicht. Der Workflow läuft ab jetzt. |
| draft | English Draft | German Entwurf |
| live | English Live | German Live |
| steps | English Steps | German Schritte |
| addStep | English Add a step here | German Hier einen Schritt einfügen |
| addAfter | English Add a step after {name} | German Schritt nach {name} einfügen |
| remove | English Remove {name} | German {name} entfernen |
| close | English Close | German Schließen |
| pick | English Pick a step | German Schritt wählen |
| end | English End | German Ende |
| setup | English Setup | German Einrichtung |
| data | English Data | German Daten |
| test | English Test | German Test |
| insert | English Insert {name} | German {name} einfügen |
| variables | English Data from earlier steps | German Daten aus früheren Schritten |
| variablesHint | English Click to insert at the cursor. | German Klicken, um sie an der Schreibmarke einzufügen. |
| runTest | English Run this step | German Schritt ausführen |
| testing | English Running… | German Läuft… |
| testResult | English Result | German Ergebnis |
| testOk | English The step ran in {ms} ms. | German Der Schritt lief in {ms} ms. |
| save | English Save step | German Schritt speichern |
| saved | English Saved | German Gespeichert |
| run | English Run test | German Test starten |
| running | English Running… | German Läuft… |
| replay | English Run again | German Erneut ausführen |
| success | English Succeeded | German Erfolgreich |
| failed | English Failed | German Fehlgeschlagen |
| skipped | English Skipped | German Übersprungen |
| waiting | English Waiting | German Wartet |
| total | English {n} steps in {ms} ms | German {n} Schritte in {ms} ms |
| output | English Output | German Ausgabe |
| path | English Path taken | German Genommener Weg |
| noRun | English Run the workflow to see each step light up. | German Starte den Workflow, um jeden Schritt aufleuchten zu sehen. |
Accessibility
Read from the source of the component.
- Workflow builder handles the keys Escape, Enter, End and Space itself.
- Sets aria-controls, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles application, button, menu, menuitem, status, tab, tablist and tabpanel.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,098 lines
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, round, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
import { useScrollFade } from '@/components/ui/motion';
export type WorkflowBuilderVariant = 'canvas' | 'steps' | 'inspector' | 'run-trace';
export type NodeKind = 'trigger' | 'action' | 'condition' | 'delay';
export interface WorkflowNode {
id: string;
kind: NodeKind;
title: string;
/** Small line under the title, e.g. the app or the setting */
detail?: string;
icon?: IconName;
/** Position on the canvas in pixels */
x: number;
y: number;
/** Following nodes. A condition has two: yes first, then no. */
next?: string[];
}
export interface WorkflowField {
id: string;
label: string;
value: string;
hint?: string;
multiline?: boolean;
}
export interface WorkflowVariable {
/** e.g. deal.value */
key: string;
label: string;
/** Example value from the last run */
sample: string;
}
export interface TraceStep {
/** Node id */
node: string;
status: 'success' | 'failed' | 'skipped';
ms: number;
/** Short output, e.g. Created task #812 */
output?: string;
}
export interface WorkflowBuilderProps {
variant?: WorkflowBuilderVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
nodes?: WorkflowNode[];
/** inspector: the node on screen and its fields */
selected?: string;
fields?: WorkflowField[];
variables?: WorkflowVariable[];
/** run-trace: the steps of one run */
trace?: TraceStep[];
/** steps: kinds of steps that can be added, with a title and an icon */
library?: { kind: NodeKind; title: string; icon: IconName }[];
/** Called on save, publish and test. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
trigger: 'Trigger',
action: 'Action',
condition: 'Condition',
delay: 'Wait',
yes: 'Yes',
no: 'No',
zoomIn: 'Zoom in',
zoomOut: 'Zoom out',
fit: 'Fit to screen',
canvas: 'Workflow canvas. Pick a step, then move it with the arrow keys.',
moveHint: 'Drag a step or move it with the arrow keys.',
selected: 'Selected step',
comesAfter: 'Comes after',
leadsTo: 'Leads to',
library: 'Add a step',
libraryText: 'Click to add it at the end.',
publish: 'Publish',
publishing: 'Publishing…',
published: 'Published. The workflow runs from now on.',
draft: 'Draft',
live: 'Live',
steps: 'Steps',
addStep: 'Add a step here',
addAfter: 'Add a step after {name}',
remove: 'Remove {name}',
close: 'Close',
pick: 'Pick a step',
end: 'End',
setup: 'Setup',
data: 'Data',
test: 'Test',
insert: 'Insert {name}',
variables: 'Data from earlier steps',
variablesHint: 'Click to insert at the cursor.',
runTest: 'Run this step',
testing: 'Running…',
testResult: 'Result',
testOk: 'The step ran in {ms} ms.',
save: 'Save step',
saved: 'Saved',
run: 'Run test',
running: 'Running…',
replay: 'Run again',
success: 'Succeeded',
failed: 'Failed',
skipped: 'Skipped',
waiting: 'Waiting',
total: '{n} steps in {ms} ms',
output: 'Output',
path: 'Path taken',
noRun: 'Run the workflow to see each step light up.',
},
de: {
trigger: 'Auslöser',
action: 'Aktion',
condition: 'Bedingung',
delay: 'Warten',
yes: 'Ja',
no: 'Nein',
zoomIn: 'Vergrößern',
zoomOut: 'Verkleinern',
fit: 'Einpassen',
canvas: 'Workflow-Fläche. Wähle einen Schritt und verschiebe ihn mit den Pfeiltasten.',
moveHint: 'Schritt ziehen oder mit den Pfeiltasten verschieben.',
selected: 'Gewählter Schritt',
comesAfter: 'Kommt nach',
leadsTo: 'Führt zu',
library: 'Schritt hinzufügen',
libraryText: 'Klicken, um ihn am Ende anzufügen.',
publish: 'Veröffentlichen',
publishing: 'Wird veröffentlicht…',
published: 'Veröffentlicht. Der Workflow läuft ab jetzt.',
draft: 'Entwurf',
live: 'Live',
steps: 'Schritte',
addStep: 'Hier einen Schritt einfügen',
addAfter: 'Schritt nach {name} einfügen',
remove: '{name} entfernen',
close: 'Schließen',
pick: 'Schritt wählen',
end: 'Ende',
setup: 'Einrichtung',
data: 'Daten',
test: 'Test',
insert: '{name} einfügen',
variables: 'Daten aus früheren Schritten',
variablesHint: 'Klicken, um sie an der Schreibmarke einzufügen.',
runTest: 'Schritt ausführen',
testing: 'Läuft…',
testResult: 'Ergebnis',
testOk: 'Der Schritt lief in {ms} ms.',
save: 'Schritt speichern',
saved: 'Gespeichert',
run: 'Test starten',
running: 'Läuft…',
replay: 'Erneut ausführen',
success: 'Erfolgreich',
failed: 'Fehlgeschlagen',
skipped: 'Übersprungen',
waiting: 'Wartet',
total: '{n} Schritte in {ms} ms',
output: 'Ausgabe',
path: 'Genommener Weg',
noRun: 'Starte den Workflow, um jeden Schritt aufleuchten zu sehen.',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion of the app screens */
/* ------------------------------------------------------------------ */
/*
* The same small set in every app block (auth, dashboard, files, inbox …),
* kept in the block so it ships on its own:
* useEnter entrance that waits until its area scrolls in. The area gets
* data-kitm-scope and data-kitm={state}, children take kitm-wipe
* (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
* kitm-up and kitm-pop, with --kitm-d as delay.
* useGlide a marker inside the active item ([data-glide]) glides over
* from where the previous one stood: tabs, segments, steps.
* kitm-in, kitm-fly, kitm-tick for things that appear after an action.
* The server renders the end state, without JavaScript nothing waits longer
* than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
* clip-path change, so no layout shifts.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
['grow-x', 'transform:scaleX(0)', 900],
['grow-y', 'transform:scaleY(0)', 800],
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
'@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
'.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
'@media (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
'[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
'}';
function KitmStyle() {
// React hoists it once into the head, however many app blocks render
return (
<style href="kitm" precedence="default">
{KITM_CSS}
</style>
);
}
/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
/**
* The marker ([data-glide]) is rendered inside the active item, so the server
* and a page without JavaScript show it in place. After a change it starts
* where the old one stood and glides into the new item.
*/
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
// Read during render, while the DOM still shows the old marker
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
type P = Omit<WorkflowBuilderProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; nodes: WorkflowNode[] };
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: WorkflowBuilderProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(prefersReducedMotion() ? 0 : ms);
}
const NODE_W = 232;
const NODE_H = 72;
const KIND: Record<NodeKind, { icon: IconName; ring: string; chip: string }> = {
trigger: { icon: 'bolt', ring: 'ring-[color-mix(in_oklab,var(--warning)_50%,transparent)]', chip: 'bg-[color-mix(in_oklab,var(--warning)_20%,var(--card))] text-warning-foreground' },
action: { icon: 'play', ring: 'ring-[color-mix(in_oklab,var(--primary)_55%,transparent)]', chip: 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' },
condition: { icon: 'route', ring: 'ring-[color-mix(in_oklab,var(--foreground)_40%,transparent)]', chip: 'bg-muted text-foreground' },
delay: { icon: 'hourglass', ring: 'ring-[color-mix(in_oklab,var(--foreground)_30%,transparent)]', chip: 'bg-muted text-muted-foreground' },
};
function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
/** The order of the steps from the trigger on, breadth first */
function order(nodes: WorkflowNode[]) {
const byId = new Map(nodes.map((n) => [n.id, n]));
const start = nodes.find((n) => n.kind === 'trigger') ?? nodes[0];
const seen = new Set<string>();
const out: WorkflowNode[] = [];
const queue = start ? [start.id] : [];
while (queue.length) {
const id = queue.shift()!;
if (seen.has(id)) continue;
seen.add(id);
const n = byId.get(id);
if (!n) continue;
out.push(n);
queue.push(...(n.next ?? []));
}
return out;
}
/** One step as a card, the same look on the canvas and in lists */
function NodeCard({ n, t, active, status, className, children }: { n: WorkflowNode; t: Texts; active?: boolean; status?: TraceStep['status'] | 'running' | 'waiting'; className?: string; children?: React.ReactNode }) {
const k = KIND[n.kind];
return (
<div className={cn('relative flex min-w-0 items-center gap-3 rounded-xl border bg-card px-3 py-3 shadow-[var(--highlight),var(--shadow-sm)] transition-[box-shadow,border-color,opacity] duration-300', active ? cn('border-transparent ring-2', k.ring, 'shadow-[var(--highlight),var(--shadow-md)]') : 'border-border', status === 'skipped' && 'opacity-55', status === 'waiting' && 'opacity-70', className)}>
<span className={cn('relative grid size-10 shrink-0 place-items-center rounded-lg', k.chip)}>
<Icon name={n.icon ?? k.icon} size={18} />
{status === 'running' && <span aria-hidden className="absolute -inset-1 rounded-[11px] border-2 border-primary motion-safe:animate-pulse" />}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{t[n.kind]}</span>
<span className="block truncate text-[13.5px] font-semibold text-foreground">{n.title}</span>
{n.detail && <span className="block truncate text-[12px] text-muted-foreground">{n.detail}</span>}
</span>
{status && status !== 'waiting' && status !== 'running' && (
<span className={cn('kitm-pop grid size-6 shrink-0 place-items-center rounded-full', status === 'success' ? 'bg-success text-white' : status === 'failed' ? 'bg-destructive text-white' : 'bg-muted text-muted-foreground')}>
<Icon name={status === 'success' ? 'check' : status === 'failed' ? 'x' : 'minus'} size={12} strokeWidth={2.6} />
</span>
)}
{children}
</div>
);
}
/** Smooth vertical curve from the bottom of a to the top of b */
const edge = (a: WorkflowNode, b: WorkflowNode) => {
const x1 = a.x + NODE_W / 2;
const y1 = a.y + NODE_H;
const x2 = b.x + NODE_W / 2;
const y2 = b.y;
const my = round((y1 + y2) / 2);
return `M ${round(x1)} ${round(y1)} C ${round(x1)} ${my}, ${round(x2)} ${my}, ${round(x2)} ${round(y2)}`;
};
/* ------------------------------------------------------------------ */
/* canvas */
/* ------------------------------------------------------------------ */
/**
* The flow as nested lists: a straight chain, and at a condition one group per branch
* that runs until the branches meet again. The meeting step follows the groups.
*/
function PhoneFlow({ nodes, t, sel, onPick }: { nodes: WorkflowNode[]; t: Texts; sel: string; onPick: (id: string) => void }) {
const byId = new Map(nodes.map((n) => [n.id, n]));
const parents = new Map<string, number>();
nodes.forEach((n) => n.next?.forEach((x) => parents.set(x, (parents.get(x) ?? 0) + 1)));
const start = nodes.find((n) => n.kind === 'trigger') ?? nodes[0];
let k = 0;
const card = (n: WorkflowNode) => (
<button type="button" onClick={() => onPick(n.id)} aria-pressed={n.id === sel} className="kitm-up block w-full text-left" style={{ '--kitm-d': `${k++ * 60}ms` } as React.CSSProperties}>
<NodeCard n={n} t={t} active={n.id === sel} />
</button>
);
const line = (label?: string) => (
<span aria-hidden className="relative mx-auto flex h-7 w-px justify-center bg-border-strong">
{label && <span className="absolute top-1/2 -translate-y-1/2 rounded-full border border-border bg-card px-2 text-[11px] font-semibold leading-5 text-muted-foreground">{label}</span>}
</span>
);
// Walks from id until a step that several steps lead to (where branches meet), returns the items and that step
const walk = (id: string | undefined, inBranch: boolean, seen: Set<string>): { items: React.ReactNode[]; meet?: string } => {
const items: React.ReactNode[] = [];
let cur = id;
while (cur && !seen.has(cur)) {
if (inBranch && (parents.get(cur) ?? 0) > 1) return { items, meet: cur };
seen.add(cur);
const n = byId.get(cur);
if (!n) break;
if (items.length) items.push(<span key={`l-${n.id}`}>{line()}</span>);
items.push(<div key={n.id}>{card(n)}</div>);
if (n.kind === 'condition' && (n.next?.length ?? 0) > 1) {
let meet: string | undefined;
const groups = (n.next ?? []).map((x, i) => {
const res = walk(x, true, seen);
meet = meet ?? res.meet;
return (
<li key={x} className="min-w-0 border-s-2 border-border ps-3">
<span className="mb-2 inline-flex h-5 items-center rounded-full border border-border bg-card px-2 text-[11px] font-semibold text-muted-foreground">{i === 0 ? t.yes : t.no}</span>
<div className="grid">{res.items}</div>
</li>
);
});
items.push(<span key={`lg-${n.id}`}>{line()}</span>);
items.push(
<ul key={`g-${n.id}`} className="grid gap-4">
{groups}
</ul>,
);
if (meet) items.push(<span key={`lm-${meet}`}>{line()}</span>);
cur = meet;
if (meet) {
seen.delete(meet);
// The meeting step belongs to the main chain again
const rest = walk(meet, false, seen);
items.push(...rest.items);
}
return { items };
}
cur = n.next?.[0];
}
return { items };
};
return <div aria-label={t.steps} className="grid">{start ? walk(start.id, false, new Set()).items : null}</div>;
}
function Canvas({ t, title, subtitle, nodes: start, onAction }: P) {
const id = useId();
const [nodes, setNodes] = useState(start);
const [sel, setSel] = useState(start[1]?.id ?? start[0]?.id ?? '');
const [zoom, setZoom] = useState(1);
const boxRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<'draft' | 'busy' | 'live'>('draft');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const drag = useRef<{ id: string; dx: number; dy: number } | null>(null);
const stageRef = useRef<HTMLDivElement>(null);
const byId = new Map(nodes.map((n) => [n.id, n]));
const w = Math.max(...nodes.map((n) => n.x + NODE_W)) + 48;
const h = Math.max(...nodes.map((n) => n.y + NODE_H)) + 48;
const s = nodes.find((n) => n.id === sel);
const move = (nid: string, x: number, y: number) => setNodes((l) => l.map((n) => (n.id === nid ? { ...n, x: Math.max(0, Math.round(x / 8) * 8), y: Math.max(0, Math.round(y / 8) * 8) } : n)));
const point = (e: React.PointerEvent) => {
const r = stageRef.current!.getBoundingClientRect();
return { x: (e.clientX - r.left) / zoom, y: (e.clientY - r.top) / zoom };
};
const flow = order(nodes);
// Fit the whole flow into the width of the canvas, on first paint and on request
const fit = () => {
const el = boxRef.current;
if (!el) return;
setZoom(Math.max(0.6, Math.min(1, round((el.clientWidth - 48) / w, 2))));
};
useEffect(() => {
fit();
const el = boxRef.current;
if (!el || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => fit());
ro.observe(el);
return () => ro.disconnect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Which branch of a condition a step sits on
const branch = new Map<string, string>();
nodes.forEach((n) => n.kind === 'condition' && n.next?.forEach((x, k) => branch.set(x, k === 0 ? t.yes : t.no)));
const before = s ? nodes.filter((n) => n.next?.includes(s.id)) : [];
const after = s ? (s.next ?? []).map((x) => byId.get(x)).filter((x): x is WorkflowNode => Boolean(x)) : [];
return (
<Main>
<Header title={title} subtitle={subtitle}>
<span className={cn('inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-[12.5px] font-medium', state === 'live' ? 'bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success-foreground' : 'bg-muted text-muted-foreground')}>
<span aria-hidden className={cn('size-1.5 rounded-full', state === 'live' ? 'bg-success motion-safe:animate-pulse' : 'bg-subtle-foreground')} />
{state === 'live' ? t.live : t.draft}
</span>
<Button
size="sm"
iconLeft={state === 'busy' ? 'loader' : 'rocket'}
disabled={state !== 'draft'}
onClick={async () => {
setState('busy');
await run(onAction, 'publish', { nodes });
setState('live');
setMsg(t.published);
}}
>
{state === 'busy' ? t.publishing : t.publish}
</Button>
</Header>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_18rem] lg:items-start">
{/* Tablets and desktop: a canvas with dots. Phones: the same steps as a list from top to bottom. */}
<section className="kitm-up relative hidden min-w-0 overflow-hidden rounded-xl border border-border bg-surface shadow-[var(--highlight),var(--shadow-sm)] md:block">
<div aria-hidden className="pointer-events-none absolute inset-0 [background-image:radial-gradient(color-mix(in_oklab,var(--foreground)_14%,transparent)_1px,transparent_1px)] [background-size:20px_20px]" />
<div className="absolute end-3 top-3 z-10 flex items-center gap-1 rounded-lg border border-border bg-card p-1 shadow-[var(--shadow-sm)]">
<button type="button" aria-label={t.zoomOut} onClick={() => setZoom((z) => Math.max(0.6, round(z - 0.1, 1)))} className="grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground sm:size-8">
<Icon name="minus" size={15} />
</button>
<span className="w-11 text-center text-[12px] tabular-nums text-muted-foreground">{Math.round(zoom * 100)}%</span>
<button type="button" aria-label={t.zoomIn} onClick={() => setZoom((z) => Math.min(1.4, round(z + 0.1, 1)))} className="grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground sm:size-8">
<Icon name="plus" size={15} />
</button>
<button type="button" aria-label={t.fit} onClick={fit} className="grid size-9 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground sm:size-8">
<Icon name="maximize" size={14} />
</button>
</div>
<div ref={boxRef} className="kit-no-scrollbar relative overflow-auto p-6 pt-16" style={{ height: `min(75dvh, ${Math.round(h * zoom) + 88}px)` }}>
<div
ref={stageRef}
role="application"
aria-label={t.canvas}
className="relative origin-top-left transition-transform duration-300 ease-[var(--ease-kit)]"
style={{ width: w, height: h, transform: `scale(${zoom})` }}
onPointerMove={(e) => {
const d = drag.current;
if (!d) return;
const p = point(e);
move(d.id, p.x - d.dx, p.y - d.dy);
}}
onPointerUp={() => (drag.current = null)}
onPointerLeave={() => (drag.current = null)}
>
<svg aria-hidden className="pointer-events-none absolute inset-0 overflow-visible" width={w} height={h}>
{nodes.flatMap((a) =>
(a.next ?? []).map((nid, k) => {
const b = byId.get(nid);
if (!b) return null;
const d = edge(a, b);
const hot = a.id === sel || b.id === sel;
return (
<g key={`${a.id}-${nid}`}>
<path d={d} fill="none" stroke={hot ? 'var(--primary)' : 'color-mix(in oklab, var(--foreground) 32%, transparent)'} strokeWidth={hot ? 2 : 1.5} className="transition-[stroke] duration-300" />
<path d={d} fill="none" stroke="var(--primary)" strokeWidth={2} strokeDasharray="4 10" className={cn('opacity-0 transition-opacity', hot && 'opacity-80 motion-safe:animate-[kitw-flow_1s_linear_infinite]')} />
{a.kind === 'condition' && (
<text x={round((a.x + NODE_W / 2 + b.x + NODE_W / 2) / 2)} y={round((a.y + NODE_H + b.y) / 2) - 6} textAnchor="middle" className="fill-muted-foreground text-[11px] font-semibold">
{k === 0 ? t.yes : t.no}
</text>
)}
</g>
);
}),
)}
</svg>
{nodes.map((n, i) => (
<div
key={n.id}
role="button"
tabIndex={0}
aria-pressed={n.id === sel}
aria-label={`${t[n.kind]}: ${n.title}`}
className={cn('kitm-pop absolute cursor-grab touch-none outline-none active:cursor-grabbing [&:focus-visible>div]:ring-2 [&:focus-visible>div]:ring-primary')}
style={{ left: n.x, top: n.y, width: NODE_W, '--kitm-d': `${i * 70}ms` } as React.CSSProperties}
onPointerDown={(e) => {
setSel(n.id);
const p = point(e);
drag.current = { id: n.id, dx: p.x - n.x, dy: p.y - n.y };
}}
onKeyDown={(e) => {
const d = e.shiftKey ? 32 : 8;
const map: Record<string, [number, number]> = { ArrowLeft: [-d, 0], ArrowRight: [d, 0], ArrowUp: [0, -d], ArrowDown: [0, d] };
const m = map[e.key];
if (m) {
e.preventDefault();
move(n.id, n.x + m[0], n.y + m[1]);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSel(n.id);
}
}}
onFocus={() => setSel(n.id)}
>
<NodeCard n={n} t={t} active={n.id === sel} />
</div>
))}
</div>
</div>
<p className="relative border-t border-border bg-card px-4 py-2.5 text-[12px] text-muted-foreground">{t.moveHint}</p>
</section>
{/* Phones: the flow as a list, the branches of a condition side by side as indented groups */}
<div className="md:hidden">
<PhoneFlow nodes={nodes} t={t} sel={sel} onPick={setSel} />
</div>
{/* The picked step */}
{s && (
<aside aria-labelledby={`${id}-s`} className="kitm-up [--kitm-d:120ms] rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5 lg:sticky lg:top-6">
<p id={`${id}-s`} className="text-[12px] font-medium text-muted-foreground">
{t.selected}
</p>
<div key={s.id} className="kitm-in mt-3">
<div className="flex items-center gap-3">
<span className={cn('grid size-11 place-items-center rounded-xl', KIND[s.kind].chip)}>
<Icon name={s.icon ?? KIND[s.kind].icon} size={20} />
</span>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{t[s.kind]}</p>
<p className="truncate text-[16px] font-semibold text-foreground">{s.title}</p>
</div>
</div>
{s.detail && <p className="mt-3 text-pretty text-[13px] text-muted-foreground">{s.detail}</p>}
<dl className="mt-4 grid gap-3 border-t border-border pt-4 text-[13px]">
{before.length > 0 && (
<div>
<dt className="text-[12px] text-muted-foreground">{t.comesAfter}</dt>
{before.map((b) => (
<dd key={b.id}>
<button type="button" onClick={() => setSel(b.id)} className="mt-1 flex min-h-11 w-full items-center gap-2 rounded-md text-left text-foreground hover:text-primary sm:min-h-9">
<Icon name="arrow-up" size={14} className="shrink-0 text-muted-foreground" />
<span className="truncate">{b.title}</span>
</button>
</dd>
))}
</div>
)}
<div>
<dt className="text-[12px] text-muted-foreground">{t.leadsTo}</dt>
{after.length ? (
after.map((b) => (
<dd key={b.id}>
<button type="button" onClick={() => setSel(b.id)} className="mt-1 flex min-h-11 w-full items-center gap-2 rounded-md text-left text-foreground hover:text-primary sm:min-h-9">
<Icon name="arrow-down" size={14} className="shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{b.title}</span>
{s.kind === 'condition' && <span className="shrink-0 rounded bg-muted px-1.5 text-[11px] font-semibold text-muted-foreground">{branch.get(b.id)}</span>}
</button>
</dd>
))
) : (
<dd className="mt-1 text-muted-foreground">{t.end}</dd>
)}
</div>
</dl>
</div>
</aside>
)}
</div>
<style>{'@keyframes kitw-flow{to{stroke-dashoffset:-28}}'}</style>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* steps */
/* ------------------------------------------------------------------ */
function Steps({ t, title, subtitle, nodes: start, library = [], onAction }: P) {
const [nodes, setNodes] = useState(() => order(start).filter((n) => n.kind !== 'condition'));
const [menu, setMenu] = useState<number | null>(null);
const [fresh, setFresh] = useState('');
const [msg, setMsg] = useState('');
const [scope, enter] = useEnter<HTMLDivElement>();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (menu === null) return;
menuRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
const close = (e: KeyboardEvent) => e.key === 'Escape' && setMenu(null);
window.addEventListener('keydown', close);
return () => window.removeEventListener('keydown', close);
}, [menu]);
const add = (at: number, item: { kind: NodeKind; title: string; icon: IconName }) => {
const n: WorkflowNode = { id: `n${Date.now()}`, kind: item.kind, title: item.title, icon: item.icon, x: 0, y: 0 };
setNodes((l) => [...l.slice(0, at + 1), n, ...l.slice(at + 1)]);
setFresh(n.id);
setMenu(null);
setMsg(item.title);
void onAction?.('add-step', { after: nodes[at]?.id, kind: item.kind });
};
const Plus = ({ at }: { at: number }) => (
<div className="relative flex flex-col items-center">
<span aria-hidden className="block h-4 w-px bg-border-strong" />
<button type="button" aria-label={nodes[at] ? fill(t.addAfter, { name: nodes[at].title }) : t.addStep} aria-expanded={menu === at} onClick={() => setMenu(menu === at ? null : at)} className={cn('grid size-11 place-items-center rounded-full border bg-card text-muted-foreground shadow-[var(--shadow-sm)] transition-[transform,background-color,color,border-color] duration-300 ease-[var(--ease-kit)] hover:scale-110 hover:border-primary hover:text-primary sm:size-8', menu === at ? 'rotate-45 border-primary text-primary' : 'border-border-strong')}>
<Icon name="plus" size={15} />
</button>
<span aria-hidden className="block h-4 w-px bg-border-strong" />
{menu === at && (
<div ref={menuRef} role="menu" aria-label={t.pick} className="kitm-in absolute top-[calc(50%+1.5rem)] z-20 grid w-64 gap-0.5 rounded-xl border border-border bg-popover p-1.5 shadow-[var(--highlight),var(--shadow-lg)]">
{library.map((item) => (
<button key={item.title} type="button" role="menuitem" onClick={() => add(at, item)} className="flex h-11 items-center gap-3 rounded-lg px-2.5 text-left text-[13.5px] text-foreground hover:bg-accent focus-visible:bg-accent focus-visible:outline-none sm:h-10">
<span className={cn('grid size-7 shrink-0 place-items-center rounded-md', KIND[item.kind].chip)}>
<Icon name={item.icon} size={14} />
</span>
{item.title}
</button>
))}
</div>
)}
</div>
);
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_17rem] lg:items-start">
<div ref={scope} {...enter} className="mx-auto mt-2 grid w-full max-w-[30rem] justify-items-stretch">
{nodes.map((n, i) => (
<div key={n.id} className={cn('kitm-up relative', n.id === fresh && 'kitm-in', menu === i && 'z-30')} style={{ '--kitm-d': `${i * 70}ms` } as React.CSSProperties}>
<NodeCard n={n} t={t} active={n.id === fresh}>
{n.kind !== 'trigger' && (
<button
type="button"
aria-label={fill(t.remove, { name: n.title })}
onClick={() => {
setNodes((l) => l.filter((x) => x.id !== n.id));
void onAction?.('remove-step', { id: n.id });
}}
className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-destructive sm:size-8"
>
<Icon name="trash" size={15} />
</button>
)}
</NodeCard>
<Plus at={i} />
</div>
))}
<div className="kitm-up flex justify-center" style={{ '--kitm-d': `${nodes.length * 70}ms` } as React.CSSProperties}>
<span className="inline-flex h-8 items-center gap-2 rounded-full border border-dashed border-border-strong px-3.5 text-[12.5px] font-medium text-muted-foreground">
<Icon name="flag" size={13} />
{t.end}
</span>
</div>
</div>
{/* Desktop: the step types as a panel. Tablets and phones: the menu behind each plus. */}
<aside className="kitm-up [--kitm-d:160ms] hidden rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] lg:sticky lg:top-6 lg:block">
<h2 className="text-[14px] font-semibold text-foreground">{t.library}</h2>
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t.libraryText}</p>
<ul className="mt-3 grid gap-1.5">
{library.map((item) => (
<li key={item.title}>
<button type="button" onClick={() => add(nodes.length - 1, item)} className="group flex h-11 w-full items-center gap-3 rounded-lg border border-border px-2.5 text-left text-[13.5px] text-foreground transition-[border-color,background-color] hover:border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] hover:bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]">
<span className={cn('grid size-7 shrink-0 place-items-center rounded-md', KIND[item.kind].chip)}>
<Icon name={item.icon} size={14} />
</span>
<span className="min-w-0 flex-1 truncate">{item.title}</span>
<Icon name="plus" size={14} className="shrink-0 text-muted-foreground transition-colors group-hover:text-primary" />
</button>
</li>
))}
</ul>
</aside>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* inspector */
/* ------------------------------------------------------------------ */
function Inspector({ t, title, subtitle, nodes, selected, fields = [], variables = [], onAction }: P) {
const id = useId();
const flow = order(nodes);
const node = nodes.find((n) => n.id === selected) ?? flow[1] ?? flow[0];
const [tab, setTab] = useState<'setup' | 'data' | 'test'>('setup');
const [vals, setVals] = useState<Record<string, string>>(() => Object.fromEntries(fields.map((f) => [f.id, f.value])));
const [focus, setFocus] = useState(fields[0]?.id ?? '');
const [state, setState] = useState<'idle' | 'testing' | 'tested' | 'saved'>('idle');
const [scope, enter] = useEnter<HTMLDivElement>();
const tabsRef = useGlide<HTMLDivElement>(tab);
const [rowRef, fade] = useScrollFade<HTMLOListElement>();
const refs = useRef<Record<string, HTMLInputElement | HTMLTextAreaElement | null>>({});
const insert = (key: string) => {
const el = refs.current[focus];
const token = `{{${key}}}`;
const v = vals[focus] ?? '';
const at = el?.selectionStart ?? v.length;
const end = el?.selectionEnd ?? at;
const nv = v.slice(0, at) + token + v.slice(end);
setVals((x) => ({ ...x, [focus]: nv }));
setState('idle');
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(at + token.length, at + token.length);
});
};
// The fields with every token replaced by its example value
const preview = (v: string) => v.replace(/\{\{([^}]+)\}\}/g, (_, k) => variables.find((x) => x.key === k)?.sample ?? `{{${k}}}`);
const tabs: { v: typeof tab; label: string; icon: IconName }[] = [
{ v: 'setup', label: t.setup, icon: 'sliders' },
{ v: 'data', label: t.data, icon: 'database' },
{ v: 'test', label: t.test, icon: 'play' },
];
const parts = (v: string) => v.split(/(\{\{[^}]+\}\})/g);
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[16rem_minmax(0,1fr)] lg:items-start">
{/* Desktop: the flow as a slim column. Tablets and phones: a row of steps above the panel. */}
<ol ref={rowRef} aria-label={t.steps} className={cn('kitm-up kit-no-scrollbar -mx-4 flex gap-2 overflow-x-auto px-4 lg:mx-0 lg:grid lg:gap-0 lg:overflow-visible lg:px-0 lg:[mask-image:none]', fade)}>
{flow.map((n, i) => (
<li key={n.id} className="w-56 shrink-0 lg:w-auto">
{i > 0 && <span aria-hidden className="mx-auto hidden h-4 w-px bg-border-strong lg:block" />}
<NodeCard n={n} t={t} active={n.id === node?.id} className="py-2.5" />
</li>
))}
</ol>
{node && (
<section aria-labelledby={`${id}-h`} className="kitm-up [--kitm-d:100ms] min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex items-center gap-3 border-b border-border px-4 py-4 sm:px-5">
<span className={cn('grid size-10 place-items-center rounded-lg', KIND[node.kind].chip)}>
<Icon name={node.icon ?? KIND[node.kind].icon} size={18} />
</span>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{t[node.kind]}</p>
<h2 id={`${id}-h`} className="truncate text-[16px] font-semibold text-foreground">
{node.title}
</h2>
</div>
</div>
<div ref={tabsRef} role="tablist" aria-label={node.title} className="relative flex gap-1 border-b border-border px-3 sm:px-4">
{tabs.map((x) => (
<button key={x.v} type="button" role="tab" id={`${id}-t-${x.v}`} aria-selected={tab === x.v} aria-controls={`${id}-p`} onClick={() => setTab(x.v)} className={cn('relative inline-flex h-11 items-center gap-1.5 px-2.5 text-[13px] font-medium transition-colors', tab === x.v ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}>
<Icon name={x.icon} size={14} />
{x.label}
{tab === x.v && <span data-glide aria-hidden className="absolute inset-x-1 -bottom-px h-0.5 rounded-full bg-primary" />}
</button>
))}
</div>
<div id={`${id}-p`} role="tabpanel" aria-labelledby={`${id}-t-${tab}`} key={tab} className="kitm-in p-4 sm:p-5">
{tab === 'setup' && (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_15rem]">
<div className="grid gap-4">
{fields.map((f) => {
const Tag = f.multiline ? 'textarea' : 'input';
return (
<label key={f.id} className="grid gap-1.5 text-[13px] font-medium text-foreground" htmlFor={`${id}-${f.id}`}>
{f.label}
<Tag
id={`${id}-${f.id}`}
ref={(el: HTMLInputElement | HTMLTextAreaElement | null) => {
refs.current[f.id] = el;
}}
value={vals[f.id] ?? ''}
rows={f.multiline ? 5 : undefined}
onFocus={() => setFocus(f.id)}
onChange={(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setVals((x) => ({ ...x, [f.id]: e.target.value }));
setState('idle');
}}
className={cn('w-full rounded-lg border bg-background px-3 text-base font-normal text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:text-[13.5px]', f.multiline ? 'resize-y py-2.5 leading-relaxed' : 'h-11 sm:h-10', focus === f.id ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))]' : 'border-input')}
/>
{f.hint && <span className="text-[12.5px] font-normal text-muted-foreground">{f.hint}</span>}
</label>
);
})}
</div>
<div className="rounded-lg border border-border bg-surface p-3">
<p className="text-[12.5px] font-semibold text-foreground">{t.variables}</p>
<p className="mt-0.5 text-[12px] text-muted-foreground">{t.variablesHint}</p>
<ul className="mt-3 grid gap-1.5">
{variables.map((v) => (
<li key={v.key}>
<button type="button" aria-label={fill(t.insert, { name: v.label })} onMouseDown={(e) => e.preventDefault()} onClick={() => insert(v.key)} className="group flex min-h-11 w-full items-center gap-2 rounded-md border border-border bg-card px-2.5 py-1.5 text-left transition-[border-color,background-color] hover:border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] hover:bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))] sm:min-h-9">
<Icon name="plus" size={13} className="shrink-0 text-muted-foreground transition-colors group-hover:text-primary" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[12.5px] font-medium text-foreground">{v.label}</span>
<span className="block truncate text-[11.5px] tabular-nums text-subtle-foreground">{v.sample}</span>
</span>
</button>
</li>
))}
</ul>
</div>
</div>
)}
{tab === 'data' && (
<dl className="grid gap-3">
{fields.map((f) => (
<div key={f.id} className="rounded-lg border border-border p-3">
<dt className="text-[12px] font-medium text-muted-foreground">{f.label}</dt>
<dd className="mt-1 whitespace-pre-wrap text-[13.5px] leading-relaxed text-foreground">
{parts(vals[f.id] ?? '').map((p, i) =>
/^\{\{/.test(p) ? (
<span key={i} className="mx-0.5 inline-flex items-center rounded-md bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] px-1.5 text-[12.5px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
{variables.find((v) => `{{${v.key}}}` === p)?.label ?? p}
</span>
) : (
<span key={i}>{p}</span>
),
)}
</dd>
</div>
))}
</dl>
)}
{tab === 'test' && (
<div className="grid gap-4">
{state === 'tested' ? (
<div className="kitm-in grid gap-3">
<p className="flex items-center gap-2 text-[13.5px] font-medium text-success-foreground">
<Icon name="check-circle" size={16} />
{fill(t.testOk, { ms: 284 })}
</p>
<p className="text-[12.5px] font-medium text-muted-foreground">{t.testResult}</p>
{fields.map((f) => (
<div key={f.id} className="rounded-lg border border-border bg-surface p-3">
<p className="text-[12px] text-muted-foreground">{f.label}</p>
<p className="mt-1 whitespace-pre-wrap text-[13.5px] leading-relaxed text-foreground">{preview(vals[f.id] ?? '')}</p>
</div>
))}
</div>
) : (
<p className="text-[13.5px] text-muted-foreground">{t.noRun}</p>
)}
</div>
)}
</div>
<div className="flex flex-col-reverse gap-2.5 border-t border-border bg-surface px-4 py-3.5 sm:flex-row sm:justify-end sm:px-5">
<Button
variant="outline"
size="sm"
iconLeft={state === 'testing' ? 'loader' : 'play'}
disabled={state === 'testing'}
onClick={async () => {
setState('testing');
setTab('test');
await run(onAction, 'test-step', { node: node.id, values: vals }, 900);
setState('tested');
}}
>
{state === 'testing' ? t.testing : t.runTest}
</Button>
<Button
size="sm"
iconLeft={state === 'saved' ? 'check' : undefined}
onClick={async () => {
await run(onAction, 'save-step', { node: node.id, values: vals });
setState('saved');
}}
>
{state === 'saved' ? t.saved : t.save}
</Button>
</div>
</section>
)}
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* run-trace */
/* ------------------------------------------------------------------ */
function RunTrace({ t, title, subtitle, nodes, trace = [], locale, onAction }: P) {
// Starts with a finished run, so the page shows a result without a click and without JavaScript
const [step, setStep] = useState(trace.length);
const [running, setRunning] = useState(false);
const [open, setOpen] = useState<string | null>(null);
const [scope, enter] = useEnter<HTMLDivElement>();
const byId = new Map(nodes.map((n) => [n.id, n]));
const shown = trace.map((s) => ({ s, n: byId.get(s.node) })).filter((x): x is { s: TraceStep; n: WorkflowNode } => Boolean(x.n));
const total = trace.reduce((a, s) => a + s.ms, 0);
const num = (n: number) => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n);
const play = async () => {
setRunning(true);
setOpen(null);
void onAction?.('run', {});
for (let i = 0; i <= shown.length; i++) {
setStep(i);
await wait(prefersReducedMotion() ? 0 : 520);
}
setRunning(false);
};
const done = step >= shown.length;
const label: Record<TraceStep['status'], string> = { success: t.success, failed: t.failed, skipped: t.skipped };
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Button size="sm" iconLeft={running ? 'loader' : step < 0 ? 'play' : 'rotate-cw'} disabled={running} onClick={play}>
{running ? t.running : step < 0 ? t.run : t.replay}
</Button>
</Header>
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,34rem)_minmax(0,1fr)] lg:items-start">
<ol className="relative grid">
{shown.map(({ s, n }, i) => {
const st = i < step || done ? s.status : i === step ? 'running' : 'waiting';
const lit = i < step || done;
return (
<li key={n.id} className="kitm-up" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
{i > 0 && (
<span aria-hidden className="relative mx-auto block h-6 w-0.5 overflow-hidden rounded-full bg-border">
<span className={cn('absolute inset-0 origin-top bg-primary transition-transform duration-500 ease-[var(--ease-kit)]', (lit || i === step) && s.status !== 'skipped' && shown[i - 1]?.s.status !== 'skipped' ? 'scale-y-100' : 'scale-y-0')} />
</span>
)}
<button type="button" aria-expanded={open === n.id} onClick={() => setOpen(open === n.id ? null : n.id)} className="block w-full text-left">
<NodeCard n={n} t={t} status={st as TraceStep['status']} active={open === n.id}>
<span className="hidden shrink-0 text-right text-[12px] tabular-nums text-subtle-foreground sm:block">{lit ? `${num(s.ms)} ms` : ''}</span>
</NodeCard>
</button>
<div className={cn('grid transition-[grid-template-rows] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', open === n.id && lit ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')} inert={!(open === n.id && lit)}>
<div className="min-h-0 overflow-hidden">
<p className="mx-3 rounded-b-lg border border-t-0 border-border bg-surface px-3 py-2.5 text-[12.5px] text-muted-foreground">
<span className="font-medium text-foreground">{label[s.status]}</span> · {num(s.ms)} ms
{s.output && <span className="mt-0.5 block text-foreground">{s.output}</span>}
</p>
</div>
</div>
</li>
);
})}
</ol>
<aside className="kitm-up [--kitm-d:120ms] rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5 lg:sticky lg:top-6">
{done ? (
<div key="done" className="kitm-in">
<p className="text-[12.5px] font-medium text-muted-foreground">{t.path}</p>
<p className="mt-1 text-[22px] font-semibold tabular-nums tracking-[-0.02em] text-foreground">{fill(t.total, { n: shown.filter((x) => x.s.status !== 'skipped').length, ms: num(total) })}</p>
{/* A strip of the run: each step as wide as it took */}
<div className="mt-4 flex h-3 gap-[2px] overflow-hidden rounded-full">
{shown.map(({ s, n }) => (
<span key={n.id} title={`${n.title} · ${num(s.ms)} ms`} className={cn('h-full', s.status === 'success' ? 'bg-primary' : s.status === 'failed' ? 'bg-destructive' : 'bg-track')} style={{ flexGrow: Math.max(s.ms, total / 60), flexBasis: 0 }} />
))}
</div>
<ul className="mt-4 grid gap-2">
{shown.map(({ s, n }) => (
<li key={n.id} className="flex items-center gap-2.5 text-[13px]">
<span aria-hidden className={cn('size-2 shrink-0 rounded-sm', s.status === 'success' ? 'bg-primary' : s.status === 'failed' ? 'bg-destructive' : 'bg-muted-foreground/40')} />
<span className={cn('min-w-0 flex-1 truncate', s.status === 'skipped' ? 'text-muted-foreground line-through' : 'text-foreground')}>{n.title}</span>
<span className="shrink-0 tabular-nums text-subtle-foreground">{num(s.ms)} ms</span>
</li>
))}
</ul>
</div>
) : (
<p key="wait" className="kitm-in text-[13.5px] text-muted-foreground" role="status">
{step < 0 ? t.noRun : `${t.running} ${Math.min(step + 1, shown.length)} / ${shown.length}`}
</p>
)}
</aside>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* WorkflowBuilder */
/* ------------------------------------------------------------------ */
/**
* Workflow builder screens: a canvas with draggable steps and live
* connections, a linear builder with steps to insert anywhere, an
* inspector with data from earlier steps, and a test run that lights up
* step by step.
* <WorkflowBuilder variant="canvas" title="New lead" nodes={[{ id, kind, title, x, y, next }]} />
*/
export function WorkflowBuilder({ variant = 'canvas', sidebar, labels, className, nodes = [], ...props }: WorkflowBuilderProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const p: P = { ...props, t, locale, nodes };
const body = (() => {
switch (variant) {
case 'steps':
return <Steps {...p} />;
case 'inspector':
return <Inspector {...p} />;
case 'run-trace':
return <RunTrace {...p} />;
default:
return <Canvas {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<KitmStyle />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<KitmStyle />
{body}
</Sidebar>
);
}Frequent questions
How do I install Workflow builder?
With aiw add @ai-website/workflow-builder. The command puts components/ui/workflow-builder.tsx into your project, the code is yours from then on. It brings along button, icon and sidebar. It needs no further npm packages.
Which variants does Workflow builder have?
4 variants, Canvas, Linear steps, Step inspector and Test run. You pick one with the variant prop, for example variant="steps".
Can I translate the texts of Workflow builder?
Yes. Workflow builder carries 51 texts of its own in English and German, such as “Trigger”, “Action” and “Condition”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Workflow builder?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Workflow builder with aiw_components and install it with aiw_add @ai-website/workflow-builder. Name the variant in your prompt, for example variant="canvas", and the assistant writes your content straight into the props.
Can I adjust Workflow builder without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching WorkflowBuilder code to copy.