Run timeline

The steps of one agent run as a vertical timeline: a connector that fills as steps finish, a live timer on the active step, and a waiting step that holds an approval.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/run-timeline

Once per app, add the registry to components.json:

components.json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json"
  }
}

Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/run-timeline.json

Adds components/pixilui/run-timeline.tsx. Needs motion, clsx, tailwind-merge.

Preview#

State
Mode
Approval
Theme
Accent

The connector fills down to the next step as each check draws itself, until the run stops on an approval card and the card folds into a receipt.

Code#

components/pixilui/run-timeline.tsx
"use client";

import { forwardRef, useEffect, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { instantTransition, motionTokens, reducedTransition } from "./motion-tokens";

const noSubscription = () => () => {};

/**
 * useReducedMotion, held at false until hydration finishes. The server cannot know the
 * preference, so reading it on the first client render would change the markup and break
 * hydration; the reduced branch takes over on the next render instead.
 */
function useReducedMotionAfterHydration() {
  const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
  const reduced = useReducedMotion();
  return hydrated && (reduced ?? false);
}

export type RunTimelineStatus = "pending" | "active" | "done" | "error" | "waiting";

export type RunTimelineStep = {
  /** Stable key for the step; onStepSelect receives it. */
  id: string;
  /** What the step does, in a few words: "Pull stuck refunds from Stripe". */
  title: ReactNode;
  /** Evidence once it ran ("9 refunds pending for more than 72 hours"), or why it failed. Hidden in compact mode. */
  detail?: ReactNode;
  /** Pending, active (live timer), done (drawn check), error, or waiting on a person. */
  status: RunTimelineStatus;
  /** When the step started (epoch ms). Drives the live timer and the duration. */
  startedAt?: number;
  /** When the step finished (epoch ms). With startedAt, shows how long it took. */
  endedAt?: number;
  /** Content under the step: an approval card on a waiting step, a tool call, a diff. Waiting steps show it in compact mode too. */
  children?: ReactNode;
};

export type RunTimelineLabels = Record<RunTimelineStatus, string>;

const defaultLabels: RunTimelineLabels = {
  pending: "Up next",
  active: "In progress",
  done: "Done",
  error: "Failed",
  waiting: "Needs approval",
};

export type RunTimelineProps = Omit<ComponentPropsWithoutRef<"ol">, "children"> & {
  /** The run's steps, in order. Update a step's status and the timeline animates the change. */
  steps: RunTimelineStep[];
  /** Titles only, tighter rhythm. Waiting steps still show their children. */
  compact?: boolean;
  /** Makes each step a button. Called with the step's id. */
  onStepSelect?: (id: string) => void;
  /** The step shown elsewhere (a detail pane), marked with data-selected. */
  selectedId?: string;
  /** Status words, read by screen readers and shown for waiting and failed steps. */
  labels?: Partial<RunTimelineLabels>;
};

const TICK_MS = 250;

/** "820ms", "1.4s", "1m 04s". */
function formatDuration(ms: number) {
  if (ms < 1000) return `${Math.round(ms)}ms`;
  if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
  const s = Math.round(ms / 1000);
  return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}

function formatLive(ms: number) {
  const s = Math.floor(ms / 1000);
  return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}

/** A live timer whose digits are keyed by place and roll up. Only this re-renders each tick. */
function LiveTimer({ startedAt, reduced }: { startedAt: number; reduced: boolean }) {
  const [now, setNow] = useState<number | null>(null);
  useEffect(() => {
    const tick = () => setNow(Date.now());
    tick();
    const id = setInterval(tick, TICK_MS);
    return () => clearInterval(id);
  }, [startedAt]);
  if (now === null) return null;
  const value = formatLive(Math.max(0, now - startedAt));
  const chars = value.split("");
  return (
    <span role="timer" aria-label={value} className="numeric inline-flex">
      {chars.map((char, index) => {
        const place = chars.length - index;
        if (reduced || !/\d/.test(char)) {
          return (
            <span key={place} aria-hidden="true">
              {char}
            </span>
          );
        }
        return (
          <span key={place} aria-hidden="true" className="relative inline-flex h-[1lh] overflow-hidden">
            <AnimatePresence initial={false} mode="popLayout">
              <motion.span key={char} initial={{ y: "100%" }} animate={{ y: 0 }} exit={{ y: "-100%" }} transition={motionTokens.spring.snappy}>
                {char}
              </motion.span>
            </AnimatePresence>
          </span>
        );
      })}
    </span>
  );
}

/** Glyph rings sit between the 1px hairline and a 2px border. */
const ring = { borderWidth: 1.5 };

function Mark({ status, reduced }: { status: RunTimelineStatus; reduced: boolean }) {
  const draw = reduced
    ? { initial: { pathLength: 1 }, transition: reducedTransition }
    : { initial: { pathLength: 0 }, transition: { duration: motionTokens.duration.slow, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * 2 } };
  switch (status) {
    case "pending":
      return <span className="size-2.5 rounded-pill border-solid border-border-strong" style={ring} />;
    case "active":
      return (
        <span className="grid size-5 place-items-center rounded-pill border-solid border-accent-solid" style={ring}>
          <span className="size-2 rounded-pill bg-accent-solid" />
        </span>
      );
    case "done":
      return (
        <span className="grid size-5 place-items-center rounded-pill bg-accent-solid text-accent-contrast">
          <svg viewBox="0 0 12 12" fill="none" className="size-3">
            <motion.path d="M2.75 6.25l2.25 2.25 4.25-4.75" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" initial={draw.initial} animate={{ pathLength: 1 }} transition={draw.transition} />
          </svg>
        </span>
      );
    case "error":
      return (
        <span className="grid size-5 place-items-center rounded-pill bg-danger-subtle text-danger">
          <svg viewBox="0 0 12 12" fill="none" className="size-3">
            <motion.path d="M3.75 3.75l4.5 4.5M8.25 3.75l-4.5 4.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" initial={draw.initial} animate={{ pathLength: 1 }} transition={draw.transition} />
          </svg>
        </span>
      );
    case "waiting":
      return (
        <span className="grid size-5 place-items-center rounded-pill bg-warning-subtle text-warning">
          <svg viewBox="0 0 12 12" fill="none" className="size-3">
            <path d="M4.5 3.5v5M7.5 3.5v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
          </svg>
        </span>
      );
  }
}

/**
 * Detail and children open their own height, so the steps below are pushed along in the flow
 * (no overlap while the content fades in). Growing uses morph, closing uses smooth. The clip is
 * lifted once it has opened, so an approval card's shadow and focus ring show in full.
 */
function Reveal({ reduced, className, children, ...rest }: { reduced: boolean; className?: string; children: ReactNode; "data-slot"?: string }) {
  const [clip, setClip] = useState(true);
  const motionProps = reduced
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0, height: 0, transition: instantTransition },
        transition: reducedTransition,
      }
    : {
        initial: { opacity: 0, height: 0, filter: `blur(${motionTokens.blur.subtle}px)` },
        animate: {
          opacity: 1,
          height: "auto",
          filter: "blur(0px)",
          transition: {
            height: motionTokens.spring.morph,
            default: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * 2 },
          },
        },
        exit: {
          opacity: 0,
          height: 0,
          filter: `blur(${motionTokens.blur.subtle}px)`,
          transition: { height: motionTokens.spring.smooth, default: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } },
        },
      };
  return (
    <motion.div
      {...rest}
      {...motionProps}
      className={className}
      style={{ overflow: clip && !reduced ? "hidden" : "visible" }}
      onAnimationStart={() => setClip(true)}
      onAnimationComplete={() => setClip(false)}
    >
      {children}
    </motion.div>
  );
}

export const RunTimeline = forwardRef<HTMLOListElement, RunTimelineProps>(function RunTimeline(
  { steps, compact = false, onStepSelect, selectedId, labels: labelsProp, className, onKeyDown, ...rest },
  ref,
) {
  const labels = { ...defaultLabels, ...labelsProp };
  const reduced = useReducedMotionAfterHydration();

  // Up and Down walk the step buttons, Home and End jump to the ends.
  const handleKeyDown = (event: KeyboardEvent<HTMLOListElement>) => {
    onKeyDown?.(event);
    if (event.defaultPrevented || !onStepSelect || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
    const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-slot="run-timeline-trigger"]'));
    const current = buttons.indexOf(event.target as HTMLElement);
    if (current === -1) return;
    event.preventDefault();
    const next =
      event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : Math.min(buttons.length - 1, Math.max(0, current + (event.key === "ArrowDown" ? 1 : -1)));
    buttons[next]?.focus();
  };

  const swapIn = reduced
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: instantTransition }, transition: instantTransition }
    : {
        initial: { opacity: 0, scale: motionTokens.scale.enterFrom, filter: `blur(${motionTokens.blur.subtle}px)` },
        animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
        exit: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } },
        transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.swap },
      };

  return (
    <ol
      ref={ref}
      data-compact={compact || undefined}
      className={cn("flex w-full min-w-0 flex-col font-body text-14 text-foreground", className)}
      onKeyDown={handleKeyDown}
      {...rest}
    >
      {steps.map((step, index) => {
        const last = index === steps.length - 1;
        const duration = step.startedAt !== undefined && step.endedAt !== undefined ? step.endedAt - step.startedAt : null;
        const showDetail = !compact && step.detail;
        const showChildren = step.children && (!compact || step.status === "waiting");
        const flagged = step.status === "waiting" || step.status === "error";

        const titleRow = (
          <>
            <span className={cn("min-w-0 flex-1 text-pretty font-medium transition-colors duration-fast ease-swap", step.status === "pending" ? "text-muted" : "text-foreground")}>
              {step.title}
            </span>
            <span className="flex shrink-0 items-center gap-2 pt-px text-12 text-muted">
              {flagged ? <span className={step.status === "error" ? "text-danger" : "text-warning"}>{labels[step.status]}</span> : null}
              {step.status === "active" && step.startedAt !== undefined ? <LiveTimer startedAt={step.startedAt} reduced={reduced} /> : null}
              {step.status === "done" && duration !== null ? <span className="numeric">{formatDuration(duration)}</span> : null}
            </span>
          </>
        );

        return (
          <li
            key={step.id}
            data-status={step.status}
            data-selected={selectedId === step.id || undefined}
            aria-current={step.status === "active" ? "step" : undefined}
            className="relative grid min-w-0 grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3"
          >
            {/* Rail: the glyph, then the connector to the next step, which fills when this one is done. */}
            <span aria-hidden="true" className="relative flex justify-center">
              <span className="relative mt-0.5 grid size-5 place-items-center">
                <AnimatePresence initial={false}>
                  <motion.span key={step.status} data-slot="run-timeline-glyph" className="absolute inset-0 grid place-items-center" {...swapIn}>
                    <Mark status={step.status} reduced={reduced} />
                  </motion.span>
                </AnimatePresence>
              </span>
              {last ? null : (
                <span className="absolute top-7 bottom-1 left-1/2 w-0.5 -translate-x-1/2 overflow-hidden rounded-pill bg-border">
                  <motion.span
                    data-slot="run-timeline-connector"
                    className="absolute inset-0 origin-top rounded-pill bg-accent-solid"
                    initial={false}
                    animate={{ scaleY: step.status === "done" ? 1 : 0 }}
                    transition={reduced ? instantTransition : { duration: motionTokens.duration.slow, ease: motionTokens.ease.linear }}
                  />
                </span>
              )}
            </span>

            <div className={cn("min-w-0", last ? "pb-0" : compact ? "pb-3" : "pb-6")}>
              {onStepSelect ? (
                <button
                  type="button"
                  data-slot="run-timeline-trigger"
                  aria-pressed={selectedId !== undefined ? selectedId === step.id : undefined}
                  onClick={() => onStepSelect(step.id)}
                  className={cn(
                    "-mx-2 -my-1 flex w-[calc(100%+1rem)] min-w-0 items-start gap-3 rounded-control px-2 py-1 text-left outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:focus-ring",
                    selectedId === step.id && "bg-surface-muted",
                  )}
                >
                  {titleRow}
                </button>
              ) : (
                <div className="flex min-w-0 items-start gap-3">{titleRow}</div>
              )}
              <span className="sr-only">{labels[step.status]}</span>

              <AnimatePresence initial={false}>
                {showDetail ? (
                  <Reveal key="detail" reduced={reduced}>
                    <div className={cn("pt-0.5 text-pretty", step.status === "error" ? "text-danger" : "text-secondary")}>{step.detail}</div>
                  </Reveal>
                ) : null}
              </AnimatePresence>
              <AnimatePresence initial={false}>
                {showChildren ? (
                  <Reveal key="children" data-slot="run-timeline-children" className="min-w-0" reduced={reduced}>
                    <div className="pt-3">{step.children}</div>
                  </Reveal>
                ) : null}
              </AnimatePresence>
            </div>
          </li>
        );
      })}
    </ol>
  );
});

Usage#

Use it when

  • Showing an agent run in progress, step by step, with the evidence of what each step did.
  • A run that pauses for a person: the waiting step hosts an approval card in place.
  • A run history view where each step can open details elsewhere (onStepSelect).

Use something else when

  • A flat list of tool calls inside one message: use tool-call-row with ToolCallRowGroup.
  • A quiet status line while a model thinks: use thinking-state.
  • A form wizard or onboarding progress: use a stepper.
Example
import { useEffect, useState, useSyncExternalStore } from "react";
import { useReducedMotion } from "motion/react";
import { Mail, RotateCcw } from "lucide-react";
import { Button } from "@/components/pixilui/button";
import { ApprovalCard, type ApprovalCardItem, type ApprovalCardStatus } from "@/components/pixilui/approval-card";
import { RunTimeline, type RunTimelineStatus, type RunTimelineStep } from "@/components/pixilui/run-timeline";

const noSubscription = () => () => {};

/**
 * useReducedMotion, held at false until hydration finishes. The server cannot know the
 * preference, so reading it on the first client render would change the markup and break
 * hydration; the reduced branch takes over on the next render instead.
 */
function useReducedMotionAfterHydration() {
  const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
  const reduced = useReducedMotion();
  return hydrated && (reduced ?? false);
}

// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
  state: ["play", "waiting", "error", "done"],
  mode: ["expanded", "compact"],
  approval: ["auto", "manual"],
} as const;

type StepCopy = { id: string; title: string; detail: string };

const STEPS: StepCopy[] = [
  { id: "pull", title: "Pull stuck refunds from Stripe", detail: "9 refunds pending for more than 72 hours" },
  { id: "match", title: "Match refunds to customer accounts", detail: "7 accounts, with 2 duplicate refunds merged" },
  { id: "draft", title: "Draft apology emails with a $15 credit", detail: "7 drafts in the support voice, 94 to 118 words each" },
  { id: "approve", title: "Get approval to send", detail: "Approved by Dana Whitfield" },
  { id: "post", title: "Post the summary to #support-ops", detail: "Posted with links to ENG-4127 and the 7 tickets" },
];

const ITEMS: ApprovalCardItem[] = [
  { label: "Recipients", value: "7 customers" },
  { label: "Credits", value: "$105.00", numeric: true },
  { label: "Sends from", value: "support@northwind.co" },
];

type StepState = { status: RunTimelineStatus; startedAt?: number; endedAt?: number };
type Frame = { steps: StepState[]; approval: ApprovalCardStatus };

// Fixed clock for the still states, so durations read the same on every render.
const T0 = 1_761_400_000_000;
const DURATIONS = [1140, 870, 2260, 4120, 930];
const ended = (index: number): StepState => {
  const start = T0 + DURATIONS.slice(0, index).reduce((a, b) => a + b, 0);
  return { status: "done", startedAt: start, endedAt: start + DURATIONS[index]! };
};

const STILL: Record<"waiting" | "error" | "done", Frame> = {
  waiting: { steps: [ended(0), ended(1), ended(2), { status: "waiting" }, { status: "pending" }], approval: "pending" },
  error: { steps: [ended(0), ended(1), { status: "error" }, { status: "pending" }, { status: "pending" }], approval: "pending" },
  done: { steps: STEPS.map((_, index) => ended(index)), approval: "approved" },
};

const START: Frame = { steps: STEPS.map(() => ({ status: "pending" })), approval: "pending" };
const STEP_MS = 900;

export default function RunTimelineDemo({ state = "play", mode = "expanded", approval = "auto" }: KnobValues<typeof knobs>) {
  const reduced = useReducedMotionAfterHydration();
  const [run, setRun] = useState(0);
  const [frame, setFrame] = useState<Frame>(START);
  const [finished, setFinished] = useState(false);

  const playing = state === "play" && !reduced;

  const combo = `${state}-${approval}-${reduced}`;
  const [seen, setSeen] = useState<string | null>(null);
  if (seen !== combo) {
    setSeen(combo);
    setFinished(false);
    setFrame(playing ? START : STILL[state === "play" ? "done" : state]);
  }

  const setStep = (index: number, next: StepState) => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? next : s)) }));

  // Steps 1 to 3 run at reading speed, then the run waits on a person.
  useEffect(() => {
    if (!playing) return;
    const begin = (index: number) => () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? { status: "active", startedAt: Date.now() } : s)) }));
    const finish = (index: number) => () =>
      setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? { ...s, status: "done", endedAt: Date.now() } : s)) }));
    const plan: [number, () => void][] = [
      [300, begin(0)],
      [300 + STEP_MS, finish(0)],
      [300 + STEP_MS, begin(1)],
      [300 + STEP_MS * 2, finish(1)],
      [300 + STEP_MS * 2, begin(2)],
      [300 + STEP_MS * 3, finish(2)],
      [300 + STEP_MS * 3, () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 3 ? { status: "waiting", startedAt: Date.now() } : s)) }))],
    ];
    if (approval === "auto") plan.push([300 + STEP_MS * 3 + 2000, () => setFrame((f) => ({ ...f, approval: "approved" }))]);
    const timers = plan.map(([at, fn]) => setTimeout(fn, at));
    return () => timers.forEach(clearTimeout);
  }, [playing, approval, run]);

  // Once approved: close the waiting step, then run the last one.
  const approved = frame.approval === "approved";
  const waitingOnApproval = frame.steps[3]!.status === "waiting";
  const lastStatus = frame.steps[4]!.status;
  useEffect(() => {
    if (!approved || !waitingOnApproval) return;
    const id = setTimeout(
      () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 3 ? { ...s, status: "done", endedAt: Date.now() } : i === 4 ? { status: "active", startedAt: Date.now() } : s)) })),
      500,
    );
    return () => clearTimeout(id);
  }, [approved, waitingOnApproval]);
  useEffect(() => {
    if (lastStatus === "active") {
      const id = setTimeout(() => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 4 ? { ...s, status: "done", endedAt: Date.now() } : s)) })), STEP_MS + 100);
      return () => clearTimeout(id);
    }
    if (lastStatus === "done") {
      const id = setTimeout(() => setFinished(true), STEP_MS);
      return () => clearTimeout(id);
    }
  }, [lastStatus]);

  const steps: RunTimelineStep[] = STEPS.map((copy, index) => {
    const s = frame.steps[index]!;
    const detail =
      s.status === "done" ? copy.detail : s.status === "error" ? "Template refund-apology-v2 was archived on Oct 21. Pick another and retry." : undefined;
    const card =
      copy.id === "approve" && (s.status === "waiting" || s.status === "done") ? (
        <ApprovalCard
          title="Send 7 apology emails and apply $105.00 in credits"
          icon={<Mail strokeWidth={1.5} />}
          source="Support agent, run 7f2e1c"
          items={ITEMS}
          risk="medium"
          status={frame.approval}
          receipt="7 emails queued, $105.00 in credits applied"
          labels={{ approve: "Approve and send" }}
          onApprove={async () => {
            await new Promise((resolve) => setTimeout(resolve, 700));
            setFrame((f) => ({ ...f, approval: "approved" }));
          }}
          onReject={async () => {
            await new Promise((resolve) => setTimeout(resolve, 500));
            setStep(3, { status: "error" });
          }}
        />
      ) : undefined;
    return { id: copy.id, title: copy.title, detail: copy.id === "approve" ? undefined : detail, status: s.status, startedAt: s.startedAt, endedAt: s.endedAt, children: card };
  });

  return (
    <div className="flex w-full min-w-0 max-w-lg flex-col gap-4">
      <div className="min-h-[30rem]">
        <RunTimeline aria-label="Refund run 7f2e1c" steps={steps} compact={mode === "compact"} />
      </div>
      <div className="flex h-control-sm justify-center">
        {state === "play" && !reduced && finished ? (
          <Button
            variant="ghost"
            size="sm"
            icon={<RotateCcw strokeWidth={1.5} />}
            onClick={() => {
              setFrame(START);
              setFinished(false);
              setRun((n) => n + 1);
            }}
          >
            Replay
          </Button>
        ) : null}
      </div>
    </div>
  );
}

API#

PropTypeDefaultDescription
stepsrequiredRunTimelineStep[]The run's steps, in order. Update a step's status and the timeline animates the change.
compactbooleanfalseTitles only, tighter rhythm. Waiting steps still show their children.
onStepSelect(id: string) => voidMakes each step a button. Called with the step's id.
selectedIdstringThe step shown elsewhere (a detail pane), marked with data-selected.
labelsPartial<RunTimelineLabels>Status words, read by screen readers and shown for waiting and failed steps.

Also takes every native <ol> attribute, and a ref to it.

Keyboard#

Tab
Moves through step buttons when onStepSelect is set, and into a waiting step's approval.
Enter
Selects the focused step.
ArrowDownArrowUp
Moves between step buttons when onStepSelect is set.
HomeEnd
Jumps to the first or last step button.

Accessibility#

  • An ordered list, so screen readers say how many steps there are and where each sits.
  • The active step has aria-current="step"; every step carries its status in text (Done, In progress, Up next, Failed, Needs approval).
  • Waiting and failed steps also show their status as visible words, not only as a colour or a glyph.
  • The live timer has role timer, so it is not announced every second.
  • With onStepSelect, steps are real buttons; selectedId sets aria-pressed.

Motion#

  • Status changes crossfade the glyph through a 2px blur in 250ms on the swap easing; the done check draws itself.
  • The connector under a finished step fills top to bottom in 400ms on linear easing, so progress reads segment by segment.
  • Detail and children open their own height on the morph spring and fade up, pushing the steps below instead of overlapping them.
  • The active step's timer digits roll up on the snappy spring.
  • Reduced motion: glyphs and connectors swap instantly, content fades in.

Responsive#

  • Fills its container; titles and details wrap, timings stay on the title line.
  • Compact mode drops details and tightens the rhythm for side panels; waiting steps keep their approval.

Performance#

  • Only the active step's timer re-renders each tick; the list re-renders when steps change.
  • Connectors animate transform only.

Notes for AI#

Also in the markdown page and llms.txt
  1. Keep one source of truth: map your run events to steps with id, title, status, startedAt and endedAt, and let the component animate the changes.
  2. Write detail as evidence in past tense once a step is done ("9 refunds pending for more than 72 hours"); on error, say what failed and what to do.
  3. Put an approval-card in the children of the waiting step; it stays visible in compact mode. Keep it there after approval so its receipt shows what was approved.
  4. Use onStepSelect with selectedId to open step details in a side pane.
  5. Statuses are pending, active, done, error and waiting; keep at most one active.