Builder canvas
A node canvas on the dotted grid with a palette, an inspector and a test run.
What it's for#
The builder canvas is where people design automation as a graph: workflows that act on records, and call flows that route a supplier's call. They add steps, wire them, test them against a sample and publish a version.
It is a full-bleed tool with three columns between the header and a run log: a palette of steps, the canvas, and an inspector for the selected step. The canvas is the only surface in Canon with a pattern, the dotted grid, so it reads as a workspace you can pan. Every change is a draft until Publish creates a version; a test run plays the graph against a sample record without sending anything.
| Route | What it builds |
|---|---|
| /workflows/w_inbound | A workflow: trigger, steps and branches, with a palette, inspector, test run and runs tab. |
| /workflows/w_handoff | Another workflow on the same editor, triggered by a stage change. |
| /ai/flows/fl_main_ivr | A call flow: Nora's main menu, with debug replay, preview and versions in a sheet. |
| /ai/flows/fl_after_hours | The after-hours call flow, with the small-screen strip under the header. |
Live composition#
Cedarline's invoice approval workflow at 1440px. Select a node, add a step from the palette, press Test run to watch the run play, open the version history, or switch to Runs.
import { Button } from "@oration/canon/components/button";import { FlowCanvas, FlowEdge, type FlowEdgeData, FlowNodeCard, type FlowRunState,} from "@oration/canon/components/flow";import { Popover, PopoverContent, PopoverTrigger } from "@oration/canon/components/popover";import { SegmentedControl } from "@oration/canon/components/segmented-control";import { StatusLabel } from "@oration/canon/components/status-dot";import { Switch } from "@oration/canon/components/switch";import { Tag } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import type { Edge, EdgeTypes, Node, NodeProps, NodeTypes } from "@xyflow/react";import { BellIcon, CalendarClockIcon, CheckIcon, ChevronDownIcon, CopyIcon, FileSearchIcon, FlaskConicalIcon, HistoryIcon, InboxIcon, LoaderCircleIcon, MailIcon, MonitorIcon, PanelLeftIcon, PlusIcon, SearchIcon, SplitIcon, Trash2Icon, UserCheckIcon, XIcon,} from "lucide-react";import * as React from "react";import { AppShell, CounterScale } from "../document-page/frame";export function WorkflowBuilder() { type StepData = { icon: React.ReactNode; category: string; title: string; description: string; handles: { id: string }[]; target: boolean; run?: FlowRunState; }; type StepNode = Node<StepData, "step">; const nodeTypes = React.useMemo<NodeTypes>( () => ({ step: ({ data, selected }: NodeProps<StepNode>) => ( <FlowNodeCard icon={data.icon} category={data.category} title={data.title} description={data.description} sourceHandles={data.handles} target={data.target} run={data.run} selected={selected} /> ), }), [], ); const edgeTypes = React.useMemo<EdgeTypes>( () => ({ default: FlowEdge }), [], ); const palette = [ { group: "Records", items: [ { kind: "Match PO", icon: <FileSearchIcon />, hint: "Three-way match", }, { kind: "Request approval", icon: <UserCheckIcon />, hint: "Ask a person", }, ], }, { group: "Outreach", items: [ { kind: "Email supplier", icon: <MailIcon />, hint: "From ap@cedarline.com", }, { kind: "Notify the team", icon: <BellIcon />, hint: "Slack or email", }, ], }, { group: "Logic", items: [ { kind: "Branch", icon: <SplitIcon />, hint: "Yes and no paths", }, { kind: "Schedule payment", icon: <CalendarClockIcon />, hint: "Next payment run", }, ], }, ]; const [steps, setSteps] = React.useState<StepNode[]>([ { id: "trigger", type: "step", position: { x: 0, y: 0 }, data: { icon: <InboxIcon />, category: "Trigger", title: "Invoice received", description: "From ap@cedarline.com", handles: [{ id: "out" }], target: false, }, }, { id: "match", type: "step", position: { x: 0, y: 130 }, data: { icon: <FileSearchIcon />, category: "Matching", title: "Match PO", description: "Three-way match against the PO", handles: [{ id: "out" }], target: true, }, }, { id: "branch", type: "step", position: { x: 0, y: 260 }, data: { icon: <SplitIcon />, category: "Branch", title: "Approve under $5k", description: "Amount is less than $5,000", handles: [{ id: "yes" }, { id: "no" }], target: true, }, }, { id: "schedule", type: "step", position: { x: -170, y: 410 }, data: { icon: <CalendarClockIcon />, category: "Payments", title: "Schedule payment", description: "Adds it to Friday's payment run", handles: [{ id: "out" }], target: true, }, }, { id: "review", type: "step", position: { x: 170, y: 410 }, data: { icon: <UserCheckIcon />, category: "Approval", title: "Request approval", description: "Asks Maya Okafor to approve it", handles: [{ id: "out" }], target: true, }, }, ]); const [links, setLinks] = React.useState<Edge<FlowEdgeData>[]>([ { id: "trigger-match", source: "trigger", target: "match", sourceHandle: "out", type: "default", }, { id: "match-branch", source: "match", target: "branch", sourceHandle: "out", type: "default", }, { id: "branch-schedule", source: "branch", target: "schedule", sourceHandle: "yes", type: "default", data: { label: "Yes" }, }, { id: "branch-review", source: "branch", target: "review", sourceHandle: "no", type: "default", data: { label: "No" }, }, ]); const [selected, setSelected] = React.useState<string | null>("branch"); const [paletteOpen, setPaletteOpen] = React.useState(true); const [mobilePalette, setMobilePalette] = React.useState(false); const [query, setQuery] = React.useState(""); const [runIndex, setRunIndex] = React.useState(-1); const [logOpen, setLogOpen] = React.useState(false); const [unpublished, setUnpublished] = React.useState(true); const [live, setLive] = React.useState(true); const [mode, setMode] = React.useState<"editor" | "runs">("editor"); // The path a test run with INV-20931 ($3,980.00) takes. const ids = steps.map((s) => s.id); const path = [ "trigger", "match", "branch", "schedule", ...ids.filter((id) => id.startsWith("step-")), ].filter((id) => ids.includes(id)); const running = runIndex >= 0 && runIndex < path.length; const finished = runIndex >= path.length; const runFor = (id: string): FlowRunState => { if (runIndex < 0) return "idle"; const index = path.indexOf(id); if (index === -1) return finished ? "skipped" : "queued"; if (index < runIndex) return "success"; return index === runIndex ? "running" : "queued"; }; React.useEffect(() => { if (!running) return; const id = window.setTimeout(() => { setRunIndex(runIndex + 1); if (runIndex + 1 === path.length) toast.add({ type: "success", title: "Test run passed", description: `${path.length} steps in ${(path.length * 0.6).toFixed(1)} s with INV-20931. Nothing was sent.`, }); }, 600); return () => window.clearTimeout(id); }, [running, runIndex, path.length]); const nodes = steps.map((step) => ({ ...step, selected: step.id === selected, ariaLabel: `${step.data.title}, ${runFor(step.id)}`, data: { ...step.data, run: runFor(step.id) }, })); const edges = links.map((edge) => ({ ...edge, data: { ...edge.data, active: running && path[runIndex] === edge.target, onInsert: running ? undefined : () => toast.add({ title: "Add a step here", description: "Pick a step from the palette to insert it on this edge.", }), }, })); const selectedStep = steps.find((s) => s.id === selected); const add = (kind: string, icon: React.ReactNode, hint: string) => { if (running) { toast.add({ title: "Hold on until the test run finishes", description: "Edits during a run would change what it's testing.", }); return; } const tail = path[path.length - 1] ?? "schedule"; const tailNode = steps.find((s) => s.id === tail); const id = `step-${steps.length}`; setSteps((list) => [ ...list, { id, type: "step", position: { x: tailNode?.position.x ?? 0, y: (tailNode?.position.y ?? 0) + 130, }, data: { icon, category: kind, title: kind, description: hint, handles: [{ id: "out" }], target: true, }, }, ]); setLinks((list) => [ ...list, { id: `${tail}-${id}`, source: tail, target: id, sourceHandle: "out", type: "default", }, ]); setSelected(id); setUnpublished(true); setMobilePalette(false); setRunIndex(-1); toast.add({ title: `Added ${kind}`, description: `Below ${tailNode?.data.title}.`, }); }; const remove = (id: string) => { const step = steps.find((s) => s.id === id); if (!step || id === "trigger") { toast.add({ title: "Every workflow needs a trigger", description: "Pick another from the palette to replace this one.", }); return; } const before = { steps, links }; setSteps((list) => list.filter((s) => s.id !== id)); setLinks((list) => list.filter((e) => e.source !== id && e.target !== id), ); setSelected(null); setUnpublished(true); toast.add({ title: `Deleted ${step.data.title}`, actionProps: { children: "Undo", onClick: () => { setSteps(before.steps); setLinks(before.links); }, }, }); }; const paletteBody = ( <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-3"> <label className="flex h-7 items-center gap-2 rounded-md bg-card px-2 text-13 shadow-border"> <SearchIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> <span className="sr-only">Search steps</span> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search steps" className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground" /> </label> <p className="text-xs text-pretty text-muted-foreground"> Click to add{" "} {selectedStep ? `below ${selectedStep.data.title}` : "at the end of the main path"} . </p> {palette.map((group) => { const items = group.items.filter((item) => item.kind.toLowerCase().includes(query.toLowerCase()), ); if (!items.length) return null; return ( <div key={group.group} className="flex flex-col gap-1"> <h3 className="px-1 text-xs font-medium text-muted-foreground"> {group.group} </h3> {items.map((item) => ( <button key={item.kind} type="button" onClick={() => add(item.kind, item.icon, item.hint) } className="flex items-center gap-2.5 rounded-lg px-1.5 py-1.5 text-left outline-none transition-colors duration-150 hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40" > <span className="flex size-6 shrink-0 items-center justify-center rounded-md bg-card text-foreground shadow-border [&>svg]:size-3.5"> {item.icon} </span> <span className="flex min-w-0 flex-col"> <span className="truncate text-13 font-medium"> {item.kind} </span> <span className="truncate text-xs text-muted-foreground"> {item.hint} </span> </span> </button> ))} </div> ); })} </div> ); const inspector = selectedStep ? ( <> <div className="flex items-start gap-2.5 border-b border-border p-4"> <span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-muted [&>svg]:size-4"> {selectedStep.data.icon} </span> <div className="min-w-0 flex-1"> <p className="text-xs text-muted-foreground"> {selectedStep.data.category} </p> <h2 className="truncate text-sm font-semibold"> {selectedStep.data.title} </h2> </div> <Button variant="ghost" size="icon-sm" aria-label="Close inspector" onClick={() => setSelected(null)} > <XIcon aria-hidden="true" /> </Button> </div> <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4 text-13"> <p className="text-muted-foreground"> {selectedStep.data.description} </p> {selectedStep.id === "branch" ? ( <div className="flex flex-col gap-1.5 rounded-[10px] bg-muted/70 p-3"> <span className="text-xs text-muted-foreground"> Goes Yes when all of these are true </span> <span className="rounded-md bg-background px-2 py-1 shadow-border"> Amount is less than $5,000 </span> <span className="rounded-md bg-background px-2 py-1 shadow-border"> Supplier W-9 is on file </span> </div> ) : null} <dl className="flex flex-col gap-2"> <div className="flex justify-between gap-3"> <dt className="text-muted-foreground">Last run</dt> <dd className="tabular-nums"> {runFor(selectedStep.id) === "idle" ? "Not run yet" : runFor(selectedStep.id)} </dd> </div> <div className="flex justify-between gap-3"> <dt className="text-muted-foreground"> Runs this week </dt> <dd className="tabular-nums">1,284</dd> </div> </dl> </div> <div className="flex items-center gap-1 border-t border-border p-2"> <Button variant="ghost" size="sm"> <CopyIcon data-icon="inline-start" aria-hidden="true" /> Duplicate </Button> <Button variant="ghost" size="sm" className="ml-auto text-destructive hover:text-destructive" onClick={() => remove(selectedStep.id)} > <Trash2Icon data-icon="inline-start" aria-hidden="true" /> Delete step </Button> </div> </> ) : ( <div className="flex flex-col gap-1 p-4"> <h2 className="text-sm font-semibold">Workflow settings</h2> <p className="text-13 text-muted-foreground"> Select a step on the canvas to edit it. </p> <dl className="mt-3 flex flex-col gap-2 text-13"> {[ ["Owner", "Maya Okafor"], ["Runs on", "Every new invoice"], ["On failure", "Notify AP and pause"], ].map(([label, value]) => ( <div key={label} className="flex justify-between gap-3"> <dt className="text-muted-foreground">{label}</dt> <dd>{value}</dd> </div> ))} </dl> </div> ); return ( <AppShell active="Workflows" crumbs={[{ label: "Workflows" }, { label: "Invoice approval" }]} actions={ <> <Popover> <PopoverTrigger render={<Button variant="ghost" size="sm" />} > <HistoryIcon data-icon="inline-start" aria-hidden="true" /> v4 </PopoverTrigger> <PopoverContent align="end" className="w-80 gap-0 p-1"> <p className="px-2 pt-1.5 pb-1 text-xs font-medium text-muted-foreground"> Version history </p> <ul> {[ [ "v4", "Maya Okafor", "Sep 26", "Raised the auto-approve limit to $5,000.", ], [ "v3", "Priya Raman", "Sep 12", "Added the W-9 check to the branch.", ], [ "v2", "Tomás Ferreira", "Aug 30", "Routed exceptions to Maya.", ], ].map( ([version, author, date, note], index) => ( <li key={version} className="flex items-start gap-2.5 rounded-md px-2 py-2 hover:bg-muted/60" > <div className="min-w-0 flex-1"> <p className="flex items-center gap-1.5 text-13"> <span className="font-medium tabular-nums"> {version} </span> <span className="text-muted-foreground"> by {author} {date} </span> </p> <p className="text-xs text-pretty text-muted-foreground"> {note} </p> </div> {index === 0 && !unpublished ? ( <Tag color="green" className="mt-0.5" > Current </Tag> ) : ( <Button variant="ghost" size="xs" onClick={() => { setUnpublished(true); toast.add({ title: `Restored ${version} as a draft`, description: "Publish to make it live.", actionProps: { children: "Undo", onClick: () => undefined, }, }); }} > Restore </Button> )} </li> ), )} </ul> </PopoverContent> </Popover> <Button variant="outline" size="sm" disabled={running} onClick={() => { setRunIndex(0); setLogOpen(true); setMode("editor"); }} > {running ? ( <LoaderCircleIcon data-icon="inline-start" aria-hidden="true" className="motion-safe:animate-spin" /> ) : ( <FlaskConicalIcon data-icon="inline-start" aria-hidden="true" /> )} {running ? "Running" : "Test run"} </Button> <Button size="sm" onClick={() => { setUnpublished(false); toast.add({ type: "success", title: "Published v5", description: "Version 5 is live for new invoices.", }); }} > Publish </Button> </> } > <section aria-label="Workflow editor" className="flex min-h-0 flex-1 flex-col" onKeyDown={(event) => { const target = event.target as HTMLElement; if (target.closest("input, textarea")) return; if (event.key === "Escape") setSelected(null); if ( (event.key === "Delete" || event.key === "Backspace") && selected ) { event.preventDefault(); remove(selected); } }} > <div className="flex h-11 shrink-0 items-center gap-2 border-b border-border px-3"> <Button variant="ghost" size="icon-sm" aria-label={paletteOpen ? "Hide steps" : "Show steps"} aria-pressed={paletteOpen} className="text-muted-foreground @max-[768px]:hidden" onClick={() => setPaletteOpen((open) => !open)} > <PanelLeftIcon aria-hidden="true" /> </Button> <SegmentedControl label="Mode" value={mode} onValueChange={setMode} options={[ { value: "editor", label: "Editor" }, { value: "runs", label: "Runs" }, ]} /> {unpublished ? ( <Tag color="amber" dot className="@max-[640px]:hidden"> Unpublished changes </Tag> ) : null} <span className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground @min-[768px]:hidden"> <MonitorIcon aria-hidden="true" className="size-3.5" /> Best on a larger screen </span> <Switch label="Live" size="sm" checked={live} onCheckedChange={setLive} className="@max-[768px]:hidden ml-auto" /> </div> <div className="relative flex min-h-0 flex-1"> <aside aria-label="Steps you can add" className={cn( "hidden w-60 shrink-0 flex-col border-r border-border bg-surface", paletteOpen && "@min-[768px]:flex", )} > {paletteBody} </aside> <main className="relative min-w-0 flex-1 bg-surface"> {mode === "editor" ? ( <CounterScale> <FlowCanvas<StepNode, Edge<FlowEdgeData>> key={steps.length} nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={edgeTypes} minimap={false} fitView fitViewOptions={{ padding: 0.15, maxZoom: 1, }} nodesDraggable={false} nodesConnectable={false} deleteKeyCode={null} zoomOnScroll={false} preventScrolling={false} onNodeClick={(_, node) => setSelected(node.id) } onPaneClick={() => setSelected(null)} aria-label="Workflow canvas" /> </CounterScale> ) : ( <div className="absolute inset-0 overflow-y-auto bg-background p-4"> <table className="w-full text-13"> <thead> <tr className="border-b border-border text-xs text-muted-foreground"> <th scope="col" className="h-8 text-left font-medium" > Run </th> <th scope="col" className="text-left font-medium" > Record </th> <th scope="col" className="text-left font-medium" > Status </th> <th scope="col" className="text-right font-medium" > Duration </th> </tr> </thead> <tbody> {[ [ "#1284", "INV-20957, Orchard Street", "Passed", "1.9 s", ], [ "#1283", "INV-20944, Northwind Freight", "Failed", "1.4 s", ], [ "#1282", "INV-20931, Halcyon", "Passed", "2.2 s", ], ].map( ([ run, record, status, duration, ]) => ( <tr key={run} className="border-b border-border" > <td className="h-9 tabular-nums"> {run} </td> <td>{record}</td> <td> <StatusLabel tone={ status === "Passed" ? "success" : "danger" } > {status} </StatusLabel> </td> <td className="text-right tabular-nums"> {duration} </td> </tr> ), )} </tbody> </table> </div> )} <Button variant="outline" size="sm" className="absolute top-3 left-3 z-10 @min-[768px]:hidden" onClick={() => setMobilePalette(true)} > <PlusIcon data-icon="inline-start" aria-hidden="true" /> Add step </Button> {mobilePalette ? ( <div className="absolute inset-0 z-30 @min-[768px]:hidden"> <button type="button" aria-label="Close steps" className="absolute inset-0 bg-black/25" onClick={() => setMobilePalette(false)} /> <div role="dialog" aria-label="Add a step" className="absolute inset-y-0 left-0 flex w-72 flex-col bg-popover shadow-lg" > <p className="border-b border-border px-4 py-3 text-base leading-none font-medium"> Add a step </p> {paletteBody} </div> </div> ) : null} {selectedStep ? ( <div className="absolute inset-y-0 right-0 z-20 flex w-80 max-w-full flex-col bg-popover shadow-lg @min-[1024px]:hidden"> {inspector} </div> ) : null} </main> <aside aria-label="Inspector" className="hidden w-80 shrink-0 flex-col border-l border-border bg-background @min-[1024px]:flex" > {inspector} </aside> </div> <section aria-label="Run log" className="shrink-0 border-t border-border bg-background" > <div className="flex h-10 items-center gap-3 px-3 text-13"> <button type="button" aria-expanded={logOpen} onClick={() => setLogOpen((open) => !open)} className="flex items-center gap-1.5 rounded-md px-1 font-medium outline-none focus-visible:ring-3 focus-visible:ring-ring/40" > <ChevronDownIcon aria-hidden="true" className={cn( "size-3.5 transition-transform duration-200", !logOpen && "-rotate-90", )} /> Test run </button> {running ? ( <StatusLabel tone="primary" pulse> Running </StatusLabel> ) : finished ? ( <StatusLabel tone="success"> Passed in {(path.length * 0.6).toFixed(1)} s </StatusLabel> ) : ( <span className="truncate text-muted-foreground"> Runs every step against a sample record without sending anything </span> )} <span className="ml-auto truncate text-xs text-muted-foreground @max-[640px]:hidden"> Sample record INV-20931, $3,980.00 </span> </div> {logOpen ? ( <ol aria-live="polite" className="max-h-40 overflow-y-auto border-t border-border" > {runIndex < 0 ? ( <li className="px-4 py-3 text-13 text-muted-foreground"> Press Test run. Each step shows up here with what went in and what came out. </li> ) : ( steps.map((step, index) => { const state = runFor(step.id); return ( <li key={step.id} className="flex h-9 items-center gap-2.5 border-b border-border px-4 text-13 last:border-b-0" > <span className="flex size-4 items-center justify-center"> {state === "success" ? ( <CheckIcon aria-hidden="true" className="size-3.5 text-success" /> ) : state === "running" ? ( <LoaderCircleIcon aria-hidden="true" className="size-3.5 text-primary motion-safe:animate-spin" /> ) : ( <span className="size-1.5 rounded-full bg-subtle-foreground" /> )} </span> <span className="w-4 text-xs text-muted-foreground tabular-nums"> {index + 1} </span> <span className="min-w-0 flex-1 truncate"> {step.data.title} </span> <span className="text-xs text-muted-foreground"> {state === "skipped" ? "Not on this path" : state === "success" ? "0.6 s" : state === "running" ? "Running" : "Queued"} </span> </li> ); }) )} </ol> ) : null} </section> </section> </AppShell> );}React Flow measures handles in screen pixels, so a canvas inside a scaled frame draws its edges in the wrong place. The frame here undoes its scale around the canvas and lets fitView zoom instead, which is why the nodes look larger than the chrome around them.
Anatomy#
Nine parts. The canvas pans and zooms; the palette, inspector and run log scroll on their own.
- App header. Crumbs with the name, a ghost Versions button showing the latest version, an outline Test run and the filled Publish.
- Toolbar. 44px: the palette toggle, Editor and Runs, an amber Unpublished changes tag with a dot, and the Live switch. Below 768px, the Best on a larger screen note.
- Palette. 15rem on Row Mist with a hairline right edge. A search, grouped steps with 24px tiles; click to add below the selection, or drag onto the canvas.
- Canvas. Flow's
FlowCanvas: 1.2px dots every 18px in Firm Hairline, zoom from 0.4 to 1.6, fit with 15% padding. - Node. 288px
FlowNodeCard: a 32px icon tile, category and run badge, title and description. Selected gets a 2px indigo ring; running, passed, failed and skipped have their own. - Edge. 1.5px smooth-step in Firm Hairline, bent at 10px, with label chips and a 20px insert button at the midpoint. During a run the active edge marches as a 1.75px dashed indigo line.
- Controls. Zoom controls bottom left, and an optional minimap bottom right, in 10px-corner hairline-lift boxes.
- Inspector. 20rem with a hairline left edge from 1024px: the selected step's title, settings and conditions, then Duplicate and Delete step. With nothing selected, the workflow's settings.
- Run log. A 40px bar with the run's status label and sample record, expanding to a 220px list of steps with timings, inputs and outputs.
Measurements#
From DESIGN.md's Workflow canvas section and the two editors.
| Part | Value | Notes |
|---|---|---|
| Toolbar | 44px | h-11 border-b px-3 |
| Palette | 15rem | w-60 border-r bg-surface |
| Inspector | 20rem; 22rem in call flows | w-80 or w-[22rem], border-l |
| Dot grid | 1.2px dots every 18px | Firm Hairline, --border-strong |
| Node | 288px, 12px corners | w-72 rounded-xl shadow-border |
| Icon tile | 32px, 10px corners | Well Gray |
| Handles | 10px | White, 1.5px Firm Hairline stroke |
| Edges | 1.5px; 1.75px active | Smooth-step, 10px bend, dashed 5 5 marching over 0.9s |
| Insert button | 20px round | At each edge's midpoint |
| Zoom | 0.4 to 1.6 | fitViewOptions={{ padding: 0.25, maxZoom: 1 }} by default |
| Run log | 40px bar, 220px open | h-10, height animates over 220ms |
| Test run pace | 600ms a step | Workflows; call flow replay is 900ms |
Responsive behavior#
The canvas never blocks a small screen. Below 768px it says it's best on a larger one and keeps working.
Below 768px the builder still works, with a note in the toolbar: Best on a larger screen. The palette opens from Add step, and the inspector slides over the canvas when you select a step. Nothing is blocked.
| Width | What changes |
|---|---|
| 390px | The toolbar shows Best on a larger screen. The palette opens as a left sheet from Add step; the inspector opens as a right sheet on selection. |
| 768px | The palette sits on the left. The inspector is still a sheet. |
| 1024px | The inspector sits on the right; all three columns show. |
| 1440px | The canvas takes the extra width. |
The two editors disagree. Call flows show a full-width strip under the header (The flow builder is best on a larger screen. You can still review and edit nodes here.), the procedure editor shows the short toolbar note used here, and the workflows editor shows neither and hides its palette below 1360px. Follow Responsive: a note, not a wall, below 768px.
States#
Run states belong to nodes and edges; everything else belongs to its pane.
| Region | States it owns |
|---|---|
| Canvas | Loading as a dotted ground with skeleton cards, Couldn't load this flow with Retry, and fading in once nodes are measured. |
| Nodes | Idle, selected (2px indigo), queued, running (2px indigo at 60% and a spinner), passed (1px green at 50%), failed (2px red at 60%) and skipped (60% opacity). Config problems show as a red footer, and while you drag from the palette the nearest node takes a light indigo ring as the drop target. |
| Edges | Resting, selected, and active during a run. Insert buttons hide while a run plays. |
| Palette | Search with no match: No steps match, in quotes. |
| Header | Unpublished changes, Not published yet, running (Test run disabled), and the version count. |
| Run log | Idle copy, running, passed in a time, failed with Edit step and Retry run, and a history banner when viewing an old run. |
Loading
The canvas loads as its own ground: the dotted grid with skeleton cards where nodes will be.
import { SkeletonCard } from "@oration/canon/components/skeletons";export function CanvasLoading() { return ( <div role="status" aria-busy="true" aria-label="Loading the workflow" className="relative flex h-80 w-full flex-col items-center gap-6 overflow-hidden rounded-xl bg-surface bg-[radial-gradient(var(--border-strong)_1.2px,transparent_1.2px)] bg-size-[18px_18px] pt-10 shadow-border" > <SkeletonCard lines={1} className="w-72" /> <SkeletonCard lines={2} className="w-72" /> <SkeletonCard lines={1} className="w-72" /> </div> );}Keyboard#
| Keys | Action |
|---|---|
| Tab | Move through nodes, then edges, insert buttons and controls. |
| Enter | Select the focused node or press the focused button. |
| Delete | Delete the selected step, with Undo. The trigger can't be deleted. |
| Esc | Clear the selection and close the inspector sheet. |
- Set
deleteKeyCode={null}and handle Delete yourself, so deletion goes through the same Undo toast as the inspector's button. - Name every node with
ariaLabelon the React Flow node, including its run state;FlowNodeCardtakes noaria-*props.
Starter code#
The workflows editor's frame. The palette, inspector and run panel are product components; the canvas is Canon's.
// components/workflows/workflow-editor.tsxexport function WorkflowEditorRoute({ id }: { id: string }) { const workflow = useWorkflowStore((s) => s.workflows.find((w) => w.id === id)); if (!workflow) return <MissingWorkflow />; return ( // Only needed because the editor calls useReactFlow() outside the canvas <ReactFlowProvider> <WorkflowEditor workflow={workflow} /> </ReactFlowProvider> );}function WorkflowEditor({ workflow }: { workflow: Workflow }) { return ( <div className="flex h-svh min-h-0 flex-col"> <AppHeader crumbs={[{ label: "Workflows", href: "/workflows" }, { label: workflow.name }]} actions={ <> <VersionsPopover /> <Button variant="outline" onClick={startRun}><FlaskConicalIcon data-icon="inline-start" />Test run</Button> <Button onClick={publish}>Publish</Button> </> } /> <div className="flex h-11 items-center gap-2 border-b border-border px-3"> <PaletteToggle /> <ViewTabs /> <UnpublishedTag /> <span className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground md:hidden"> <MonitorIcon className="size-3.5" aria-hidden="true" /> Best on a larger screen </span> <Switch label="Live" checked={live} onCheckedChange={setLive} /> </div> <div className="flex min-h-0 flex-1"> <aside aria-label="Steps you can add" className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface md:flex"> <Palette onAdd={addStep} /> </aside> <main className="relative min-w-0 flex-1 bg-surface"> <FlowCanvas nodes={nodes} edges={edges} nodeTypes={nodeTypes} edgeTypes={{ default: FlowEdge }} minimap={false} deleteKeyCode={null} panOnScroll aria-label="Workflow canvas" /> </main> <aside aria-label="Inspector" className="hidden w-80 shrink-0 flex-col border-l border-border bg-background lg:flex"> {selected ? <StepInspector node={selected} /> : <WorkflowSettingsPanel />} </aside> </div> <RunPanel session={session} /> </div> );}Rules that apply#
The Quiet Indigo Rule
The Hairline-and-Lift Rule
shadow-border), never from a CSS border plus a shadow. CSS borders are for structural dividers only: table rules, header bottoms, section splits.