Skip to content
UI Kit
Accent color

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

4 variants

Variants

Canvas

Steps on a dotted canvas with curves between them, drag or arrow keys to move, zoom, the picked step in a panel and publish. Phones get the steps as a list.

Open in Studio

Linear steps

Steps from top to bottom with a plus between any two that opens a menu of step types, remove in place and a clear end.

Open in Studio

Step inspector

One step with setup, data and test tabs, fields that take data from earlier steps at the cursor, and a test that fills in example values.

Open in Studio

Test run

The path of one run with each step lit, skipped branches faded, the time per step as a strip and a replay that walks the path again.

Open in Studio

Details

What Workflow builder brings along, read from the registry and the source.

Area
App
Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/workflow-builder.tsx
Brings along
utilskitbuttoniconsidebar
npm packages
None
Languages
English and German, 51 texts of its own

Props

The WorkflowBuilderProps interface from components/ui/workflow-builder.tsx, with the comments from the code.

  • variantoptional
    WorkflowBuilderVariant

    canvasstepsinspectorrun-trace

  • sidebaroptional
    Omit<SidebarProps, 'children'> | null

    The app frame. Leave it out (or null) for a standalone screen.

  • titlerequired
    string
  • subtitleoptional
    string
  • nodesoptional
    WorkflowNode[]
  • selectedoptional
    string

    inspector: the node on screen and its fields

  • fieldsoptional
    WorkflowField[]
  • variablesoptional
    WorkflowVariable[]
  • traceoptional
    TraceStep[]

    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
triggerEnglish TriggerGerman Auslöser
actionEnglish ActionGerman Aktion
conditionEnglish ConditionGerman Bedingung
delayEnglish WaitGerman Warten
yesEnglish YesGerman Ja
noEnglish NoGerman Nein
zoomInEnglish Zoom inGerman Vergrößern
zoomOutEnglish Zoom outGerman Verkleinern
fitEnglish Fit to screenGerman Einpassen
canvasEnglish 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.
moveHintEnglish Drag a step or move it with the arrow keys.German Schritt ziehen oder mit den Pfeiltasten verschieben.
selectedEnglish Selected stepGerman Gewählter Schritt
comesAfterEnglish Comes afterGerman Kommt nach
leadsToEnglish Leads toGerman Führt zu
libraryEnglish Add a stepGerman Schritt hinzufügen
libraryTextEnglish Click to add it at the end.German Klicken, um ihn am Ende anzufügen.
publishEnglish PublishGerman Veröffentlichen
publishingEnglish Publishing…German Wird veröffentlicht…
publishedEnglish Published. The workflow runs from now on.German Veröffentlicht. Der Workflow läuft ab jetzt.
draftEnglish DraftGerman Entwurf
liveEnglish LiveGerman Live
stepsEnglish StepsGerman Schritte
addStepEnglish Add a step hereGerman Hier einen Schritt einfügen
addAfterEnglish Add a step after {name}German Schritt nach {name} einfügen
removeEnglish Remove {name}German {name} entfernen
closeEnglish CloseGerman Schließen
pickEnglish Pick a stepGerman Schritt wählen
endEnglish EndGerman Ende
setupEnglish SetupGerman Einrichtung
dataEnglish DataGerman Daten
testEnglish TestGerman Test
insertEnglish Insert {name}German {name} einfügen
variablesEnglish Data from earlier stepsGerman Daten aus früheren Schritten
variablesHintEnglish Click to insert at the cursor.German Klicken, um sie an der Schreibmarke einzufügen.
runTestEnglish Run this stepGerman Schritt ausführen
testingEnglish Running…German Läuft…
testResultEnglish ResultGerman Ergebnis
testOkEnglish The step ran in {ms} ms.German Der Schritt lief in {ms} ms.
saveEnglish Save stepGerman Schritt speichern
savedEnglish SavedGerman Gespeichert
runEnglish Run testGerman Test starten
runningEnglish Running…German Läuft…
replayEnglish Run againGerman Erneut ausführen
successEnglish SucceededGerman Erfolgreich
failedEnglish FailedGerman Fehlgeschlagen
skippedEnglish SkippedGerman Übersprungen
waitingEnglish WaitingGerman Wartet
totalEnglish {n} steps in {ms} msGerman {n} Schritte in {ms} ms
outputEnglish OutputGerman Ausgabe
pathEnglish Path takenGerman Genommener Weg
noRunEnglish 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
components/ui/workflow-builder.tsx
'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.

Often searched

Jump to

Copied to the clipboard