Skip to content

Commit 6a6d27a

Browse files
committed
feat(n8n-canvas): phase 3 — entry, edge-draw, and handoff animations
Adds the cinematic polish to the build canvas: - Per-node entry animation: fade + scale + de-blur over 360ms, with per-node delay staggering at 70ms intervals. Stagger order follows natural flow direction (left-to-right, top-to-bottom) so the eye reads along the trigger → action → fallback path. - Edge draw animation: stroke-dashoffset slides from 1000 to 0 over 600ms (with a 200ms initial delay so source nodes have landed before edges start drawing). Replaces n8n's marching-ants animation with a one-shot 'line being drawn' effect that lands once and stays. - Canvas → iframe handoff: when phase flips to 'finalized', the canvas opacity transitions to 0 over 360ms before unmounting. Parent (chat/[id]/page.tsx) holds the canvas mounted for 400ms past the phase flip via a keepBuildCanvasMounted latch, so the CSS fade-out can complete before the iframe takes over. Status pill flips amber 'Building' → emerald 'Imported' as the fade begins so the moment reads as a successful handoff, not a glitch. Animations live in N8nBuildCanvas.css (kept separate because stroke-dashoffset effects don't translate cleanly to Tailwind).
1 parent 387bbf9 commit 6a6d27a

3 files changed

Lines changed: 130 additions & 18 deletions

File tree

src/app/chat/[id]/page.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,26 @@ function ChatPage() {
304304
// from here instead of the e2b URL so the n8n auth cookie lands first-party
305305
// (browsers block third-party cookies in iframes even with SameSite=None).
306306
const [n8nClaimUrl, setN8nClaimUrl] = useState<string | null>(null);
307+
// Build-canvas hand-off latch: stays true while the n8n build canvas is
308+
// active (exploring/drafting) AND for ~400ms past the phase flip to
309+
// 'finalized', so the canvas can run its CSS fade-out before the iframe
310+
// takes over. Without this latch, the canvas unmounts the instant the
311+
// workflowReady event arrives and the user sees a hard cut to the iframe.
312+
const [keepBuildCanvasMounted, setKeepBuildCanvasMounted] = useState(false);
313+
useEffect(() => {
314+
const phase = ctx.n8nBuildState.phase;
315+
if (phase === "exploring" || phase === "drafting") {
316+
setKeepBuildCanvasMounted(true);
317+
return;
318+
}
319+
if (phase === "finalized" && keepBuildCanvasMounted) {
320+
// Already mounted — hold for the fade-out duration then unmount.
321+
const timer = setTimeout(() => setKeepBuildCanvasMounted(false), 400);
322+
return () => clearTimeout(timer);
323+
}
324+
// 'idle' or fresh-mount on 'finalized' — no canvas to keep.
325+
if (phase === "idle") setKeepBuildCanvasMounted(false);
326+
}, [ctx.n8nBuildState.phase, keepBuildCanvasMounted]);
307327

308328
// --- Refs for one-shot effects ---
309329
const didInitRef = useRef(false);
@@ -852,11 +872,10 @@ function ChatPage() {
852872
// n8n build canvas — replaces the iframe while the agent is composing the
853873
// workflow. Snaps from placeholders (exploring) to a positioned canonical
854874
// graph (drafting) before handing off to the real n8n UI in the iframe
855-
// once `workflowReady` flips phase to `finalized`.
856-
if (
857-
ctx.templateType === "n8n"
858-
&& (ctx.n8nBuildState.phase === "exploring" || ctx.n8nBuildState.phase === "drafting")
859-
) {
875+
// once `workflowReady` flips phase to `finalized`. The keepBuildCanvasMounted
876+
// latch holds the canvas in the DOM ~400ms past the phase flip so its
877+
// CSS fade-out can play before the iframe takes over.
878+
if (ctx.templateType === "n8n" && keepBuildCanvasMounted) {
860879
return (
861880
<N8nBuildCanvas
862881
phase={ctx.n8nBuildState.phase}

src/components/N8nBuildCanvas.css

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/* Animations for the N8nBuildCanvas. Kept here (not Tailwind) because
2+
stroke-dashoffset/dasharray edge-draw effects need raw CSS, and the
3+
stagger timing is tied to per-node delays we set inline. */
4+
5+
@keyframes codevibe-node-enter {
6+
0% {
7+
opacity: 0;
8+
transform: scale(0.85);
9+
filter: blur(4px);
10+
}
11+
60% {
12+
opacity: 1;
13+
filter: blur(0);
14+
}
15+
100% {
16+
opacity: 1;
17+
transform: scale(1);
18+
filter: blur(0);
19+
}
20+
}
21+
22+
.codevibe-node-enter {
23+
/* Entry timing: fade + scale + de-blur over 360ms.
24+
Each node's animation-delay is set inline based on its render index. */
25+
animation: codevibe-node-enter 360ms cubic-bezier(0.34, 1.4, 0.64, 1) both;
26+
}
27+
28+
@keyframes codevibe-edge-draw {
29+
to {
30+
stroke-dashoffset: 0;
31+
}
32+
}
33+
34+
/* React Flow renders edges as <path> inside <svg>. Target the path with our
35+
own class and animate stroke-dashoffset down from its initial dasharray
36+
value. The dasharray is set high enough (1000) to cover any edge length;
37+
the animation just slides the offset down to 0 to "draw" the line. */
38+
.codevibe-edge {
39+
stroke-dasharray: 1000;
40+
stroke-dashoffset: 1000;
41+
animation: codevibe-edge-draw 600ms 200ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
42+
}
43+
44+
/* When the canvas is finalizing — flip to 0 opacity over the same window the
45+
iframe takes to fade in (300-400ms). The parent component toggles this
46+
class once `phase` becomes `finalized`. */
47+
.codevibe-canvas-fadeout {
48+
opacity: 0;
49+
transition: opacity 360ms ease-out;
50+
pointer-events: none;
51+
}

src/components/N8nBuildCanvas.tsx

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ReactFlowProvider,
1313
} from "@xyflow/react";
1414
import "@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

6468
function 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

128139
const PLACEHOLDER_Y = 320;
129140
const PLACEHOLDER_X_STEP = 140;
141+
const ENTRY_STAGGER_MS = 70;
130142

131143
function 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

Comments
 (0)