Redesign flow editor UI to match Flow Builder design

Reworks the flow editor (/flows/[id]) to the Flow Builder design
handoff, adapted to wacrm's existing tokens, components, and
light+dark theming. Keeps all behaviour — React Flow canvas,
drag-to-connect, typed config forms, validation, save/activate/delete,
per-browser view persistence, mobile-forces-list.

- shared.tsx: per-node-type color system (nodeColors) + one-line
  blurbs, and a shared NodeIconChip component. Label color blends
  toward --foreground via color-mix so it stays readable in both modes.
- header.tsx: compact single-row toolbar (back / icon / inline name /
  status chip / edited dot · Runs / Delete / Activate / Save) plus a
  subtle inline description note.
- flow-editor-shell.tsx: app-like full-height layout (toolbar → mode
  row → stage → validation bar) with a segmented Canvas/List control
  and a complete node-type legend derived from NODE_META.
- flow-canvas.tsx: per-type colored node cards, branch ports, dot-grid
  background, rounded controls/minimap, top-left primary Add-node menu,
  colored inspector header.
- flow-builder.tsx: list rows tied to the same per-type color language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-26 16:53:03 +03:00
parent b72e7a6e62
commit 1f4d1a3a11
No known key found for this signature in database
5 changed files with 378 additions and 114 deletions

View File

@ -46,6 +46,8 @@ import { cn } from "@/lib/utils";
import { type ValidationIssue } from "@/lib/flows/validate";
import {
NODE_META,
NodeIconChip,
nodeColors,
slugify,
summarizeNode,
type BuilderNode,
@ -150,7 +152,7 @@ export function FlowBuilder() {
}, [flashKey]);
return (
<div className="flex flex-col gap-6">
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-7">
<TriggerPanel
state={state}
setState={setState}
@ -390,13 +392,14 @@ function NodeCard({
onSetEntry: () => void;
}) {
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
const hasError = issues.some((i) => i.severity === "error");
const preview = summarizeNode(node);
return (
<div
ref={cardRef}
className={cn(
"rounded-lg border bg-card transition-shadow duration-500",
"relative overflow-hidden rounded-xl border bg-card transition-shadow duration-500",
hasError
? "border-red-500/40"
: isEntry
@ -406,15 +409,23 @@ function NodeCard({
"ring-2 ring-primary ring-offset-2 ring-offset-background",
)}
>
{/* type-colored left rail, ties the list row to the canvas hue */}
<span
className="absolute inset-y-0 left-0 w-[3px]"
style={{ background: c.solid }}
/>
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
className="flex w-full items-center gap-3 px-4 py-3 pl-5 text-left"
>
<meta.icon className={cn("h-4 w-4 shrink-0", meta.color)} />
<NodeIconChip type={node.node_type} size={32} iconSize={16} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
<span
className="truncate text-[11px] font-semibold uppercase tracking-wider"
style={{ color: c.text }}
>
{meta.label}
</span>
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">

View File

@ -40,6 +40,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
applyNodeChanges,
Background,
BackgroundVariant,
Controls,
Handle,
MiniMap,
@ -76,6 +77,8 @@ import {
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
import {
NODE_META,
NodeIconChip,
nodeColors,
summarizeNode,
type BuilderNode,
type NodeType,
@ -109,11 +112,26 @@ const NODE_HEIGHT = 90;
// view's collapsed card so the two views feel like the same product.
// ============================================================
// Echo the design's green/red branch ports for the condition node's
// true / false slots; every other slot inherits the node's own hue.
// The true/false hues are derived from the start (emerald) and handoff
// (rose) node colors so all branch/port colors stay single-sourced in
// NODE_HUE — a palette tweak there can't leave these stale.
function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
if (nodeType === "condition" && slotId === "true") {
return nodeColors("start").solid;
}
if (nodeType === "condition" && slotId === "false") {
return nodeColors("handoff").solid;
}
return fallback;
}
function FlowNodeCard({ data, selected }: NodeProps) {
const { node, isEntry, isFlashed } = data as NodeData;
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
const summary = summarizeNode(node);
const Icon = meta.icon;
const slots = outgoingSlots(node);
// Start nodes are entry-only; nothing ever targets them, so they
// don't need an incoming Handle. Every other node type accepts
@ -127,11 +145,23 @@ function FlowNodeCard({ data, selected }: NodeProps) {
const isMultiSlot = slots.length > 1;
return (
<div
style={
{
"--nc": c.solid,
"--nc-soft": c.soft,
"--nc-ring": c.ring,
"--nc-text": c.text,
borderColor: selected ? c.solid : undefined,
boxShadow: selected
? `0 0 0 1px ${c.solid}, 0 14px 36px -12px ${c.ring}`
: undefined,
} as React.CSSProperties
}
className={cn(
"relative min-w-[220px] max-w-[260px] rounded-lg border bg-card/95 px-3 py-2 text-left shadow-lg backdrop-blur transition-colors",
"relative min-w-[220px] max-w-[260px] rounded-xl border bg-card px-3.5 py-3 text-left shadow-[0_2px_6px_rgba(0,0,0,0.18)] transition-[box-shadow,border-color]",
selected
? "border-primary ring-1 ring-primary/40"
: "border-border hover:border-border",
? "border-[var(--nc)]"
: "border-border hover:border-[var(--nc-ring)]",
// Flash overrides hover/selected colors briefly. Tailwind's
// built-in `animate-pulse` is too gentle; a ring with the
// amber accent matches the list view's flash semantics.
@ -142,32 +172,40 @@ function FlowNodeCard({ data, selected }: NodeProps) {
<Handle
type="target"
position={Position.Left}
className="!h-2.5 !w-2.5 !border-border !bg-muted"
className="!h-2.5 !w-2.5 !border-2 !border-[var(--nc-ring)] !bg-card"
/>
)}
<div className="flex items-center gap-2">
<Icon className={cn("h-3.5 w-3.5 shrink-0", meta.color)} />
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<NodeIconChip
type={node.node_type}
size={24}
iconSize={14}
className="rounded-md"
/>
<span
className="truncate text-[10.5px] font-semibold uppercase tracking-wider"
style={{ color: c.text }}
>
{meta.label}
</span>
{isEntry && (
<span className="ml-auto rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300">
<span className="ml-auto rounded border border-border px-1.5 py-0.5 text-[8.5px] font-bold uppercase tracking-[0.1em] text-muted-foreground">
Entry
</span>
)}
</div>
<div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
<div className="mt-2 truncate font-mono text-[11px] text-muted-foreground">
{node.node_key}
</div>
{summary && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
<div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{summary}
</div>
)}
{isMultiSlot && (
<div className="mt-2 flex flex-col gap-1 border-t border-border pt-2">
<div className="mt-2.5 flex flex-col gap-1 border-t border-border pt-2.5">
{slots.map((slot) => (
<div
key={slot.id}
@ -180,11 +218,14 @@ function FlowNodeCard({ data, selected }: NodeProps) {
type="source"
id={slot.id}
position={Position.Right}
style={{
borderColor: slotColor(node.node_type, slot.id, c.solid),
}}
// Override default absolute positioning so the handle
// sits flush with the right edge of the card instead
// of floating at vertical center. The negative offset
// matches the card's px-3 + the handle's own radius.
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[12px] !transform-none !border-border !bg-muted"
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[14px] !transform-none !border-2 !bg-card"
/>
</div>
))}
@ -196,7 +237,8 @@ function FlowNodeCard({ data, selected }: NodeProps) {
type="source"
id={slots[0].id}
position={Position.Right}
className="!h-2.5 !w-2.5 !border-border !bg-muted"
style={{ borderColor: c.solid }}
className="!h-2.5 !w-2.5 !border-2 !bg-card"
/>
)}
</div>
@ -457,7 +499,7 @@ function FlowCanvasInner() {
if (rfNodes.length === 0) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-background text-sm text-muted-foreground">
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<p>No nodes yet.</p>
<CanvasAddNodeButton />
</div>
@ -466,7 +508,7 @@ function FlowCanvasInner() {
return (
<>
<div className="h-[70vh] w-full overflow-hidden rounded-lg border border-border bg-background">
<div className="h-full w-full overflow-hidden">
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
@ -492,19 +534,29 @@ function FlowCanvasInner() {
minZoom={0.2}
maxZoom={1.5}
>
<Background gap={24} size={1} color="var(--border)" />
{/* Dot grid, matching the design's faint canvas backdrop. */}
<Background
variant={BackgroundVariant.Dots}
gap={22}
size={1.4}
color="var(--border)"
/>
<Controls
className="!border-border !bg-card [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground"
className="!overflow-hidden !rounded-xl !border !border-border !bg-card !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground"
showInteractive={false}
/>
<MiniMap
pannable
zoomable
nodeColor="var(--muted-foreground)"
nodeColor={(n) =>
nodeColors((n.data as NodeData).node.node_type).solid
}
nodeStrokeWidth={0}
nodeBorderRadius={3}
maskColor="color-mix(in oklch, var(--background) 70%, transparent)"
className="!border !border-border !bg-card"
className="!rounded-xl !border !border-border !bg-card !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
/>
<Panel position="bottom-right" className="!bottom-4 !right-4">
<Panel position="top-left" className="!left-4 !top-4">
<CanvasAddNodeButton />
</Panel>
</ReactFlow>
@ -557,26 +609,31 @@ function NodeEditSheet({
);
}
const meta = NODE_META[node.node_type];
const Icon = meta.icon;
const c = nodeColors(node.node_type);
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 border-l border-border bg-popover p-0 sm:max-w-md"
>
<SheetHeader className="border-b border-border px-5 py-4">
<SheetTitle className="flex items-center gap-2 text-popover-foreground">
<Icon className={cn("h-4 w-4 shrink-0", meta.color)} />
<span>{meta.label}</span>
<SheetHeader className="flex-row items-center gap-3 space-y-0 border-b border-border px-5 py-4">
<NodeIconChip type={node.node_type} size={36} iconSize={18} />
<div className="min-w-0 flex-1">
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider">
<span style={{ color: c.text }}>{meta.label}</span>
{isEntry && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300">
Entry
</span>
)}
</SheetTitle>
<SheetDescription className="font-mono text-[11px] text-muted-foreground">
{node.node_key}
<SheetDescription className="mt-0.5 text-xs text-muted-foreground">
{meta.blurb}
</SheetDescription>
</div>
<code className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
{node.node_key}
</code>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-5 py-4">
@ -658,20 +715,36 @@ function CanvasAddNodeButton() {
return (
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-lg transition-colors hover:bg-muted"
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3.5 py-2 text-[13px] font-medium text-primary-foreground shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors hover:bg-primary-hover"
aria-label="Add node"
>
<Plus className="h-3.5 w-3.5" />
<Plus className="h-4 w-4" />
Add node
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-border bg-popover">
<DropdownMenuContent
align="start"
className="w-[268px] border-border bg-popover p-1.5"
>
<div className="px-2 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
Add a step
</div>
{ADD_NODE_TYPES.map((t) => {
const meta = NODE_META[t];
const Icon = meta.icon;
return (
<DropdownMenuItem key={t} onClick={() => handleAdd(t)}>
<Icon className={cn("h-3.5 w-3.5", meta.color)} />
<DropdownMenuItem
key={t}
onClick={() => handleAdd(t)}
className="gap-3 py-2"
>
<NodeIconChip type={t} size={28} iconSize={16} className="rounded-md" />
<span className="flex flex-col">
<span className="text-[13px] font-semibold text-popover-foreground">
{meta.label}
</span>
<span className="text-[11.5px] text-muted-foreground">
{meta.blurb}
</span>
</span>
</DropdownMenuItem>
);
})}

View File

@ -1,11 +1,19 @@
"use client";
/**
* View-switcher for the flow editor.
* View-switcher + chrome for the flow editor.
*
* Renders a small Canvas / List pill above whichever view is active,
* and conditionally mounts `<FlowCanvas>` or `<FlowBuilder>`. Why a
* separate component:
* Lays the editor out as one app-like column that fills the dashboard
* content area (toolbar → mode row → stage → validation bar), matching
* the Flow Builder design handoff:
* - A segmented Canvas / List control on the left of the mode row.
* - A node-type legend on the right so the canvas's per-type colors
* are decodable at a glance.
* - The active view is mounted inside a rounded "stage" that owns its
* own scroll/overflow, so the canvas can fill available height and
* the list scrolls internally.
*
* Why a separate component:
* - The page itself stays trivially small (loading + error + this).
* - Either view can stay unaware of the other — they share data
* (`{flow, nodes}`) and nothing else.
@ -17,13 +25,14 @@
*/
import { useEffect, useState } from "react";
import { LayoutGrid, ListTree } from "lucide-react";
import { GitFork, List } from "lucide-react";
import { FlowBuilder } from "./flow-builder";
import { FlowCanvas } from "./flow-canvas";
import { FlowEditorProvider } from "./flow-editor-state";
import { EditorHeader } from "./header";
import { ValidationPanel } from "./validation-panel";
import { NODE_META, nodeColors, type NodeType } from "./shared";
import { cn } from "@/lib/utils";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
@ -39,6 +48,11 @@ type View = "canvas" | "list";
const STORAGE_KEY = "wacrm.flowEditor.view";
// Legend covers every node type, derived from NODE_META so a new type
// can't silently go undocumented. NODE_META's key order already reads
// the way a flow flows: start → talk → capture → branch → mutate → end.
const LEGEND_TYPES = Object.keys(NODE_META) as NodeType[];
interface Props {
initialFlow: FlowRow;
initialNodes: FlowNodeRow[];
@ -78,37 +92,63 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
return (
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}>
<div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6">
<div className="flex h-full min-h-0 flex-col">
<EditorHeader />
{/* ---- mode row: view toggle + node-type legend ----
Omitted entirely on mobile (canvas is unavailable there and
the legend is lg-only), so there's no empty band above the
stage on small screens. */}
{!isMobile && (
<div className="flex items-center justify-end">
<div className="flex items-center gap-4 px-6 py-3.5">
<div
role="group"
aria-label="Editor view"
className="inline-flex items-center gap-1 rounded-md border border-border bg-card p-0.5 text-xs"
className="inline-flex gap-0.5 rounded-lg border border-border bg-muted p-0.5"
>
<ToggleButton
<SegButton
active={effectiveView === "canvas"}
onClick={() => choose("canvas")}
icon={<LayoutGrid className="h-3 w-3" />}
icon={<GitFork className="h-3.5 w-3.5" />}
label="Canvas"
/>
<ToggleButton
<SegButton
active={effectiveView === "list"}
onClick={() => choose("list")}
icon={<ListTree className="h-3 w-3" />}
icon={<List className="h-3.5 w-3.5" />}
label="List"
/>
</div>
<div className="ml-auto hidden flex-wrap items-center gap-x-3.5 gap-y-1.5 lg:flex">
{LEGEND_TYPES.map((t) => (
<span
key={t}
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ background: nodeColors(t).solid }}
/>
{NODE_META[t].label}
</span>
))}
</div>
</div>
)}
{effectiveView === "canvas" ? <FlowCanvas /> : <FlowBuilder />}
{/* ---- stage: the active view, owning its own overflow ---- */}
<div className="relative mx-6 min-h-0 flex-1 overflow-hidden rounded-xl border border-border bg-card-2">
{effectiveView === "canvas" ? (
<FlowCanvas />
) : (
<div className="absolute inset-0 overflow-y-auto">
<FlowBuilder />
</div>
)}
</div>
{/* Sticky-bottom validation panel mirrors the placement used
when this lived inside FlowBuilder — the activate-readiness
status follows the user as they scroll, in either view. */}
<div className="sticky bottom-4 z-10 shadow-xl shadow-background/60">
{/* ---- validation / activate-readiness bar ---- */}
<div className="px-6 pb-5 pt-3">
<ValidationPanel />
</div>
</div>
@ -138,7 +178,7 @@ function useMatchMedia(query: string): boolean {
return matches;
}
function ToggleButton({
function SegButton({
active,
onClick,
icon,
@ -155,10 +195,10 @@ function ToggleButton({
onClick={onClick}
aria-pressed={active}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2 py-1 transition-colors",
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[12.5px] font-medium transition-colors",
active
? "bg-secondary text-secondary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{icon}

View File

@ -1,11 +1,18 @@
"use client";
/**
* Editor header — flow name / description, status badge, dirty
* Editor toolbar — flow name / description, status chip, dirty
* indicator, and the action buttons (Save, Activate/Pause, Delete,
* View runs, Back).
*
* Lifted out of flow-builder.tsx so the same header renders above
* Restyled to the Flow Builder design handoff: a single compact
* toolbar row (back · icon · inline-editable name · status chip ·
* edited dot on the left; Runs · Delete · Activate · Save on the
* right) followed by a subtle, full-width description "note" line.
* Replaces the old three-row stack so the editor reads as one app
* chrome bar above the canvas/list stage.
*
* Lifted out of flow-builder.tsx so the same toolbar renders above
* both views in FlowEditorShell. Without this, canvas users had no
* way to save without toggling to list view.
*
@ -18,6 +25,7 @@
import { useRouter } from "next/navigation";
import {
ArrowLeft,
CircleDot,
History,
Loader2,
PauseCircle,
@ -27,9 +35,7 @@ import {
Workflow,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
useFlowEditor,
@ -52,32 +58,33 @@ export function EditorHeader() {
} = useFlowEditor();
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex flex-col gap-1.5 px-6 pt-5">
<div className="flex flex-wrap items-center gap-3">
{/* ---- left: back · icon · name · status · edited ---- */}
<button
type="button"
onClick={() => router.push("/flows")}
className="inline-flex items-center gap-1 hover:text-foreground"
title="Back to Flows"
aria-label="Back to Flows"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
Flows
<ArrowLeft className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Workflow className="h-5 w-5 shrink-0 text-primary" />
<Input
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-soft text-primary">
<Workflow className="h-[18px] w-[18px]" />
</span>
<input
value={state.name}
onChange={(e) =>
setState((s) => ({ ...s, name: e.target.value }))
}
onChange={(e) => setState((s) => ({ ...s, name: e.target.value }))}
placeholder="Flow name"
className="max-w-md bg-card text-lg font-semibold"
spellCheck={false}
aria-label="Flow name"
className="min-w-[120px] max-w-[340px] rounded-lg border border-transparent bg-transparent px-2 py-1 text-lg font-bold leading-tight tracking-tight text-foreground outline-none transition-colors hover:bg-muted focus:border-primary focus:bg-transparent focus:shadow-[0_0_0_3px_var(--primary-soft)]"
/>
<StatusBadge status={state.status} />
<StatusChip status={state.status} />
{dirty && (
<span
className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-amber-300"
className="inline-flex shrink-0 items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
title="Unsaved changes — hit Save to persist"
aria-live="polite"
>
@ -85,8 +92,9 @@ export function EditorHeader() {
Edited
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{/* ---- right: runs · delete · activate · save ---- */}
<div className="ml-auto flex flex-wrap items-center gap-1.5">
<Button
variant="ghost"
size="sm"
@ -94,6 +102,9 @@ export function EditorHeader() {
>
<History className="h-3.5 w-3.5" />
Runs
<span className="ml-0.5 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
{flow.execution_count}
</span>
</Button>
<Button
variant="ghost"
@ -148,27 +159,47 @@ export function EditorHeader() {
</Button>
</div>
</div>
<Input
{/* ---- description note (subtle, inline-editable) ---- */}
<input
value={state.description}
onChange={(e) =>
setState((s) => ({ ...s, description: e.target.value }))
}
placeholder="Optional description (internal — customers don't see this)"
className="bg-card text-sm"
placeholder="Add a short description (internal — customers don't see this)"
aria-label="Flow description"
className="w-full max-w-[78ch] rounded-md border border-transparent bg-transparent px-2 py-1 text-[13px] text-muted-foreground outline-none transition-colors placeholder:text-muted-foreground/60 hover:bg-muted/50 focus:border-primary focus:bg-transparent focus:text-foreground"
/>
</div>
);
}
function StatusBadge({ status }: { status: BuilderState["status"] }) {
const cls = {
draft: "border-border bg-muted text-muted-foreground",
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
archived: "border-border bg-muted/50 text-muted-foreground",
function StatusChip({ status }: { status: BuilderState["status"] }) {
const cfg = {
draft: {
// Neutral, not amber — amber is reserved for the adjacent
// "Edited" dirty signal, so the two don't read as the same alert.
cls: "border-border bg-muted text-muted-foreground",
label: "Draft",
},
active: {
cls: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
label: "Active",
},
archived: {
cls: "border-border bg-muted/50 text-muted-foreground",
label: "Archived",
},
}[status];
return (
<Badge variant="outline" className={cn("shrink-0", cls)}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</Badge>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11.5px] font-medium",
cfg.cls,
)}
>
<CircleDot className="h-3 w-3" />
{cfg.label}
</span>
);
}

View File

@ -30,6 +30,8 @@ import {
Workflow,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ============================================================
// Node-type union — single source of truth for every place the UI
// enumerates types (add menu, type pickers, switch statements). Kept
@ -67,52 +69,159 @@ export interface BuilderNode {
export const NODE_META: Record<
NodeType,
{ label: string; icon: typeof Workflow; color: string }
{ label: string; icon: typeof Workflow; color: string; blurb: string }
> = {
start: { label: "Start", icon: PlayCircle, color: "text-emerald-400" },
start: {
label: "Start",
icon: PlayCircle,
color: "text-emerald-400",
blurb: "Entry point of the flow",
},
send_message: {
label: "Send message",
icon: MessageCircle,
color: "text-sky-400",
blurb: "Sends a WhatsApp text message",
},
send_buttons: {
label: "Send buttons",
icon: ListChecks,
color: "text-primary",
blurb: "Sends quick-reply buttons",
},
send_list: {
label: "Send list",
icon: ListPlus,
color: "text-indigo-400",
blurb: "Sends a tappable list of options",
},
send_media: {
label: "Send media",
icon: Paperclip,
color: "text-cyan-400",
blurb: "Sends an image, video, or document",
},
collect_input: {
label: "Collect input",
icon: Inbox,
color: "text-teal-400",
blurb: "Asks a question, saves the reply",
},
condition: {
label: "If / else",
icon: GitFork,
color: "text-fuchsia-400",
blurb: "Branches on a rule",
},
set_tag: {
label: "Tag contact",
icon: Tag,
color: "text-pink-400",
blurb: "Adds or removes a contact tag",
},
handoff: {
label: "Handoff to agent",
icon: UserPlus,
color: "text-amber-400",
blurb: "Hands the conversation to a human",
},
end: {
label: "End",
icon: Flag,
color: "text-muted-foreground",
blurb: "Ends the flow",
},
end: { label: "End", icon: Flag, color: "text-muted-foreground" },
};
// ============================================================
// Per-node-type color system.
//
// Each node type gets its own hue so the canvas reads at a glance —
// what KIND of step is this. Kept as raw oklch (not Tailwind classes)
// so a node card can tint its icon chip, type label, selection ring,
// and edge ports from one source, the way the Flow Builder design
// handoff does. Hues sit in the same oklch family as the app tokens
// in globals.css; they don't replace --primary (the accent), they
// complement it. `nodeColors()` derives the soft/ring/text variants.
// ============================================================
const NODE_HUE: Record<NodeType, { l: number; c: number; h: number }> = {
start: { l: 0.62, c: 0.13, h: 162 }, // emerald — the start, echoes WhatsApp green
send_message: { l: 0.6, c: 0.18, h: 293 }, // violet — the workhorse
send_buttons: { l: 0.62, c: 0.16, h: 254 }, // cobalt
send_list: { l: 0.62, c: 0.15, h: 277 }, // indigo
send_media: { l: 0.65, c: 0.12, h: 210 }, // sky
collect_input: { l: 0.65, c: 0.1, h: 185 }, // teal — capture
condition: { l: 0.72, c: 0.15, h: 65 }, // amber — a fork in the road
set_tag: { l: 0.65, c: 0.15, h: 350 }, // pink
handoff: { l: 0.65, c: 0.17, h: 16 }, // rose — hands off
end: { l: 0.55, c: 0.01, h: 260 }, // neutral grey — terminal
};
export interface NodeColors {
/** Full-strength hue — icon glyph, selection ring, port fill. */
solid: string;
/** ~14% tint — icon chip background, soft fills. */
soft: string;
/** ~45% tint — hover border / focus ring. */
ring: string;
/** Hue for the uppercase type label, kept readable in BOTH modes. */
text: string;
}
export function nodeColors(type: NodeType): NodeColors {
const t = NODE_HUE[type];
const solid = `oklch(${t.l} ${t.c} ${t.h})`;
return {
solid,
soft: `oklch(${t.l} ${t.c} ${t.h} / 0.14)`,
ring: `oklch(${t.l} ${t.c} ${t.h} / 0.45)`,
// Blend the hue toward the live --foreground token so the label
// holds contrast in BOTH modes: in dark mode --foreground is
// near-white (the label lightens to read on the dark card), in
// light mode it's near-black (the label darkens to read on the
// white card). The old fixed-light value only worked on dark.
text: `color-mix(in oklch, ${solid}, var(--foreground) 38%)`,
};
}
// ============================================================
// Shared node icon chip — the per-type colored glyph badge used in
// the canvas node card, list-view card, inspector header, and the
// add-step menu. One component so a styling change (radius, contrast,
// hover) lands in every place at once and the `nodeColors()` lookup
// lives in exactly one spot.
// ============================================================
export function NodeIconChip({
type,
size = 24,
iconSize = 14,
className,
}: {
type: NodeType;
/** Chip side length in px. */
size?: number;
/** Glyph side length in px. */
iconSize?: number;
className?: string;
}) {
const meta = NODE_META[type];
const c = nodeColors(type);
const Icon = meta.icon;
return (
<span
className={cn(
"flex shrink-0 items-center justify-center rounded-lg",
className,
)}
style={{ width: size, height: size, background: c.soft, color: c.solid }}
>
<Icon size={iconSize} />
</span>
);
}
// ============================================================
// Pure editing helpers — used by forms in both views.
// ============================================================