From c9e141e8b60e0d7dffcd969e1cf79c71bda97493 Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Tue, 14 Jul 2026 18:37:15 -0500 Subject: [PATCH 1/5] fix(workflows): reframe the density toggle from the layout's own geometry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fitView reads React Flow's measured node boxes, and measurement lags a density flip by a frame or more — so the reframe raced it and framed the old node sizes as often as the new. The structural nodes carry the layouter's authoritative position + size for exactly the new layout, so the viewport is now computed from them (getNodesBounds + getViewportForBounds, same FIT_VIEW clamps and reduced-motion handling) and set directly. Deterministic: the same density always lands the same frame. --- src/workflows/WorkflowGraph.tsx | 46 +++++++++--- src/workflows/WorkflowGraphFitView.test.tsx | 79 ++++++++++++++++----- 2 files changed, 100 insertions(+), 25 deletions(-) diff --git a/src/workflows/WorkflowGraph.tsx b/src/workflows/WorkflowGraph.tsx index 3539e98..520afa6 100644 --- a/src/workflows/WorkflowGraph.tsx +++ b/src/workflows/WorkflowGraph.tsx @@ -10,6 +10,8 @@ import { type ColorMode, Controls, type Edge, + getNodesBounds, + getViewportForBounds, Handle, MarkerType, type Node, @@ -608,6 +610,9 @@ export function WorkflowGraph({ const [userCompact, setUserCompact] = useState(defaultCompact); const compact = isPreview || userCompact; const rfRef = useRef> | null>(null); + // The canvas wrapper — measured when reframing, since the viewport math needs + // the frame's real width/height and the RF instance doesn't expose them. + const wrapperRef = useRef(null); // Structural graph from the YAML alone — STABLE across nodeState ticks. Built // with run-state spacing reserved whenever a run overlay is in play (nodeState @@ -666,8 +671,14 @@ export function WorkflowGraph({ // orientation, the density toggle, or a run-overlay transition) — node ids/count // are unchanged across a density flip, so React Flow won't auto-fit and the // graph would otherwise be left mis-zoomed. Deferred a frame so it runs after - // the re-seeded nodes (with their new sizes) commit. A run-state tick doesn't - // change `structural`, so a live run is never yanked around. + // the re-seeded nodes commit. A run-state tick doesn't change `structural`, + // so a live run is never yanked around. + // + // The viewport is computed from the STRUCTURAL nodes' own geometry — the + // layouter's authoritative position + size for exactly this layout — never + // from `fitView`, which reads React Flow's MEASURED node boxes. Measurement + // (a ResizeObserver) lags a density flip by a frame or more, so a fitView + // here raced it and framed the OLD node sizes as often as the new ones. // // Animated, because a reader is watching: the nodes resize in place and the // viewport glides to the new framing. @@ -680,11 +691,30 @@ export function WorkflowGraph({ return; } const inst = rfRef.current; - if (!inst || typeof requestAnimationFrame === "undefined") return; - // Cancelling the frame un-schedules a fit that hasn't run. One already in - // flight is left to finish: it only writes the viewport of a store nobody is - // reading any more, and React Flow exposes no way to interrupt it. - const raf = requestAnimationFrame(() => inst.fitView(fitViewOnLayoutChange())); + const wrapper = wrapperRef.current; + if (!inst || !wrapper || typeof requestAnimationFrame === "undefined") { + return; + } + // Cancelling the frame un-schedules a reframe that hasn't run. One already + // in flight is left to finish: it only writes the viewport of a store nobody + // is reading any more, and React Flow exposes no way to interrupt it. + const raf = requestAnimationFrame(() => { + const { width, height } = wrapper.getBoundingClientRect(); + // A hidden canvas (display:none ancestor) has no frame to fit into. + if (width === 0 || height === 0) return; + const options = fitViewOnLayoutChange(); + inst.setViewport( + getViewportForBounds( + getNodesBounds(structural.nodes), + width, + height, + options.minZoom, + options.maxZoom, + options.padding, + ), + "duration" in options ? { duration: options.duration } : undefined, + ); + }); return () => cancelAnimationFrame(raf); }, [structural]); @@ -708,7 +738,7 @@ export function WorkflowGraph({ return ( -
+
{ @@ -25,10 +29,13 @@ vi.mock("@xyflow/react", async (importOriginal) => { onInit, }: { children?: React.ReactNode - onInit?: (instance: { fitView: typeof fitView }) => void + onInit?: (instance: { + setViewport: typeof setViewport + fitView: typeof fitView + }) => void }) => { useEffect(() => { - onInit?.({ fitView }) + onInit?.({ setViewport, fitView }) }, [onInit]) return
{children}
}, @@ -50,10 +57,27 @@ do: path: github.issues.create ` +/** jsdom lays out nothing — give the canvas wrapper a real frame so the + * reframe math has a width/height to fit into. */ +const measureableFrame = () => + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + width: 960, + height: 480, + top: 0, + left: 0, + right: 960, + bottom: 480, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect) + afterEach(() => { cleanup() + setViewport.mockClear() fitView.mockClear() vi.unstubAllGlobals() + vi.restoreAllMocks() }) /** A browser that can report the preference, and says the reader has none. jsdom has @@ -66,18 +90,28 @@ const readerToleratesMotion = () => })) describe("reframing on the density toggle", () => { - it("hands React Flow the ANIMATED framing, not the bare constant", async () => { + it("SETS a computed viewport with the animated framing — it does not fitView", async () => { readerToleratesMotion() + measureableFrame() render() // Mounting does not refit — React Flow's own `fitView` prop already framed it. - expect(fitView).not.toHaveBeenCalled() + expect(setViewport).not.toHaveBeenCalled() fireEvent.click(screen.getByRole("button", { name: /expand|compact/i })) - await waitFor(() => expect(fitView).toHaveBeenCalledTimes(1)) - expect(fitView).toHaveBeenCalledWith( - expect.objectContaining({ duration: 220 }), + await waitFor(() => expect(setViewport).toHaveBeenCalledTimes(1)) + const [viewport, options] = setViewport.mock.calls[0] + // A concrete viewport computed from the new layout's own geometry… + expect(viewport).toEqual( + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + zoom: expect.any(Number), + }), ) + // …delivered with the glide, and never via measurement-racing fitView. + expect(options).toEqual({ duration: 220 }) + expect(fitView).not.toHaveBeenCalled() }) it("reframes without moving for a reader who asked for less motion", async () => { @@ -85,14 +119,25 @@ describe("reframing on the density toggle", () => { matches: query.includes("prefers-reduced-motion"), media: query, })) + measureableFrame() render() fireEvent.click(screen.getByRole("button", { name: /expand|compact/i })) - await waitFor(() => expect(fitView).toHaveBeenCalledTimes(1)) + await waitFor(() => expect(setViewport).toHaveBeenCalledTimes(1)) // Same framing, no transition. - const options = fitView.mock.calls[0][0] - expect(options).not.toHaveProperty("duration") - expect(options).toHaveProperty("padding") + expect(setViewport.mock.calls[0][1]).toBeUndefined() + }) + + it("skips the reframe while the canvas has no frame to fit into", async () => { + readerToleratesMotion() + // No getBoundingClientRect stub: jsdom's 0×0 stands in for display:none. + render() + + fireEvent.click(screen.getByRole("button", { name: /expand|compact/i })) + + // Give the deferred frame a chance to run, then confirm it declined. + await new Promise((r) => setTimeout(r, 50)) + expect(setViewport).not.toHaveBeenCalled() }) }) From 476d653ecb3932ba93c0ba46795febf18226f934 Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Tue, 14 Jul 2026 19:04:32 -0500 Subject: [PATCH 2/5] fix(workflows): density-aware fit zoom ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full cards at zoom 1 are their designed size, but compact tiles are small by design — capping their fit at 1 stranded a short compact graph as specks in empty space (while the Controls fit button, uncapped, framed it properly). The fit ceiling is now 1.5 for compact and stays 1 for expanded, on both the mount fit and the density-toggle reframe. --- src/workflows/WorkflowGraph.tsx | 22 ++++++++++++++++------ src/workflows/WorkflowNode.test.tsx | 9 ++++++++- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/workflows/WorkflowGraph.tsx b/src/workflows/WorkflowGraph.tsx index 520afa6..9fc3a7c 100644 --- a/src/workflows/WorkflowGraph.tsx +++ b/src/workflows/WorkflowGraph.tsx @@ -68,10 +68,20 @@ const MUTED_TRACK = * pipeline into a short panel by zooming out without limit is what shrank the * nodes to unreadable specks. Below `minZoom` the graph stops shrinking and * becomes pannable instead — a legible graph you scroll beats an illegible one - * that fits. `maxZoom: 1` keeps a two-node graph from blowing its cards up to - * fill the canvas. + * that fits. + * + * The CEILING depends on density. Full cards at 1 are already their designed + * size — zooming a two-node graph past that just blows the cards up to fill the + * canvas. Compact tiles are small BY DESIGN, so fitting them into the same + * canvas legitimately zooms past 1; capping them there strands a short compact + * graph as specks in empty space. */ -const FIT_VIEW = { padding: 0.16, minZoom: 0.55, maxZoom: 1 } as const; +const FIT_VIEW = { padding: 0.16, minZoom: 0.55 } as const; + +/** Zoom ceiling for a fit at the given density (see FIT_VIEW). */ +export function fitZoomCeiling(compact: boolean): number { + return compact ? 1.5 : 1; +} /** The same framing, animated. Used when the layout changes UNDER a reader who is * already looking at the graph (the density toggle) — an instant jump to the new @@ -709,14 +719,14 @@ export function WorkflowGraph({ width, height, options.minZoom, - options.maxZoom, + fitZoomCeiling(compact), options.padding, ), "duration" in options ? { duration: options.duration } : undefined, ); }); return () => cancelAnimationFrame(raf); - }, [structural]); + }, [structural, compact]); const handleNodeClick = useCallback( (_event: ReactMouseEvent, node: Node) => { @@ -751,7 +761,7 @@ export function WorkflowGraph({ }} onNodeClick={onNodeClick ? handleNodeClick : undefined} fitView - fitViewOptions={FIT_VIEW} + fitViewOptions={{ ...FIT_VIEW, maxZoom: fitZoomCeiling(compact) }} proOptions={{ hideAttribution: true }} // Node dragging is reserved for the full editor; a preview stays // read-only so its layout can't be disturbed. Both variants pan + zoom so diff --git a/src/workflows/WorkflowNode.test.tsx b/src/workflows/WorkflowNode.test.tsx index 190510e..fed6c55 100644 --- a/src/workflows/WorkflowNode.test.tsx +++ b/src/workflows/WorkflowNode.test.tsx @@ -10,6 +10,7 @@ import { DensityContext, DirectionContext, fitViewOnLayoutChange, + fitZoomCeiling, WorkflowGraph, WorkflowNode, } from "./WorkflowGraph"; @@ -72,7 +73,13 @@ describe("reframing the viewport when the layout changes", () => { const glide = fitViewOnLayoutChange(); expect(glide.padding).toBe(still.padding); expect(glide.minZoom).toBe(still.minZoom); - expect(glide.maxZoom).toBe(still.maxZoom); + }); + + it("lets a compact fit zoom past 1, and holds full cards at their size", () => { + // Compact tiles are small by design — fitting them into the canvas + // legitimately zooms in; full cards at 1 are already their designed size. + expect(fitZoomCeiling(true)).toBeGreaterThan(1); + expect(fitZoomCeiling(false)).toBe(1); }); }); From 8b3e1ec99a6a0f96d632a763776c3ba0a35db8fa Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Tue, 14 Jul 2026 19:52:25 -0500 Subject: [PATCH 3/5] =?UTF-8?q?feat(workflows):=20tween=20the=20density=20?= =?UTF-8?q?toggle=20=E2=80=94=20nodes=20and=20camera=20move=20as=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle was three visible stages: node content swapped in the old boxes, then boxes and positions snapped, then the camera glided — which read as jitter. The layout transition now tweens node geometry and the viewport together through React Flow's store (220ms, ease-in-out), so edges re-route against the moving nodes on every frame; each card clips overflow, so mid-tween content reads as a reveal. Run state is re-merged into every frame so a tween can't wipe a node's live status. Reduced motion (and any non-tweenable transition: new node set, hidden canvas) lands atomically — nodes and camera in the same pass. --- src/workflows/WorkflowGraph.tsx | 182 +++++++++++++------- src/workflows/WorkflowGraphFitView.test.tsx | 111 +++++++----- src/workflows/WorkflowNode.test.tsx | 45 +---- 3 files changed, 195 insertions(+), 143 deletions(-) diff --git a/src/workflows/WorkflowGraph.tsx b/src/workflows/WorkflowGraph.tsx index 9fc3a7c..e87f3ef 100644 --- a/src/workflows/WorkflowGraph.tsx +++ b/src/workflows/WorkflowGraph.tsx @@ -83,22 +83,28 @@ export function fitZoomCeiling(compact: boolean): number { return compact ? 1.5 : 1; } -/** The same framing, animated. Used when the layout changes UNDER a reader who is - * already looking at the graph (the density toggle) — an instant jump to the new - * viewport reads as the graph being replaced, while a short glide reads as the - * same graph being reframed, which is what actually happened. Short enough not to - * make the toggle feel laggy, and skipped entirely for a reader who asked the - * system for less motion. */ -export function fitViewOnLayoutChange() { - // Motion is opt-OUT, so it is only added where the reader's preference can be - // read and says nothing against it. Somewhere without `matchMedia` cannot say a - // reader tolerates movement, so the graph reframes without it. - const moves = +/** How long a layout transition (the density toggle) runs — short enough that + * the toggle feels immediate, long enough to read as the same graph being + * reframed rather than replaced. */ +export const LAYOUT_TRANSITION_MS = 220; + +/** Motion is opt-OUT, so it is only used where the reader's preference can be + * read and says nothing against it. Somewhere without `matchMedia` cannot say + * a reader tolerates movement, so it gets none. */ +function motionAllowed(): boolean { + return ( typeof window !== "undefined" && - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === false; - return moves ? { ...FIT_VIEW, duration: 220 } : FIT_VIEW; + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === false + ); } +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +/** The layout tween's easing — gentle into and out of the motion, so neither + * end of the morph reads as a snap. */ +const easeInOutCubic = (t: number) => + t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; + /** Tracks the app's dark/light class so React Flow's chrome (edges, controls, * background) themes with the rest of the app. */ function useColorMode(): ColorMode { @@ -653,13 +659,14 @@ export function WorkflowGraph({ const [nodes, setNodes, onNodesChange] = useNodesState(structural.nodes); const [edges, setEdges, onEdgesChange] = useEdgesState(styledEdges); - - // Re-seed React Flow's nodes when the definition (or run-mode) changes. The - // merge effect below re-applies the current run state in its own pass, so this - // only needs the static base nodes. - useEffect(() => { - setNodes(structural.nodes); - }, [structural, setNodes]); + // Live mirrors for the layout transition below: the tween reads its STARTING + // geometry from whatever is currently rendered (including a mid-flight tween + // it is redirecting), and re-merges the current run state into every frame it + // writes — without the transition effect re-firing on node/state ticks. + const nodesRef = useRef(nodes); + nodesRef.current = nodes; + const nodeStateRef = useRef(nodeState); + nodeStateRef.current = nodeState; // Repaint edges whenever the styling (structure or run state) changes. Edge ids // are stable, so React Flow updates color/animation in place. @@ -677,56 +684,115 @@ export function WorkflowGraph({ setNodes((prev) => mergeRunState(prev, baseById, nodeState)); }, [nodeState, structural, setNodes]); - // Reframe the viewport when the layout STRUCTURE changes (a definition edit, - // orientation, the density toggle, or a run-overlay transition) — node ids/count - // are unchanged across a density flip, so React Flow won't auto-fit and the - // graph would otherwise be left mis-zoomed. Deferred a frame so it runs after - // the re-seeded nodes commit. A run-state tick doesn't change `structural`, - // so a live run is never yanked around. + // Move the graph to a NEW layout when the STRUCTURE changes (a definition + // edit, orientation, the density toggle, a run-overlay transition). A + // run-state tick doesn't change `structural`, so a live run is never yanked + // around. // - // The viewport is computed from the STRUCTURAL nodes' own geometry — the + // For a same-node relayout with motion allowed — the density toggle — node + // geometry and the camera tween TOGETHER through React Flow's store, so the + // edges re-route against the moving nodes on every frame. (A CSS transform + // transition can't give that: edge paths are computed from store positions, + // so they would snap to the final layout while the nodes glided.) The node + // CONTENT swaps at the start and its box morphs around it — the card clips + // via overflow-hidden, so mid-tween content reads as a reveal, not a spill. + // + // Every other transition — a different node set, reduced motion, a hidden + // canvas — lands ATOMICALLY: nodes and camera written in the same pass, never + // a lingering frame of the new layout under the old camera. + // + // The end viewport is computed from the STRUCTURAL nodes' own geometry — the // layouter's authoritative position + size for exactly this layout — never // from `fitView`, which reads React Flow's MEASURED node boxes. Measurement - // (a ResizeObserver) lags a density flip by a frame or more, so a fitView - // here raced it and framed the OLD node sizes as often as the new ones. - // - // Animated, because a reader is watching: the nodes resize in place and the - // viewport glides to the new framing. - const didInitialFitRef = useRef(false); + // (a ResizeObserver) lags a relayout by a frame or more, so a fitView here + // raced it and framed the OLD node sizes as often as the new ones. + const didInitialSeedRef = useRef(false); useEffect(() => { - // ReactFlow's `fitView` prop already frames the initial render — skip this - // effect's first run so we don't double-fit on mount. - if (!didInitialFitRef.current) { - didInitialFitRef.current = true; + // The current run state, re-merged into every write this effect makes so a + // tween frame can't wipe a node's live status (the merge effect above only + // re-fires on its own deps). + const state = nodeStateRef.current; + const target = state + ? structural.nodes.map((n) => + state[n.id] ? { ...n, data: { ...n.data, state: state[n.id] } } : n, + ) + : structural.nodes; + // ReactFlow's `fitView` prop frames the initial render — the first pass + // only seeds the nodes and leaves the viewport alone. + if (!didInitialSeedRef.current) { + didInitialSeedRef.current = true; + setNodes(target); return; } const inst = rfRef.current; - const wrapper = wrapperRef.current; - if (!inst || !wrapper || typeof requestAnimationFrame === "undefined") { + const frame = wrapperRef.current?.getBoundingClientRect(); + // A hidden canvas (display:none ancestor) has no frame to fit into. + if (!inst || !frame || frame.width === 0 || frame.height === 0) { + setNodes(target); + return; + } + const endViewport = getViewportForBounds( + getNodesBounds(structural.nodes), + frame.width, + frame.height, + FIT_VIEW.minZoom, + fitZoomCeiling(compact), + FIT_VIEW.padding, + ); + const prev = nodesRef.current; + const prevById = new Map(prev.map((n) => [n.id, n])); + const sameNodeSet = + prev.length === structural.nodes.length && + structural.nodes.every((n) => prevById.has(n.id)); + if ( + !sameNodeSet || + !motionAllowed() || + typeof requestAnimationFrame === "undefined" + ) { + setNodes(target); + inst.setViewport(endViewport); return; } - // Cancelling the frame un-schedules a reframe that hasn't run. One already - // in flight is left to finish: it only writes the viewport of a store nobody - // is reading any more, and React Flow exposes no way to interrupt it. - const raf = requestAnimationFrame(() => { - const { width, height } = wrapper.getBoundingClientRect(); - // A hidden canvas (display:none ancestor) has no frame to fit into. - if (width === 0 || height === 0) return; - const options = fitViewOnLayoutChange(); - inst.setViewport( - getViewportForBounds( - getNodesBounds(structural.nodes), - width, - height, - options.minZoom, - fitZoomCeiling(compact), - options.padding, - ), - "duration" in options ? { duration: options.duration } : undefined, + const startViewport = inst.getViewport(); + const startedAt = performance.now(); + let raf = requestAnimationFrame(function step(now: number) { + const t = Math.min(1, (now - startedAt) / LAYOUT_TRANSITION_MS); + const e = easeInOutCubic(t); + setNodes( + t >= 1 + ? target + : target.map((n) => { + const p = prevById.get(n.id); + if (!p || p.width === undefined || p.height === undefined) { + return n; + } + const width = lerp(p.width, n.width ?? p.width, e); + const height = lerp(p.height, n.height ?? p.height, e); + return { + ...n, + position: { + x: lerp(p.position.x, n.position.x, e), + y: lerp(p.position.y, n.position.y, e), + }, + width, + height, + style: { ...n.style, width, height }, + }; + }), ); + inst.setViewport({ + x: lerp(startViewport.x, endViewport.x, e), + y: lerp(startViewport.y, endViewport.y, e), + zoom: lerp(startViewport.zoom, endViewport.zoom, e), + }); + if (t < 1) raf = requestAnimationFrame(step); }); + // Cancelling un-schedules the next frame only. When the layout changes + // again mid-flight, the replacing tween starts from the CURRENT rendered + // geometry (nodesRef), so a rapid double-toggle redirects smoothly instead + // of jumping to either end. return () => cancelAnimationFrame(raf); - }, [structural, compact]); + }, [structural, compact, setNodes]); const handleNodeClick = useCallback( (_event: ReactMouseEvent, node: Node) => { diff --git a/src/workflows/WorkflowGraphFitView.test.tsx b/src/workflows/WorkflowGraphFitView.test.tsx index 1177b85..3fa2da1 100644 --- a/src/workflows/WorkflowGraphFitView.test.tsx +++ b/src/workflows/WorkflowGraphFitView.test.tsx @@ -3,20 +3,26 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re import { afterEach, describe, expect, it, vi } from "vitest" /** - * The density toggle reframes the graph. `fitViewOnLayoutChange` decides HOW (see - * WorkflowNode.test.tsx) — this covers the wiring: that the graph reframes by - * SETTING a viewport computed from the structural nodes' own geometry, animated - * per that function. It must NOT go through `fitView`: fitView reads React Flow's - * measured node boxes, and measurement lags a density flip, so it raced the new - * layout and framed the old node sizes as often as the new ones. + * The density toggle reframes the graph. This covers HOW the reframe runs: * - * React Flow itself is stubbed: it measures a real viewport, which jsdom does not - * have, and it is not the thing under test. The stub hands the component the two - * things it reaches for — the instance via `onInit`, and the Panel so the toggle - * is clickable. jsdom reports every element as 0×0, so the wrapper is given a - * real frame by stubbing getBoundingClientRect. + * - With motion allowed, the node geometry and the camera TWEEN together — + * many `setViewport` writes over ~220ms, none of them handed to React Flow's + * own animator (no `duration` option), because the tween drives both the + * nodes and the camera itself so edges re-route against the moving nodes. + * It must never go through `fitView`: fitView reads MEASURED node boxes, + * which lag a density flip, so it raced the new layout and framed the old + * sizes as often as the new. + * - With reduced motion, the reframe is ATOMIC: exactly one instant + * `setViewport` alongside the node swap. + * - With no frame to fit into (a hidden canvas), the viewport is not touched. + * + * React Flow itself is stubbed: it measures a real viewport, which jsdom does + * not have, and it is not the thing under test. The stub hands the component + * the instance via `onInit` and renders the Panel so the toggle is clickable. + * jsdom lays out nothing and has no visual rAF, so both are stubbed too. */ const setViewport = vi.fn() +const getViewport = vi.fn(() => ({ x: 0, y: 0, zoom: 1 })) const fitView = vi.fn() vi.mock("@xyflow/react", async (importOriginal) => { @@ -31,11 +37,12 @@ vi.mock("@xyflow/react", async (importOriginal) => { children?: React.ReactNode onInit?: (instance: { setViewport: typeof setViewport + getViewport: typeof getViewport fitView: typeof fitView }) => void }) => { useEffect(() => { - onInit?.({ setViewport, fitView }) + onInit?.({ setViewport, getViewport, fitView }) }, [onInit]) return
{children}
}, @@ -46,7 +53,7 @@ vi.mock("@xyflow/react", async (importOriginal) => { } }) -const { WorkflowGraph } = await import("./WorkflowGraph") +const { WorkflowGraph, LAYOUT_TRANSITION_MS } = await import("./WorkflowGraph") const YAML = ` do: @@ -72,26 +79,33 @@ const measureableFrame = () => toJSON: () => ({}), } as DOMRect) +/** Timer-driven animation frames, so the tween actually advances under jsdom. */ +const timerDrivenFrames = () => { + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => + setTimeout(() => cb(performance.now()), 16), + ) + vi.stubGlobal("cancelAnimationFrame", (id: number) => clearTimeout(id)) +} + +const matchMediaSaying = (reduce: boolean) => + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: reduce && query.includes("prefers-reduced-motion"), + media: query, + })) + afterEach(() => { cleanup() setViewport.mockClear() + getViewport.mockClear() fitView.mockClear() vi.unstubAllGlobals() vi.restoreAllMocks() }) -/** A browser that can report the preference, and says the reader has none. jsdom has - * no `matchMedia` at all — and a host that cannot report the preference gets no - * motion (see WorkflowNode.test.tsx), so the animated path has to be asked for. */ -const readerToleratesMotion = () => - vi.stubGlobal("matchMedia", (query: string) => ({ - matches: false, - media: query, - })) - describe("reframing on the density toggle", () => { - it("SETS a computed viewport with the animated framing — it does not fitView", async () => { - readerToleratesMotion() + it("tweens the camera itself — many un-animated writes, never fitView", async () => { + matchMediaSaying(false) + timerDrivenFrames() measureableFrame() render() // Mounting does not refit — React Flow's own `fitView` prop already framed it. @@ -99,44 +113,55 @@ describe("reframing on the density toggle", () => { fireEvent.click(screen.getByRole("button", { name: /expand|compact/i })) - await waitFor(() => expect(setViewport).toHaveBeenCalledTimes(1)) - const [viewport, options] = setViewport.mock.calls[0] - // A concrete viewport computed from the new layout's own geometry… - expect(viewport).toEqual( - expect.objectContaining({ + // The tween runs LAYOUT_TRANSITION_MS of ~16ms frames — several writes. + await waitFor( + () => expect(setViewport.mock.calls.length).toBeGreaterThan(2), + { timeout: LAYOUT_TRANSITION_MS * 5 }, + ) + for (const [viewport, options] of setViewport.mock.calls) { + // Each write is a concrete viewport the tween computed… + expect(viewport).toEqual({ x: expect.any(Number), y: expect.any(Number), zoom: expect.any(Number), - }), - ) - // …delivered with the glide, and never via measurement-racing fitView. - expect(options).toEqual({ duration: 220 }) + }) + // …applied instantly: the tween IS the animation, so handing React Flow + // a duration would run a second animator underneath it. + expect(options).toBeUndefined() + } expect(fitView).not.toHaveBeenCalled() }) - it("reframes without moving for a reader who asked for less motion", async () => { - vi.stubGlobal("matchMedia", (query: string) => ({ - matches: query.includes("prefers-reduced-motion"), - media: query, - })) + it("reframes atomically for a reader who asked for less motion", async () => { + matchMediaSaying(true) + timerDrivenFrames() measureableFrame() render() fireEvent.click(screen.getByRole("button", { name: /expand|compact/i })) await waitFor(() => expect(setViewport).toHaveBeenCalledTimes(1)) - // Same framing, no transition. - expect(setViewport.mock.calls[0][1]).toBeUndefined() + const [viewport, options] = setViewport.mock.calls[0] + expect(viewport).toEqual({ + x: expect.any(Number), + y: expect.any(Number), + zoom: expect.any(Number), + }) + expect(options).toBeUndefined() + // One write is the whole reframe — give a straggling frame the chance to + // prove there isn't one. + await new Promise((r) => setTimeout(r, LAYOUT_TRANSITION_MS + 50)) + expect(setViewport).toHaveBeenCalledTimes(1) }) - it("skips the reframe while the canvas has no frame to fit into", async () => { - readerToleratesMotion() + it("leaves the viewport alone while the canvas has no frame to fit into", async () => { + matchMediaSaying(false) + timerDrivenFrames() // No getBoundingClientRect stub: jsdom's 0×0 stands in for display:none. render() fireEvent.click(screen.getByRole("button", { name: /expand|compact/i })) - // Give the deferred frame a chance to run, then confirm it declined. await new Promise((r) => setTimeout(r, 50)) expect(setViewport).not.toHaveBeenCalled() }) diff --git a/src/workflows/WorkflowNode.test.tsx b/src/workflows/WorkflowNode.test.tsx index fed6c55..b03e7d8 100644 --- a/src/workflows/WorkflowNode.test.tsx +++ b/src/workflows/WorkflowNode.test.tsx @@ -9,7 +9,6 @@ import { buildStyledEdges, DensityContext, DirectionContext, - fitViewOnLayoutChange, fitZoomCeiling, WorkflowGraph, WorkflowNode, @@ -34,50 +33,12 @@ function renderNode(data: WfNodeData) { ); } -describe("reframing the viewport when the layout changes", () => { - // The density toggle re-frames the graph under a reader who is already looking at - // it, so the viewport GLIDES to its new framing rather than jumping. A reader who - // asked the system for less motion gets the framing without the glide. - const prefersReducedMotion = (reduce: boolean) => - vi.stubGlobal( - "matchMedia", - (query: string) => ({ - matches: reduce && query.includes("prefers-reduced-motion"), - media: query, - }), - ); - - afterEach(() => vi.unstubAllGlobals()); - - it("glides by default", () => { - prefersReducedMotion(false); - expect(fitViewOnLayoutChange()).toHaveProperty("duration", 220); - }); - - it("reframes instantly for a reader who asked for less motion", () => { - prefersReducedMotion(true); - expect(fitViewOnLayoutChange()).not.toHaveProperty("duration"); - }); - - it("does not move at all where the preference cannot be read", () => { - // Motion is opt-OUT. Somewhere without matchMedia cannot tell us a reader - // tolerates movement, so it does not get any. - vi.stubGlobal("matchMedia", undefined); - expect(fitViewOnLayoutChange()).not.toHaveProperty("duration"); - }); - - it("keeps the SAME framing either way — only the transition differs", () => { - prefersReducedMotion(true); - const still = fitViewOnLayoutChange(); - prefersReducedMotion(false); - const glide = fitViewOnLayoutChange(); - expect(glide.padding).toBe(still.padding); - expect(glide.minZoom).toBe(still.minZoom); - }); - +describe("fitZoomCeiling", () => { it("lets a compact fit zoom past 1, and holds full cards at their size", () => { // Compact tiles are small by design — fitting them into the canvas // legitimately zooms in; full cards at 1 are already their designed size. + // (How the reframe itself runs — the tween, and the reduced-motion instant + // path — is covered in WorkflowGraphFitView.test.tsx.) expect(fitZoomCeiling(true)).toBeGreaterThan(1); expect(fitZoomCeiling(false)).toBe(1); }); From 1b672371a2f0991b3e2df61aa2336abcf5175736 Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Tue, 14 Jul 2026 20:13:42 -0500 Subject: [PATCH 4/5] feat(workflows): land the density swap mid-morph, with the content fading in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rendered density flipped the instant the toggle was clicked — full card content popped into still-tile-sized boxes (and handle anchors jumped) one frame before anything moved, which read as a glitch. The rendered density now TRAILS the target: the box and camera start morphing around the old, clipped content, and the swap lands mid-motion where the two shells are closest in size — growing flips just after the box starts to grow, shrinking once the card has nearly collapsed. The incoming content fades in (opacity only, content only — a node's border/surface never blinks out), and a bounce that reverses before its flip fired never swaps content at all. Reduced motion keeps the atomic swap. --- src/styles/globals.css | 20 ++++++++++ src/workflows/WorkflowGraph.tsx | 71 +++++++++++++++++++++++++-------- 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/styles/globals.css b/src/styles/globals.css index 6ad9f20..46cb19b 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -347,6 +347,26 @@ animation: fade-in 0.25s ease-out; } + /* A workflow node's content when its density swap lands mid layout-morph: + the incoming content materializes inside the moving box instead of popping + in. Opacity only — movement belongs to the layout tween — and applied to + CONTENT, never to a node's border/surface shell, so the node itself never + blinks out during the swap. */ + .wf-node-body-in { + animation: wf-node-body-in 150ms ease-out both; + } + + @keyframes wf-node-body-in { + from { opacity: 0; } + to { opacity: 1; } + } + + @media (prefers-reduced-motion: reduce) { + .wf-node-body-in { + animation: none; + } + } + /* Quiet enter for optimistically-inserted list rows. */ .animate-row-in { animation: row-in 0.18s ease-out; diff --git a/src/workflows/WorkflowGraph.tsx b/src/workflows/WorkflowGraph.tsx index e87f3ef..012bd5d 100644 --- a/src/workflows/WorkflowGraph.tsx +++ b/src/workflows/WorkflowGraph.tsx @@ -88,6 +88,17 @@ export function fitZoomCeiling(compact: boolean): number { * reframed rather than replaced. */ export const LAYOUT_TRANSITION_MS = 220; +/** + * When the RENDERED density flips inside the layout tween, as a fraction of + * LAYOUT_TRANSITION_MS. The box morphs first and the incoming content fades in + * mid-motion (see `.wf-node-body-in`) — flipping while everything is already + * moving is what keeps the swap from reading as a pop. Direction matters: the + * two densities' shells are closest in size near the COMPACT geometry, so + * growing (→ expanded) flips just after the box starts to grow, and shrinking + * (→ compact) flips once the card has nearly collapsed. + */ +const DENSITY_FLIP_AT = { grow: 0.12, shrink: 0.75 } as const; + /** Motion is opt-OUT, so it is only used where the reader's preference can be * read and says nothing against it. Somewhere without `matchMedia` cannot say * a reader tolerates movement, so it gets none. */ @@ -162,7 +173,7 @@ function StatusFooter({ ? `${rounds} round${rounds === 1 ? "" : "s"}` : undefined; return ( -
+
>) { }), }} > - + {/* Fade the CONTENT in when the density swap lands mid layout-morph; + the shell (this bordered tile / the card border) stays opaque so + the node never blinks out. */} + + + {d.badge && !state && ( {d.badge} @@ -415,7 +431,7 @@ export function WorkflowNode({ data }: NodeProps>) { )}
@@ -462,8 +478,10 @@ export function WorkflowNode({ data }: NodeProps>) { {/* The content region owns its overflow: each band is `shrink-0`, so a band that renders taller than its reservation (a consumer's font metrics) is CLIPPED here rather than squeezed — a squeezed band cuts a line of text - in half, and pushes the pinned footer off the card. */} -
+ in half, and pushes the pinned footer off the card. Fades in when the + density swap lands mid layout-morph; the card's border/surface (the + root) stays opaque so the node never blinks out. */} +
> | null>(null); // The canvas wrapper — measured when reframing, since the viewport math needs // the frame's real width/height and the RF instance doesn't expose them. @@ -722,6 +745,7 @@ export function WorkflowGraph({ if (!didInitialSeedRef.current) { didInitialSeedRef.current = true; setNodes(target); + setDisplayCompact(compact); return; } const inst = rfRef.current; @@ -729,6 +753,7 @@ export function WorkflowGraph({ // A hidden canvas (display:none ancestor) has no frame to fit into. if (!inst || !frame || frame.width === 0 || frame.height === 0) { setNodes(target); + setDisplayCompact(compact); return; } const endViewport = getViewportForBounds( @@ -750,9 +775,17 @@ export function WorkflowGraph({ typeof requestAnimationFrame === "undefined" ) { setNodes(target); + setDisplayCompact(compact); inst.setViewport(endViewport); return; } + // The rendered density lands mid-tween: growing flips early, shrinking + // flips once the card has nearly collapsed (see DENSITY_FLIP_AT). + const flipTimer = setTimeout( + () => setDisplayCompact(compact), + LAYOUT_TRANSITION_MS * + (compact ? DENSITY_FLIP_AT.shrink : DENSITY_FLIP_AT.grow), + ); const startViewport = inst.getViewport(); const startedAt = performance.now(); let raf = requestAnimationFrame(function step(now: number) { @@ -787,11 +820,15 @@ export function WorkflowGraph({ }); if (t < 1) raf = requestAnimationFrame(step); }); - // Cancelling un-schedules the next frame only. When the layout changes - // again mid-flight, the replacing tween starts from the CURRENT rendered - // geometry (nodesRef), so a rapid double-toggle redirects smoothly instead - // of jumping to either end. - return () => cancelAnimationFrame(raf); + // Cancelling un-schedules the next frame and the pending density flip. + // When the layout changes again mid-flight, the replacing tween starts + // from the CURRENT rendered geometry (nodesRef), so a rapid double-toggle + // redirects smoothly instead of jumping to either end — and a bounce that + // reverses before its flip fired never swaps the content at all. + return () => { + cancelAnimationFrame(raf); + clearTimeout(flipTimer); + }; }, [structural, compact, setNodes]); const handleNodeClick = useCallback( @@ -813,7 +850,7 @@ export function WorkflowGraph({ return ( - +
Date: Tue, 14 Jul 2026 21:41:22 -0500 Subject: [PATCH 5/5] fix(workflows): merge run state at write time inside the layout tween MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tween captured the run state once when it started, so a status tick landing inside the 220ms window was stomped by the next frame — and if it was the run's LAST tick (a terminal SSE event), the graph stayed stale until an unrelated re-render. Every write the transition effect makes now reads the state ref fresh, so the very next frame carries a mid-tween tick. Regression test drives a tick mid-tween and asserts the final frame kept it. --- src/workflows/WorkflowGraph.tsx | 68 +++++++++++---------- src/workflows/WorkflowGraphFitView.test.tsx | 30 +++++++++ 2 files changed, 66 insertions(+), 32 deletions(-) diff --git a/src/workflows/WorkflowGraph.tsx b/src/workflows/WorkflowGraph.tsx index 012bd5d..5ad89a0 100644 --- a/src/workflows/WorkflowGraph.tsx +++ b/src/workflows/WorkflowGraph.tsx @@ -731,20 +731,22 @@ export function WorkflowGraph({ // raced it and framed the OLD node sizes as often as the new ones. const didInitialSeedRef = useRef(false); useEffect(() => { - // The current run state, re-merged into every write this effect makes so a - // tween frame can't wipe a node's live status (the merge effect above only - // re-fires on its own deps). - const state = nodeStateRef.current; - const target = state - ? structural.nodes.map((n) => - state[n.id] ? { ...n, data: { ...n.data, state: state[n.id] } } : n, - ) - : structural.nodes; + // The run state is merged at WRITE time — read fresh from the ref inside + // every setNodes this effect makes — so a status tick landing mid-tween is + // carried by the very next frame instead of being stomped by a snapshot + // taken when the tween started (and then stranded if no later tick comes). + const withRunState = (nodes: Node[]) => { + const state = nodeStateRef.current; + if (!state) return nodes; + return nodes.map((n) => + state[n.id] ? { ...n, data: { ...n.data, state: state[n.id] } } : n, + ); + }; // ReactFlow's `fitView` prop frames the initial render — the first pass // only seeds the nodes and leaves the viewport alone. if (!didInitialSeedRef.current) { didInitialSeedRef.current = true; - setNodes(target); + setNodes(withRunState(structural.nodes)); setDisplayCompact(compact); return; } @@ -752,7 +754,7 @@ export function WorkflowGraph({ const frame = wrapperRef.current?.getBoundingClientRect(); // A hidden canvas (display:none ancestor) has no frame to fit into. if (!inst || !frame || frame.width === 0 || frame.height === 0) { - setNodes(target); + setNodes(withRunState(structural.nodes)); setDisplayCompact(compact); return; } @@ -774,7 +776,7 @@ export function WorkflowGraph({ !motionAllowed() || typeof requestAnimationFrame === "undefined" ) { - setNodes(target); + setNodes(withRunState(structural.nodes)); setDisplayCompact(compact); inst.setViewport(endViewport); return; @@ -792,26 +794,28 @@ export function WorkflowGraph({ const t = Math.min(1, (now - startedAt) / LAYOUT_TRANSITION_MS); const e = easeInOutCubic(t); setNodes( - t >= 1 - ? target - : target.map((n) => { - const p = prevById.get(n.id); - if (!p || p.width === undefined || p.height === undefined) { - return n; - } - const width = lerp(p.width, n.width ?? p.width, e); - const height = lerp(p.height, n.height ?? p.height, e); - return { - ...n, - position: { - x: lerp(p.position.x, n.position.x, e), - y: lerp(p.position.y, n.position.y, e), - }, - width, - height, - style: { ...n.style, width, height }, - }; - }), + withRunState( + t >= 1 + ? structural.nodes + : structural.nodes.map((n) => { + const p = prevById.get(n.id); + if (!p || p.width === undefined || p.height === undefined) { + return n; + } + const width = lerp(p.width, n.width ?? p.width, e); + const height = lerp(p.height, n.height ?? p.height, e); + return { + ...n, + position: { + x: lerp(p.position.x, n.position.x, e), + y: lerp(p.position.y, n.position.y, e), + }, + width, + height, + style: { ...n.style, width, height }, + }; + }), + ), ); inst.setViewport({ x: lerp(startViewport.x, endViewport.x, e), diff --git a/src/workflows/WorkflowGraphFitView.test.tsx b/src/workflows/WorkflowGraphFitView.test.tsx index 3fa2da1..ed57eab 100644 --- a/src/workflows/WorkflowGraphFitView.test.tsx +++ b/src/workflows/WorkflowGraphFitView.test.tsx @@ -24,6 +24,9 @@ import { afterEach, describe, expect, it, vi } from "vitest" const setViewport = vi.fn() const getViewport = vi.fn(() => ({ x: 0, y: 0, zoom: 1 })) const fitView = vi.fn() +/** Every `nodes` prop the component handed React Flow, in write order — the + * observable record of what the graph actually rendered over time. */ +let nodeWrites: Array> = [] vi.mock("@xyflow/react", async (importOriginal) => { const actual = await importOriginal() @@ -32,15 +35,18 @@ vi.mock("@xyflow/react", async (importOriginal) => { ...actual, ReactFlow: ({ children, + nodes, onInit, }: { children?: React.ReactNode + nodes?: Array<{ id: string; data: { state?: { status?: string } } }> onInit?: (instance: { setViewport: typeof setViewport getViewport: typeof getViewport fitView: typeof fitView }) => void }) => { + if (nodes) nodeWrites.push(nodes) useEffect(() => { onInit?.({ setViewport, getViewport, fitView }) }, [onInit]) @@ -98,6 +104,7 @@ afterEach(() => { setViewport.mockClear() getViewport.mockClear() fitView.mockClear() + nodeWrites = [] vi.unstubAllGlobals() vi.restoreAllMocks() }) @@ -154,6 +161,29 @@ describe("reframing on the density toggle", () => { expect(setViewport).toHaveBeenCalledTimes(1) }) + it("carries a run-state tick that lands mid-tween through to the final frame", async () => { + matchMediaSaying(false) + timerDrivenFrames() + measureableFrame() + // An empty nodeState opts the graph into run-overlay mode, same as a + // host that has a run but no per-node status yet. + const { rerender } = render() + const id = nodeWrites.at(-1)?.[0]?.id + expect(id).toBeTruthy() + + fireEvent.click(screen.getByRole("button", { name: /expand|compact/i })) + // The tick lands while the tween is running… + rerender( + , + ) + await new Promise((r) => setTimeout(r, LAYOUT_TRANSITION_MS * 3)) + // …and the LAST write the graph made still carries it: the tween merges + // run state at write time instead of stomping frames with a snapshot + // taken when it started. + const finalNode = nodeWrites.at(-1)?.find((n) => n.id === id) + expect(finalNode?.data.state?.status).toBe("succeeded") + }) + it("leaves the viewport alone while the canvas has no frame to fit into", async () => { matchMediaSaying(false) timerDrivenFrames()