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 3539e98..5ad89a0 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,
@@ -66,27 +68,54 @@ 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 } 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;
+}
+
+/** 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;
+
+/**
+ * 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 FIT_VIEW = { padding: 0.16, minZoom: 0.55, maxZoom: 1 } as const;
-
-/** 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 =
+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. */
+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 {
@@ -144,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}
@@ -397,7 +431,7 @@ export function WorkflowNode({ data }: NodeProps>) {
)}
@@ -444,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.
+ 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
@@ -638,13 +682,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.
@@ -662,31 +707,133 @@ 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 (with their new sizes) 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.
+ //
+ // 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.
//
- // Animated, because a reader is watching: the nodes resize in place and the
- // viewport glides to the new framing.
- const didInitialFitRef = useRef(false);
+ // 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 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 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(withRunState(structural.nodes));
+ setDisplayCompact(compact);
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()));
- return () => cancelAnimationFrame(raf);
- }, [structural]);
+ 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(withRunState(structural.nodes));
+ setDisplayCompact(compact);
+ 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(withRunState(structural.nodes));
+ 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) {
+ const t = Math.min(1, (now - startedAt) / LAYOUT_TRANSITION_MS);
+ const e = easeInOutCubic(t);
+ setNodes(
+ 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),
+ 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 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(
(_event: ReactMouseEvent, node: Node) => {
@@ -707,8 +854,8 @@ export function WorkflowGraph({
return (
-
-
+
+
({ 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()
@@ -22,13 +35,20 @@ vi.mock("@xyflow/react", async (importOriginal) => {
...actual,
ReactFlow: ({
children,
+ nodes,
onInit,
}: {
children?: React.ReactNode
- onInit?: (instance: { fitView: typeof fitView }) => void
+ 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?.({ fitView })
+ onInit?.({ setViewport, getViewport, fitView })
}, [onInit])
return {children}
},
@@ -39,7 +59,7 @@ vi.mock("@xyflow/react", async (importOriginal) => {
}
})
-const { WorkflowGraph } = await import("./WorkflowGraph")
+const { WorkflowGraph, LAYOUT_TRANSITION_MS } = await import("./WorkflowGraph")
const YAML = `
do:
@@ -50,49 +70,129 @@ 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)
+
+/** 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()
+ nodeWrites = []
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("hands React Flow the ANIMATED framing, not the bare constant", 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.
+ expect(setViewport).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole("button", { name: /expand|compact/i }))
+
+ // 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),
+ })
+ // …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 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(fitView).toHaveBeenCalledTimes(1))
- expect(fitView).toHaveBeenCalledWith(
- expect.objectContaining({ duration: 220 }),
+ await waitFor(() => expect(setViewport).toHaveBeenCalledTimes(1))
+ 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("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("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("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 }))
- await waitFor(() => expect(fitView).toHaveBeenCalledTimes(1))
- // Same framing, no transition.
- const options = fitView.mock.calls[0][0]
- expect(options).not.toHaveProperty("duration")
- expect(options).toHaveProperty("padding")
+ 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 190510e..b03e7d8 100644
--- a/src/workflows/WorkflowNode.test.tsx
+++ b/src/workflows/WorkflowNode.test.tsx
@@ -9,7 +9,7 @@ import {
buildStyledEdges,
DensityContext,
DirectionContext,
- fitViewOnLayoutChange,
+ fitZoomCeiling,
WorkflowGraph,
WorkflowNode,
} from "./WorkflowGraph";
@@ -33,46 +33,14 @@ 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);
- expect(glide.maxZoom).toBe(still.maxZoom);
+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);
});
});