@formvue/react-workflow-kit
v0.2.0
Published
Headless React workflow/flow-builder kit: graph operations, React Flow canvas machinery, screen shells, form-field renderers and Puck block layer. Bring your own shadcn primitives, theme tokens, persistence and node-kind policy.
Readme
@formvue/react-workflow-kit
Headless React kit for building flow/workflow editors: a React Flow graph canvas, screen shells and renderers, a Puck block layer, and pure graph operations — extracted from FormVue's form builder and generified so any app (a form builder, a checkout-flow editor, …) can drive it with its own domain.
Headless means the kit brings behavior; the host brings:
- shadcn primitives — via the
@formvue/react-workflow-kit/uibundler alias - theme — all kit classes are semantic shadcn tokens (
bg-background, …) - persistence — a
FlowRepositoryimplementation (the kit never calls an API) - domain rules — a
NodeKindPolicydescribing its screen kinds - node visuals — a
NodeRegistrymapping kinds to React Flow components
Subpaths
| Import | Contents | Extra peers |
|---|---|---|
| @formvue/react-workflow-kit | ports (NodeKindPolicy, FlowRepository, NodeRegistry types), WorkflowKitProvider, error boundaries, createInMemoryFlowRepository, node geometry constants | — |
| …/graph | pure graph ops: validateGraph, insertScreenBetween, removeScreenFromGraph, computeGraphVariants, computeTraversalOrder, dagre layout, condition evaluator | — (no React) |
| …/schemas | question/decision screen zod schemas, field-type manifest, dynamic getFieldSchema/buildFormSchema answer validation | zod, libphonenumber-js |
| …/shell | ScreenShell/ContentShell/BaseShell, navigation footer + progress, navigation/config/preview/runtime contexts, PhoneFrame, SyncStatusIndicator | — |
| …/flow | useFlowGraph, FlowCanvas, node/edge chrome, canConnect, editor-mode machinery, keyboard shortcuts | @xyflow/react, @tanstack/react-store |
| …/renderers | question screen contents + form-field inputs on react-hook-form | react-hook-form, zod |
| …/blocks | Puck block catalog (createBuilderConfig), PuckEditor, PuckContent, schema versioning, block manifests | @puckeditor/core |
Peers are optional per subpath — a consumer using only /graph + /flow
installs neither Puck nor react-hook-form.
The ui adapter (required for /shell, /flow, /renderers, /blocks)
The kit imports primitives from @formvue/react-workflow-kit/ui, which stays
external in the build. Point it at your shadcn components:
// src/lib/workflow-kit-ui.ts — re-export YOUR shadcn primitives
export { Button } from "@revenue/ui/components/button";
export { Input } from "@revenue/ui/components/input";
// … every member of the contract (see ui-contract.d.ts) …
// Conformance check — fails the build if a primitive drifts:
import type * as Contract from "@formvue/react-workflow-kit/ui";// vite.config.ts (AND vitest.config.ts if separate)
resolve: {
alias: {
"@formvue/react-workflow-kit/ui": path.resolve(__dirname, "src/lib/workflow-kit-ui.ts"),
},
},TypeScript resolves …/ui to the published ui-contract.d.ts automatically —
no tsconfig changes needed. An un-aliased build fails loudly at bundle time.
Both known hosts run shadcn on Base UI (render prop, no Radix asChild);
the contract is written from that intersection.
CSS
/* your global stylesheet (Tailwind v4) */
@import "@formvue/react-workflow-kit/styles.css"; /* @source scan of kit files */
@import "@formvue/react-workflow-kit/flow-canvas.css"; /* React Flow fixes (with @xyflow/react/dist/style.css) */
@import "@formvue/react-workflow-kit/puck-theme.css"; /* Puck editor chrome (optional) */styles.css carries the @source directive relative to its own dist
location, so the consumer's Tailwind generates the kit's classes even under
pnpm symlinks. All classes are semantic shadcn tokens — your theme's
--background/--primary/… mapping styles the kit for free, dark mode
included.
WorkflowKitProvider (optional host services)
<WorkflowKitProvider
optimizeImageUrl={(url, { width }) => cdn(url, width)} // identity by default
onAnalyticsEvent={(e, p) => posthog.capture(e, p)} // noop by default
toast={{ success: toast.success, error: toast.error }} // noop by default
AddressInput={MyAddressAutocomplete} // /renderers address field (else plain input)
ConsentSection={MyLegalCopy} // ScreenShell entry-screen slot (else nothing)
renderMediaIcon={(name, cls) => <DynamicIcon name={name} className={cls} />}
AnimationPlayer={Lottie}
IconPicker={MyIconPicker} // /blocks icon field (else text input)
>Minimal canvas
const policy = defineNodeKindPolicy<CheckoutKind, CheckoutScreen>({
entryKinds: ["start"],
terminalKinds: ["confirmation"],
requiredKinds: ["start", "payment", "confirmation"],
singletonKinds: ["payment"],
trailingOrder: ["payment", "confirmation"], // derives the protected edge
restrictedTargets: { payment: ["confirmation"] },
});
const registry: NodeRegistry<CheckoutScreen> = {
nodeTypes: { checkout: CheckoutNode },
nodeTypeFor: () => "checkout",
labelFor: (s) => s.name ?? s.type,
heightFor: () => 80,
};
function Editor() {
const repository = useInMemoryFlowRepository(repo); // or your own FlowRepository
const flow = useFlowGraph({ repository, policy, registry });
return (
<ReactFlowProvider>
<FlowCanvas
nodes={flow.nodes} edges={flow.edges} nodeTypes={registry.nodeTypes}
onNodesChange={flow.onNodesChange} onEdgesChange={flow.onEdgesChange}
onConnect={flow.onConnect} onNodeClick={flow.onNodeClick}
onNodeDragStop={flow.onNodeDragStop}
isValidConnection={flow.isValidConnection}
fitViewOnReady={flow.initialLayoutDone}
/>
</ReactFlowProvider>
);
}See playground/ for the complete checkout-flow example.
FlowRepository
The persistence port. The kit reads screens/edges/entryScreenId/
syncStatus and calls the mutators; how they persist is yours — optimistic
cache updates, Convex mutations, TanStack DB (formvu-web's flow-db-context is
the canonical rich implementation of this interface).
createInMemoryFlowRepository()is the reference implementation (playground, tests, PoCs) withsubscribe/getSnapshotforuseSyncExternalStore(useInMemoryFlowRepositorywraps that).- The contract test suite lives in this repo at
src/test-utils/repository-contract.ts(not shipped in the npm package) — copy it next to your implementation and run it to prove conformance.
SSR (TanStack Start / Cloudflare Workers)
The canvas must never execute during SSR. Double indirection is mandatory — anything statically imported by a route file lands in the SSR bundle:
// route file
const Canvas = React.lazy(() => import("#/components/checkout-flow/canvas"));
// render
<ClientOnly fallback={<Skeleton />}>
<Suspense><Canvas /></Suspense>
</ClientOnly>@xyflow/react and dagre then only load in the browser.
Development
pnpm dev # playground (vite) — aliases /ui to playground/src/ui
pnpm check # biome
pnpm typecheck
pnpm test # vitest
pnpm build # tsc gate + vite lib build + per-entry d.ts + css copyReleases via changesets: PRs snapshot-publish as @formvue/react-workflow-kit@pr-<N>;
merging the "chore: version packages" PR publishes to npm.
