@@ -12,6 +12,7 @@ import {
1212 ReactFlowProvider ,
1313} from "@xyflow/react" ;
1414import "@xyflow/react/dist/style.css" ;
15+ import "./N8nBuildCanvas.css" ;
1516
1617// ─── Types ──────────────────────────────────────────────────────────────────
1718//
@@ -59,16 +60,21 @@ interface CodevibeNodeData extends Record<string, unknown> {
5960 label : string ;
6061 nodeType : string ;
6162 isPlaceholder ?: boolean ;
63+ /** Per-node entry delay in ms. The container staggers nodes so they don't
64+ * all pop in at once — eye lands on each one in turn. */
65+ enterDelayMs ?: number ;
6266}
6367
6468function CodevibeNode ( { data } : NodeProps ) {
65- const { label, nodeType, isPlaceholder } = data as CodevibeNodeData ;
69+ const { label, nodeType, isPlaceholder, enterDelayMs } = data as CodevibeNodeData ;
6670 const initial = label . trim ( ) . charAt ( 0 ) . toUpperCase ( ) || "?" ;
71+ const delayStyle = enterDelayMs ? { animationDelay : `${ enterDelayMs } ms` } : undefined ;
6772 return (
6873 < div
69- className = { `group relative flex flex-col items-center gap-1.5 transition-all ${
74+ className = { `codevibe-node-enter group relative flex flex-col items-center gap-1.5 ${
7075 isPlaceholder ? "opacity-60 animate-pulse" : "opacity-100"
7176 } `}
77+ style = { delayStyle }
7278 >
7379 < Handle
7480 type = "target"
@@ -124,9 +130,15 @@ const nodeTypes = { codevibe: CodevibeNode };
124130// During `exploring`, we have only nodeType strings — lay them out in a
125131// horizontal row in the lower third (the "parking lot") so they read as
126132// candidates that haven't been arranged yet.
133+ //
134+ // Per-node enterDelayMs staggers the entry animation so nodes land one
135+ // after another instead of all at once. Stagger is small enough (~70ms)
136+ // that the whole graph reveals over ~600-800ms — fast enough to feel
137+ // responsive, slow enough that the eye registers each landing.
127138
128139const PLACEHOLDER_Y = 320 ;
129140const PLACEHOLDER_X_STEP = 140 ;
141+ const ENTRY_STAGGER_MS = 70 ;
130142
131143function buildExploringNodes ( nodeTypes : string [ ] ) : Node [ ] {
132144 return nodeTypes . map ( ( t , i ) => ( {
@@ -137,6 +149,7 @@ function buildExploringNodes(nodeTypes: string[]): Node[] {
137149 label : shortenType ( t ) ,
138150 nodeType : t ,
139151 isPlaceholder : true ,
152+ enterDelayMs : i * ENTRY_STAGGER_MS ,
140153 } as CodevibeNodeData ,
141154 } ) ) ;
142155}
@@ -145,30 +158,39 @@ function buildDraftGraph(draft: { nodes: N8nNode[]; connections: N8nConnections
145158 nodes : Node [ ] ;
146159 edges : Edge [ ] ;
147160} {
148- // Normalize positions so the smallest x/y land at a comfortable margin.
149- // n8n positions are absolute and can land at weird coordinates — keep
150- // their relative shape, just shift into view.
151161 const xs = draft . nodes . map ( ( n ) => n . position [ 0 ] ) ;
152162 const ys = draft . nodes . map ( ( n ) => n . position [ 1 ] ) ;
153163 const minX = xs . length ? Math . min ( ...xs ) : 0 ;
154164 const minY = ys . length ? Math . min ( ...ys ) : 0 ;
155165 const offsetX = 80 - minX ;
156166 const offsetY = 80 - minY ;
157167
158- // n8n IDs aren't guaranteed unique across all reasonable graphs (sometimes
159- // missing). Fall back to name as the React Flow id; we use name everywhere
160- // anyway because connections reference by name.
161- const nodes : Node [ ] = draft . nodes . map ( ( n ) => ( {
168+ // Order nodes left-to-right, top-to-bottom so the entry-stagger reads
169+ // along the natural flow direction (trigger → action → fallback) rather
170+ // than in arbitrary JSON order.
171+ const sortedIndices = draft . nodes
172+ . map ( ( n , i ) => ( { i, x : n . position [ 0 ] , y : n . position [ 1 ] } ) )
173+ . sort ( ( a , b ) => ( a . x === b . x ? a . y - b . y : a . x - b . x ) )
174+ . map ( ( entry ) => entry . i ) ;
175+ const renderOrder = new Map < number , number > ( ) ;
176+ sortedIndices . forEach ( ( origIdx , renderIdx ) => renderOrder . set ( origIdx , renderIdx ) ) ;
177+
178+ const nodes : Node [ ] = draft . nodes . map ( ( n , origIdx ) => ( {
162179 id : n . name ,
163180 type : "codevibe" ,
164181 position : { x : n . position [ 0 ] + offsetX , y : n . position [ 1 ] + offsetY } ,
165182 data : {
166183 label : n . name ,
167184 nodeType : n . type ,
168185 isPlaceholder : false ,
186+ enterDelayMs : ( renderOrder . get ( origIdx ) ?? 0 ) * ENTRY_STAGGER_MS ,
169187 } as CodevibeNodeData ,
170188 } ) ) ;
171189
190+ // Edges: animate via `codevibe-edge` className so the path draws itself
191+ // after the source node has had time to land. The 200ms hardcoded delay
192+ // inside the CSS keyframe + the per-node entry stagger keeps edges
193+ // landing slightly after their source tile finishes its entry.
172194 const edges : Edge [ ] = [ ] ;
173195 for ( const [ sourceName , conn ] of Object . entries ( draft . connections ) ) {
174196 const mainOutputs = conn . main ?. [ 0 ] ?? [ ] ;
@@ -178,7 +200,8 @@ function buildDraftGraph(draft: { nodes: N8nNode[]; connections: N8nConnections
178200 source : sourceName ,
179201 target : target . node ,
180202 type : "smoothstep" ,
181- animated : true ,
203+ animated : false , // we draw via stroke-dashoffset; no need for n8n's marching ants
204+ className : "codevibe-edge" ,
182205 style : { stroke : "rgb(161 161 170)" , strokeWidth : 1.5 } ,
183206 } ) ;
184207 }
@@ -205,8 +228,18 @@ function N8nBuildCanvasInner({ phase, exploredNodeTypes, draft }: N8nBuildCanvas
205228 ? `${ draft ?. nodes . length ?? 0 } nodes · ${ edges . length } connections`
206229 : "" ;
207230
231+ // When the parent flips phase to 'finalized', apply the fadeout class so
232+ // opacity transitions to 0 over 360ms before the parent unmounts us. The
233+ // parent is expected to keep this component mounted for ~400ms past the
234+ // phase flip via a delayed-unmount effect (see use in chat/[id]/page.tsx).
235+ const isFadingOut = phase === "finalized" ;
236+
208237 return (
209- < div className = "relative h-full w-full bg-zinc-950" >
238+ < div
239+ className = { `relative h-full w-full bg-zinc-950 ${
240+ isFadingOut ? "codevibe-canvas-fadeout" : ""
241+ } `}
242+ >
210243 { /* Header */ }
211244 < div className = "absolute left-0 right-0 top-0 z-10 flex items-center justify-between px-4 py-2.5 border-b border-zinc-800 bg-zinc-900/80 backdrop-blur" >
212245 < div className = "flex flex-col" >
@@ -216,8 +249,17 @@ function N8nBuildCanvasInner({ phase, exploredNodeTypes, draft }: N8nBuildCanvas
216249 ) }
217250 </ div >
218251 < div className = "flex items-center gap-1.5 text-[10px] uppercase tracking-wider" >
219- < div className = "h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
220- < span className = "text-amber-400" > Building</ span >
252+ { isFadingOut ? (
253+ < >
254+ < div className = "h-1.5 w-1.5 rounded-full bg-emerald-400" />
255+ < span className = "text-emerald-400" > Imported</ span >
256+ </ >
257+ ) : (
258+ < >
259+ < div className = "h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse" />
260+ < span className = "text-amber-400" > Building</ span >
261+ </ >
262+ ) }
221263 </ div >
222264 </ div >
223265 { /* Canvas */ }
0 commit comments