Workflow-Builder
Workflow-Screens. Eine Fläche mit ziehbaren Schritten und lebenden Verbindungen, ein linearer Builder, ein Schritt-Inspektor mit Daten aus früheren Schritten und ein Testlauf, der Schritt für Schritt aufleuchtet.
Eine von 3 Components in Automation und Builder
Variants
Details
Was Workflow-Builder mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Automation und Builder
- Bereich
- App
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/workflow-builder.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 51 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle WorkflowBuilderProps aus components/ui/workflow-builder.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalWorkflowBuilderVariantcanvasstepsinspectorrun-trace
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringnodesoptionalWorkflowNode[]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.
Eingebaute Texte
Diese Texte schreibt Workflow-Builder selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 51 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| trigger | Englisch Trigger | Deutsch Auslöser |
| action | Englisch Action | Deutsch Aktion |
| condition | Englisch Condition | Deutsch Bedingung |
| delay | Englisch Wait | Deutsch Warten |
| yes | Englisch Yes | Deutsch Ja |
| no | Englisch No | Deutsch Nein |
| zoomIn | Englisch Zoom in | Deutsch Vergrößern |
| zoomOut | Englisch Zoom out | Deutsch Verkleinern |
| fit | Englisch Fit to screen | Deutsch Einpassen |
| canvas | Englisch Workflow canvas. Pick a step, then move it with the arrow keys. | Deutsch Workflow-Fläche. Wähle einen Schritt und verschiebe ihn mit den Pfeiltasten. |
| moveHint | Englisch Drag a step or move it with the arrow keys. | Deutsch Schritt ziehen oder mit den Pfeiltasten verschieben. |
| selected | Englisch Selected step | Deutsch Gewählter Schritt |
| comesAfter | Englisch Comes after | Deutsch Kommt nach |
| leadsTo | Englisch Leads to | Deutsch Führt zu |
| library | Englisch Add a step | Deutsch Schritt hinzufügen |
| libraryText | Englisch Click to add it at the end. | Deutsch Klicken, um ihn am Ende anzufügen. |
| publish | Englisch Publish | Deutsch Veröffentlichen |
| publishing | Englisch Publishing… | Deutsch Wird veröffentlicht… |
| published | Englisch Published. The workflow runs from now on. | Deutsch Veröffentlicht. Der Workflow läuft ab jetzt. |
| draft | Englisch Draft | Deutsch Entwurf |
| live | Englisch Live | Deutsch Live |
| steps | Englisch Steps | Deutsch Schritte |
| addStep | Englisch Add a step here | Deutsch Hier einen Schritt einfügen |
| addAfter | Englisch Add a step after {name} | Deutsch Schritt nach {name} einfügen |
| remove | Englisch Remove {name} | Deutsch {name} entfernen |
| close | Englisch Close | Deutsch Schließen |
| pick | Englisch Pick a step | Deutsch Schritt wählen |
| end | Englisch End | Deutsch Ende |
| setup | Englisch Setup | Deutsch Einrichtung |
| data | Englisch Data | Deutsch Daten |
| test | Englisch Test | Deutsch Test |
| insert | Englisch Insert {name} | Deutsch {name} einfügen |
| variables | Englisch Data from earlier steps | Deutsch Daten aus früheren Schritten |
| variablesHint | Englisch Click to insert at the cursor. | Deutsch Klicken, um sie an der Schreibmarke einzufügen. |
| runTest | Englisch Run this step | Deutsch Schritt ausführen |
| testing | Englisch Running… | Deutsch Läuft… |
| testResult | Englisch Result | Deutsch Ergebnis |
| testOk | Englisch The step ran in {ms} ms. | Deutsch Der Schritt lief in {ms} ms. |
| save | Englisch Save step | Deutsch Schritt speichern |
| saved | Englisch Saved | Deutsch Gespeichert |
| run | Englisch Run test | Deutsch Test starten |
| running | Englisch Running… | Deutsch Läuft… |
| replay | Englisch Run again | Deutsch Erneut ausführen |
| success | Englisch Succeeded | Deutsch Erfolgreich |
| failed | Englisch Failed | Deutsch Fehlgeschlagen |
| skipped | Englisch Skipped | Deutsch Übersprungen |
| waiting | Englisch Waiting | Deutsch Wartet |
| total | Englisch {n} steps in {ms} ms | Deutsch {n} Schritte in {ms} ms |
| output | Englisch Output | Deutsch Ausgabe |
| path | Englisch Path taken | Deutsch Genommener Weg |
| noRun | Englisch Run the workflow to see each step light up. | Deutsch Starte den Workflow, um jeden Schritt aufleuchten zu sehen. |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Workflow-Builder reagiert selbst auf die Tasten Escape, Enter, Ende und Leertaste.
- Setzt aria-controls, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen application, button, menu, menuitem, status, tab, tablist und tabpanel.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.098 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Workflow-Builder?
Mit aiw add @ai-website/workflow-builder. Der Befehl legt die Datei components/ui/workflow-builder.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Workflow-Builder?
4 Varianten, Fläche, Lineare Schritte, Schritt-Inspektor und Testlauf. Du wählst sie mit der Prop variant, zum Beispiel variant="steps".
Kann ich die Texte von Workflow-Builder übersetzen?
Ja. Workflow-Builder bringt 51 eigene Texte auf Deutsch und Englisch mit, etwa „Auslöser“, „Aktion“ und „Bedingung“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Workflow-Builder einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Workflow-Builder mit aiw_components und installieren sie mit aiw_add @ai-website/workflow-builder. Nenn im Prompt die Variante, etwa variant="canvas", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Workflow-Builder ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden WorkflowBuilder-Code zum Kopieren.