From 1f4d1a3a11e9b89765161540769ce4f8275bc90a Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Fri, 26 Jun 2026 16:53:03 +0300 Subject: [PATCH] Redesign flow editor UI to match Flow Builder design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/flows/flow-builder.tsx | 21 ++- src/components/flows/flow-canvas.tsx | 153 +++++++++++++++------ src/components/flows/flow-editor-shell.tsx | 82 ++++++++--- src/components/flows/header.tsx | 121 ++++++++++------ src/components/flows/shared.tsx | 115 +++++++++++++++- 5 files changed, 378 insertions(+), 114 deletions(-) diff --git a/src/components/flows/flow-builder.tsx b/src/components/flows/flow-builder.tsx index 0df4af5..8010ae8 100644 --- a/src/components/flows/flow-builder.tsx +++ b/src/components/flows/flow-builder.tsx @@ -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 ( -
+
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 (
+ {/* type-colored left rail, ties the list row to the canvas hue */} + -
-
-
- - - setState((s) => ({ ...s, name: e.target.value })) - } - placeholder="Flow name" - className="max-w-md bg-card text-lg font-semibold" - /> - - {dirty && ( - - - Edited - - )} -
-
+ + + + setState((s) => ({ ...s, name: e.target.value }))} + placeholder="Flow name" + 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)]" + /> + + {dirty && ( + + + Edited + + )} + + {/* ---- right: runs · delete · activate · save ---- */} +
- 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" />
); } -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 ( - - {status.charAt(0).toUpperCase() + status.slice(1)} - + + + {cfg.label} + ); } diff --git a/src/components/flows/shared.tsx b/src/components/flows/shared.tsx index 1b2a097..3befa71 100644 --- a/src/components/flows/shared.tsx +++ b/src/components/flows/shared.tsx @@ -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 = { + 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 ( + + + + ); +} + // ============================================================ // Pure editing helpers — used by forms in both views. // ============================================================