@tangle-network/sandbox-ui 0.134.11 → 0.135.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
- import { A as triggerNodeIndex, B as fmtTokens, C as COMPACT_NODE_SIZE, D as buildWorkflowGraph, I as shortModel, L as clampPreview, M as worstSeverity, O as isStructuralKind, R as fmtCost, S as statusBorder, _ as emptySlotLabel, a as NodeMark, b as problemSurface, c as RESTING_BORDER, d as STATUS_LABEL, g as edgeColor, h as StatusPill, j as wfEdgeId, l as RESTING_SHADOW, m as StatusFooter, n as classifyOutput, o as ProblemMarker, s as ProblemMessages, t as NodeOutputBody, u as STATUS_COLOR, v as kindAccent, x as problemTitle, y as problemBorder, z as fmtDuration } from "./node-output-CHlf3tni.js";
1
+ import { A as triggerNodeIndex, B as fmtTokens, C as COMPACT_NODE_SIZE, D as buildWorkflowGraph, I as shortModel, L as clampPreview, M as worstSeverity, O as isStructuralKind, R as fmtCost, S as statusBorder, _ as emptySlotLabel, a as NodeMark, b as problemSurface, c as RESTING_BORDER, d as STATUS_LABEL, g as edgeColor, h as StatusPill, j as wfEdgeId, l as RESTING_SHADOW, m as StatusFooter, n as classifyOutput, o as ProblemMarker, s as ProblemMessages, t as NodeOutputBody, u as STATUS_COLOR, v as kindAccent, x as problemTitle, y as problemBorder, z as fmtDuration } from "./node-output-Bs51H68g.js";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
- import { Maximize2, Minimize2, Plus, X } from "lucide-react";
5
- import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, Panel, Position, ReactFlow, getSmoothStepPath, getViewportForBounds, useEdgesState, useNodesState } from "@xyflow/react";
4
+ import { Expand, Maximize2, Minimize2, Plus, Shrink, X } from "lucide-react";
5
+ import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, MiniMap, Panel, Position, ReactFlow, ViewportPortal, getSmoothStepPath, getViewportForBounds, useEdgesState, useNodesState, useStore } from "@xyflow/react";
6
6
  import "@xyflow/react/dist/style.css";
7
7
  //#region src/workflows/flow-graph.ts
8
8
  /**
@@ -95,6 +95,9 @@ function buildFlowGraph(yaml, optionsOrNodeState) {
95
95
  const targetPosition = isLR ? Position.Left : Position.Top;
96
96
  return {
97
97
  error: graph.error,
98
+ compact,
99
+ direction,
100
+ ...graph.spacing ? { spacing: graph.spacing } : {},
98
101
  nodes: graph.nodes.map((n) => ({
99
102
  id: n.id,
100
103
  type: "wfNode",
@@ -287,6 +290,396 @@ function clearOfNodeBoxes(point, boxes, direction, half) {
287
290
  return Math.abs(up) <= Math.abs(down) ? up : down;
288
291
  }
289
292
  //#endregion
293
+ //#region src/workflows/elk-layout.ts
294
+ /** One ELK instance per page. `null` once loading failed, so a host without
295
+ * the peer pays for one failed import, not one per render. */
296
+ let elkInstance;
297
+ function loadElk() {
298
+ elkInstance ??= import("elkjs/lib/elk.bundled.js").then((mod) => {
299
+ const Elk = mod.default;
300
+ return new Elk();
301
+ }).catch(() => null);
302
+ return elkInstance;
303
+ }
304
+ /**
305
+ * Whether the graph is worth the ELK pass: anything but a plain line. A line
306
+ * has nothing to order or route, and `model.ts` folds a long one into rows
307
+ * that read in order, which a layered layout would not improve.
308
+ */
309
+ function wantsElkLayout(graph) {
310
+ if (graph.error || graph.nodes.length < 3) return false;
311
+ const outdegree = /* @__PURE__ */ new Map();
312
+ const indegree = /* @__PURE__ */ new Map();
313
+ for (const e of graph.edges) {
314
+ if (e.data?.whenLabel !== void 0 || e.data?.backEdge) return true;
315
+ outdegree.set(e.source, (outdegree.get(e.source) ?? 0) + 1);
316
+ indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1);
317
+ }
318
+ for (const n of outdegree.values()) if (n > 1) return true;
319
+ for (const n of indegree.values()) if (n > 1) return true;
320
+ return false;
321
+ }
322
+ /**
323
+ * A join that runs when ANY of its inputs fails (`join: any_failed`) is a
324
+ * failure handler, not a step in the flow: the factory's `failed` node is fed
325
+ * by ten steps across every stage. Drawn as ten real edges it pulls the whole
326
+ * layout toward one corner and buries the flow under lines that only matter
327
+ * when something breaks. Its inputs are laid out and drawn as one caption on
328
+ * the node instead; the node itself still lays out and opens like any other.
329
+ */
330
+ function isFailureSink(graph, nodeId) {
331
+ return graph.nodes.find((n) => n.id === nodeId)?.data.config?.join === "any_failed" && !graph.edges.some((e) => e.source === nodeId);
332
+ }
333
+ /**
334
+ * ELK options for one flow direction. Every value here was chosen against the
335
+ * shapes the catalogue ships (a 22-step factory with two loops and a ten-way
336
+ * failure join, a four-lane review) rather than taken as defaults:
337
+ * - model order breaks ties, so a layer reads in the order the author wrote
338
+ * the steps unless reordering removes a crossing;
339
+ * - cycle breaking follows the same order, so the edge drawn as the loop's
340
+ * return is the one that points back up the definition, as the model's own
341
+ * back-edge classification marks it;
342
+ * - orthogonal routing keeps every edge on horizontal and vertical runs, and
343
+ * a guard chip sits on its edge's longest run (see the renderer). Chips are
344
+ * NOT given to ELK as label boxes: a label is laid out as a node of its own
345
+ * layer, which on the factory doubled the layer count and the width.
346
+ */
347
+ function elkLayoutOptions(direction, spacing) {
348
+ return {
349
+ "elk.algorithm": "layered",
350
+ "elk.direction": direction === "LR" ? "RIGHT" : "DOWN",
351
+ "elk.edgeRouting": "ORTHOGONAL",
352
+ "elk.layered.considerModelOrder.strategy": "NODES_AND_EDGES",
353
+ "elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
354
+ "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
355
+ "elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
356
+ "elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED",
357
+ "elk.layered.feedbackEdges": "true",
358
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(Math.max(spacing.rank, 40)),
359
+ "elk.spacing.nodeNode": String(Math.max(spacing.cross, 20)),
360
+ "elk.spacing.edgeNode": "14",
361
+ "elk.spacing.edgeEdge": "10",
362
+ "elk.layered.spacing.edgeNodeBetweenLayers": "14",
363
+ "elk.layered.spacing.edgeEdgeBetweenLayers": "10",
364
+ "elk.padding": "[top=0,left=0,bottom=0,right=0]",
365
+ "elk.separateConnectedComponents": "false"
366
+ };
367
+ }
368
+ /**
369
+ * Cut a long layered graph into rows stacked toward `aspect` (width / height
370
+ * of the canvas it is drawn in), so a fourteen-layer pipeline uses the
371
+ * panel's height instead of shrinking to fit one strip. ELK picks the cuts
372
+ * and routes the edges that cross them.
373
+ */
374
+ function wrapOptions(aspect) {
375
+ return {
376
+ "elk.aspectRatio": String(aspect),
377
+ "elk.layered.wrapping.strategy": "MULTI_EDGE",
378
+ "elk.layered.wrapping.additionalEdgeSpacing": "16",
379
+ "elk.layered.wrapping.correctionFactor": "1.0"
380
+ };
381
+ }
382
+ /** The graph in ELK's terms: every node at its authoritative size, and every
383
+ * edge except a failure sink's inputs. */
384
+ /**
385
+ * Where a node's edges attach, relative to its box: the handle the card
386
+ * draws. A compact node's box is much bigger than its tile (the name sits
387
+ * under it), so routing to the box would stop every edge short of the tile it
388
+ * points at. One port per side, at the handle, makes ELK route to exactly
389
+ * where the card draws its connection point.
390
+ */
391
+ function handleOffset(width, height, direction, compact) {
392
+ if (direction === "LR") {
393
+ const y = compact ? 38 : height / 2;
394
+ return {
395
+ inX: 0,
396
+ inY: y,
397
+ outX: width,
398
+ outY: y
399
+ };
400
+ }
401
+ const x = compact ? 38 : width / 2;
402
+ return {
403
+ inX: x,
404
+ inY: 0,
405
+ outX: x,
406
+ outY: height
407
+ };
408
+ }
409
+ function toElkGraph(graph, direction, wrapAspect, compact = false, foldFailureInputs = true) {
410
+ const spacing = graph.spacing ?? {
411
+ rank: 72,
412
+ cross: 24
413
+ };
414
+ const sinks = foldedSinks(graph, foldFailureInputs);
415
+ return {
416
+ id: "root",
417
+ layoutOptions: {
418
+ ...elkLayoutOptions(direction, spacing),
419
+ ...wrapAspect !== void 0 ? wrapOptions(wrapAspect) : {}
420
+ },
421
+ children: graph.nodes.map((n) => {
422
+ const width = n.width ?? 0;
423
+ const height = n.height ?? 0;
424
+ const at = handleOffset(width, height, direction, compact);
425
+ return {
426
+ id: n.id,
427
+ width,
428
+ height,
429
+ layoutOptions: {
430
+ "elk.portConstraints": "FIXED_POS",
431
+ ...sinks.has(n.id) ? { "elk.layered.layering.layerConstraint": "LAST" } : {}
432
+ },
433
+ ports: [{
434
+ id: `${n.id}:in`,
435
+ x: at.inX,
436
+ y: at.inY,
437
+ width: 0,
438
+ height: 0
439
+ }, {
440
+ id: `${n.id}:out`,
441
+ x: at.outX,
442
+ y: at.outY,
443
+ width: 0,
444
+ height: 0
445
+ }]
446
+ };
447
+ }),
448
+ edges: graph.edges.filter((e) => !sinks.has(e.target)).map((e) => ({
449
+ id: e.id,
450
+ sources: [`${e.source}:out`],
451
+ targets: [`${e.target}:in`]
452
+ }))
453
+ };
454
+ }
455
+ /** The middle of a polyline's longest straight run: the one place on a routed
456
+ * edge with room for a chip, and distinct per edge even when several leave
457
+ * the same node together. */
458
+ function longestRunMidpoint(points) {
459
+ let best = 0;
460
+ let at = {
461
+ x: points[0].x,
462
+ y: points[0].y
463
+ };
464
+ for (let i = 1; i < points.length; i += 1) {
465
+ const a = points[i - 1];
466
+ const b = points[i];
467
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
468
+ if (len > best) {
469
+ best = len;
470
+ at = {
471
+ x: (a.x + b.x) / 2,
472
+ y: (a.y + b.y) / 2
473
+ };
474
+ }
475
+ }
476
+ return at;
477
+ }
478
+ /** Flatten an ELK edge's sections into one polyline. */
479
+ function edgePoints(edge) {
480
+ const points = [];
481
+ for (const section of edge.sections ?? []) points.push(section.startPoint, ...section.bendPoints ?? [], section.endPoint);
482
+ return points;
483
+ }
484
+ /**
485
+ * Apply an ELK result to the graph: new positions, a route on every edge, and
486
+ * frames around parallel fan-outs. Pure, so the mapping is checkable without
487
+ * running ELK. Returns null when the result does not cover the graph (a node
488
+ * ELK did not place), which the caller treats as "keep the first layout".
489
+ */
490
+ /** The failure handlers whose inputs this layout folds. */
491
+ function foldedSinks(graph, fold) {
492
+ if (!fold) return /* @__PURE__ */ new Set();
493
+ return new Set(graph.nodes.filter((n) => isFailureSink(graph, n.id)).map((n) => n.id));
494
+ }
495
+ function applyElkLayout(graph, laid, foldFailureInputs = true) {
496
+ const placed = /* @__PURE__ */ new Map();
497
+ for (const child of laid.children ?? []) {
498
+ if (child.x === void 0 || child.y === void 0) return null;
499
+ placed.set(child.id, {
500
+ x: child.x,
501
+ y: child.y
502
+ });
503
+ }
504
+ if (graph.nodes.some((n) => !placed.has(n.id))) return null;
505
+ const routed = /* @__PURE__ */ new Map();
506
+ for (const e of laid.edges ?? []) routed.set(e.id, e);
507
+ const sinks = foldedSinks(graph, foldFailureInputs);
508
+ const sinkInputs = /* @__PURE__ */ new Map();
509
+ for (const e of graph.edges) if (sinks.has(e.target)) sinkInputs.set(e.target, (sinkInputs.get(e.target) ?? 0) + 1);
510
+ const nodes = graph.nodes.map((n) => {
511
+ const inputs = sinkInputs.get(n.id);
512
+ return {
513
+ ...n,
514
+ position: placed.get(n.id),
515
+ ...inputs !== void 0 ? { data: {
516
+ ...n.data,
517
+ subtitle: `If any of ${inputs} step${inputs === 1 ? "" : "s"} fails`
518
+ } } : {}
519
+ };
520
+ });
521
+ const edges = graph.edges.map((e) => {
522
+ if (sinkInputs.has(e.target)) return {
523
+ ...e,
524
+ hidden: true
525
+ };
526
+ const r = routed.get(e.id);
527
+ const points = r ? edgePoints(r) : [];
528
+ if (points.length < 2) return e;
529
+ const from = placed.get(e.source);
530
+ const to = placed.get(e.target);
531
+ return {
532
+ ...e,
533
+ type: WF_EDGE_TYPE,
534
+ data: {
535
+ ...e.data ?? { kind: "spine" },
536
+ route: {
537
+ points: points.map((p) => ({
538
+ x: p.x,
539
+ y: p.y
540
+ })),
541
+ label: longestRunMidpoint(points),
542
+ from,
543
+ to
544
+ }
545
+ }
546
+ };
547
+ });
548
+ const laidOut = {
549
+ ...graph,
550
+ nodes,
551
+ edges,
552
+ engine: "elk"
553
+ };
554
+ const groups = parallelFrames(laidOut);
555
+ return groups.length > 0 ? {
556
+ ...laidOut,
557
+ groups
558
+ } : laidOut;
559
+ }
560
+ /** Air between a frame and the cards inside it, and the band its caption sits
561
+ * in above them. */
562
+ const FRAME_PAD = 10;
563
+ const FRAME_CAPTION = 18;
564
+ /**
565
+ * Frames around PARALLEL fan-outs: two or more steps that start together
566
+ * because the same steps finished, over unguarded edges. A guarded fan-out is
567
+ * a choice, not parallel work, and gets no frame — its edge chips already say
568
+ * which way it goes. Each member is followed down its own lane (a single
569
+ * unguarded successor that only it feeds) so a review-then-verify lane is
570
+ * framed whole rather than just its first step.
571
+ *
572
+ * A frame that would enclose a card outside the fan-out is dropped: a frame
573
+ * claiming an unrelated step is worse than no frame.
574
+ */
575
+ function parallelFrames(graph) {
576
+ const forward = graph.edges.filter((e) => !e.data?.backEdge && !e.hidden);
577
+ const preds = /* @__PURE__ */ new Map();
578
+ const succs = /* @__PURE__ */ new Map();
579
+ for (const e of forward) {
580
+ preds.set(e.target, [...preds.get(e.target) ?? [], e]);
581
+ succs.set(e.source, [...succs.get(e.source) ?? [], e]);
582
+ }
583
+ const byId = new Map(graph.nodes.map((n) => [n.id, n]));
584
+ const siblings = /* @__PURE__ */ new Map();
585
+ for (const n of graph.nodes) {
586
+ if (triggerNodeIndex(n.id) !== null) continue;
587
+ const incoming = preds.get(n.id) ?? [];
588
+ if (incoming.length === 0) continue;
589
+ if (incoming.some((e) => e.data?.whenLabel !== void 0)) continue;
590
+ if (n.data.config && "if" in n.data.config) continue;
591
+ const key = incoming.map((e) => e.source).sort().join("|");
592
+ siblings.set(key, [...siblings.get(key) ?? [], n.id]);
593
+ }
594
+ const frames = [];
595
+ for (const [key, members] of siblings) {
596
+ if (members.length < 2) continue;
597
+ const framed = /* @__PURE__ */ new Set();
598
+ for (const start of members) {
599
+ let at = start;
600
+ framed.add(at);
601
+ for (let hop = 0; hop < graph.nodes.length; hop += 1) {
602
+ const out = succs.get(at) ?? [];
603
+ if (out.length !== 1 || out[0].data?.whenLabel !== void 0) break;
604
+ const next = out[0].target;
605
+ if ((preds.get(next) ?? []).length !== 1 || framed.has(next)) break;
606
+ const config = byId.get(next)?.data.config;
607
+ if (config && "if" in config) break;
608
+ framed.add(next);
609
+ at = next;
610
+ }
611
+ }
612
+ const boxes = [...framed].map((id) => byId.get(id)).filter(Boolean);
613
+ const x0 = Math.min(...boxes.map((n) => n.position.x)) - FRAME_PAD;
614
+ const y0 = Math.min(...boxes.map((n) => n.position.y)) - FRAME_PAD - FRAME_CAPTION;
615
+ const x1 = Math.max(...boxes.map((n) => n.position.x + (n.width ?? 0))) + FRAME_PAD;
616
+ const y1 = Math.max(...boxes.map((n) => n.position.y + (n.height ?? 0))) + FRAME_PAD;
617
+ if (graph.nodes.some((n) => {
618
+ if (framed.has(n.id)) return false;
619
+ const nx1 = n.position.x + (n.width ?? 0);
620
+ const ny1 = n.position.y + (n.height ?? 0);
621
+ return n.position.x < x1 && nx1 > x0 && n.position.y < y1 && ny1 > y0;
622
+ })) continue;
623
+ frames.push({
624
+ id: `frame:${key}`,
625
+ x: x0,
626
+ y: y0,
627
+ width: x1 - x0,
628
+ height: y1 - y0,
629
+ lanes: members.length,
630
+ agents: boxes.filter((n) => n.data.kind === "agent.run").length
631
+ });
632
+ }
633
+ return frames;
634
+ }
635
+ /**
636
+ * Lay the graph out with ELK. Resolves to null when ELK is unavailable or the
637
+ * layout fails, and the caller keeps the synchronous layout.
638
+ */
639
+ async function layoutWithElk(graph, direction, compact, canvasAspect = DEFAULT_CANVAS_ASPECT, options = {}) {
640
+ const fold = options.foldFailureInputs ?? true;
641
+ const elk = await loadElk();
642
+ if (!elk) return null;
643
+ try {
644
+ const plain = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, void 0, compact, fold)), fold);
645
+ if (!plain) return null;
646
+ const plainAspect = boundsAspect(plain);
647
+ if (direction !== "LR" || graph.nodes.length < WRAP_MIN_NODES || plainAspect < canvasAspect * WRAP_WHEN_WIDER) return plain;
648
+ const wrapped = applyElkLayout(graph, await elk.layout(toElkGraph(graph, direction, canvasAspect, compact, fold)), fold);
649
+ if (!wrapped) return plain;
650
+ return fitScale(wrapped, canvasAspect) > fitScale(plain, canvasAspect) ? wrapped : plain;
651
+ } catch {
652
+ return null;
653
+ }
654
+ }
655
+ /** The aspect a graph is framed in when the host says nothing: the workflow
656
+ * page's canvas, about 1100 x 480. */
657
+ const DEFAULT_CANVAS_ASPECT = 2.2;
658
+ /** How much wider than its canvas a single band must be before wrapping is
659
+ * tried. */
660
+ const WRAP_WHEN_WIDER = 1.6;
661
+ /** Below this many nodes a graph reads better as one band: cut into rows, a
662
+ * six-step loop sends its edges across the row break for no gain in size. */
663
+ const WRAP_MIN_NODES = 12;
664
+ function bounds(graph) {
665
+ const xs = graph.nodes.flatMap((n) => [n.position.x, n.position.x + (n.width ?? 0)]);
666
+ const ys = graph.nodes.flatMap((n) => [n.position.y, n.position.y + (n.height ?? 0)]);
667
+ return {
668
+ width: Math.max(...xs) - Math.min(...xs),
669
+ height: Math.max(...ys) - Math.min(...ys)
670
+ };
671
+ }
672
+ function boundsAspect(graph) {
673
+ const { width, height } = bounds(graph);
674
+ return width / Math.max(height, 1);
675
+ }
676
+ /** The scale a graph fits a unit-height canvas of `aspect` at: bigger is a
677
+ * more readable graph. */
678
+ function fitScale(graph, aspect) {
679
+ const { width, height } = bounds(graph);
680
+ return Math.min(aspect / Math.max(width, 1), 1 / Math.max(height, 1));
681
+ }
682
+ //#endregion
290
683
  //#region src/workflows/WorkflowGraph.tsx
291
684
  /**
292
685
  * n8n-style node-graph view of a workflow, rendered from its YAML definition.
@@ -341,6 +734,22 @@ function fitZoomFloor(compact) {
341
734
  return compact ? .55 : .35;
342
735
  }
343
736
  /**
737
+ * The floor for a graph ELK laid out. ELK wraps a long graph into rows sized
738
+ * for the canvas, so its fit is the whole workflow at once — the shape Drew
739
+ * asked to see — and names stay legible through semantic zoom
740
+ * (useCompactTitleScale). Clamping it at the compact floor instead would crop
741
+ * a layout built to be seen whole.
742
+ */
743
+ const ELK_FIT_FLOOR = .28;
744
+ /**
745
+ * The fit floor a laid-out graph opens at. Only a left-to-right ELK layout
746
+ * is built to be seen whole; a top-to-bottom one is a phone's column, read by
747
+ * panning down it from the top, so it keeps the readable compact floor.
748
+ */
749
+ function elkFloor(graph, direction) {
750
+ return graph.engine === "elk" && direction === "LR" ? ELK_FIT_FLOOR : void 0;
751
+ }
752
+ /**
344
753
  * The rectangle the LAID-OUT nodes occupy, in flow coordinates.
345
754
  *
346
755
  * Read straight off the layouter's own position + size rather than through
@@ -378,6 +787,39 @@ function layoutBounds(nodes) {
378
787
  height: maxY - minY
379
788
  };
380
789
  }
790
+ /** What the camera frames: the nodes, and the parallel frames drawn around
791
+ * them, whose captions sit above the first lane. */
792
+ function graphBounds(graph) {
793
+ const nodes = layoutBounds(graph.nodes);
794
+ if (!graph.groups || graph.groups.length === 0) return nodes;
795
+ const x0 = Math.min(nodes.x, ...graph.groups.map((g) => g.x));
796
+ const y0 = Math.min(nodes.y, ...graph.groups.map((g) => g.y));
797
+ const x1 = Math.max(nodes.x + nodes.width, ...graph.groups.map((g) => g.x + g.width));
798
+ const y1 = Math.max(nodes.y + nodes.height, ...graph.groups.map((g) => g.y + g.height));
799
+ return {
800
+ x: x0,
801
+ y: y0,
802
+ width: x1 - x0,
803
+ height: y1 - y0
804
+ };
805
+ }
806
+ /**
807
+ * The camera a graph opens at: {@link framingViewport} over the graph and its
808
+ * frames, at the floor its layout calls for. A top-to-bottom column wider
809
+ * than a phone is read down its spine, so the camera centres on the entry
810
+ * step rather than on the column's left edge, where only loop returns run.
811
+ */
812
+ function openingViewport(graph, width, height, compact, direction) {
813
+ const bounds = graphBounds(graph);
814
+ const view = framingViewport(bounds, width, height, compact, elkFloor(graph, direction));
815
+ const entry = graph.nodes[0];
816
+ if (direction !== "TB" || !entry || bounds.width * view.zoom <= width) return view;
817
+ const entryCenter = entry.position.x + (entry.width ?? 0) / 2;
818
+ return {
819
+ ...view,
820
+ x: width / 2 - entryCenter * view.zoom
821
+ };
822
+ }
381
823
  /**
382
824
  * The camera that frames `bounds` inside a `width`×`height` canvas.
383
825
  *
@@ -397,8 +839,8 @@ function layoutBounds(nodes) {
397
839
  * is already centered with slack on both sides, so `max` returns it untouched,
398
840
  * and nothing here has to re-derive React Flow's own reading of `padding`.
399
841
  */
400
- function framingViewport(bounds, width, height, compact) {
401
- const fit = getViewportForBounds(bounds, width, height, fitZoomFloor(compact), fitZoomCeiling(compact), FIT_VIEW.padding);
842
+ function framingViewport(bounds, width, height, compact, floor = fitZoomFloor(compact)) {
843
+ const fit = getViewportForBounds(bounds, width, height, floor, fitZoomCeiling(compact), FIT_VIEW.padding);
402
844
  /** Slide `offset` just far enough that the bounds' leading edge is on canvas. */
403
845
  const anchored = (offset, origin) => offset + Math.max(0, -(offset + origin * fit.zoom));
404
846
  return {
@@ -657,6 +1099,7 @@ function WorkflowNode({ id, data, sourcePosition, targetPosition }) {
657
1099
  className: handleClass,
658
1100
  style: compact ? compactHandleStyle(sourcePos, isLR) : void 0
659
1101
  })] });
1102
+ const titleScale = useCompactTitleScale(compact);
660
1103
  if (compact) return /* @__PURE__ */ jsxs("div", {
661
1104
  className: `relative flex h-full w-full ${isLR ? "flex-col items-center" : "flex-row items-center"}`,
662
1105
  children: [
@@ -712,10 +1155,14 @@ function WorkflowNode({ id, data, sourcePosition, targetPosition }) {
712
1155
  children: [
713
1156
  /* @__PURE__ */ jsx("div", {
714
1157
  className: "truncate font-semibold text-[12px] text-foreground leading-tight",
1158
+ style: titleScale > 1 ? {
1159
+ fontSize: 12 * titleScale,
1160
+ ...isLR ? { marginInline: -16 } : {}
1161
+ } : void 0,
715
1162
  title: d.title,
716
1163
  children: d.title
717
1164
  }),
718
- subtitle && /* @__PURE__ */ jsx("div", {
1165
+ subtitle && titleScale <= SUBTITLE_MAX_SCALE && /* @__PURE__ */ jsx("div", {
719
1166
  className: "truncate text-[11px] text-muted-foreground leading-tight",
720
1167
  title: subtitle,
721
1168
  children: subtitle
@@ -944,6 +1391,214 @@ function releasedOnCanvas(at, frame) {
944
1391
  * then never revisited.
945
1392
  */
946
1393
  const NodeBoxesContext = createContext([]);
1394
+ /** A node's current top-left, read from the store so an edge notices its end
1395
+ * moving (a drag, a tween). Null while the node is not in the store. */
1396
+ function useInternalNodePosition(id) {
1397
+ return useStore(useCallback((state) => {
1398
+ const node = state.nodeLookup.get(id);
1399
+ return node ? node.position : null;
1400
+ }, [id]), (a, b) => a === b || a !== null && b !== null && samePoint(a, b));
1401
+ }
1402
+ function samePoint(a, b) {
1403
+ return Math.abs(a.x - b.x) < .5 && Math.abs(a.y - b.y) < .5;
1404
+ }
1405
+ /** Corner radius of a routed edge's bends, capped by the shorter of the two
1406
+ * segments that meet there so a tight jog never overshoots. */
1407
+ const ROUTE_CORNER = 8;
1408
+ /** An orthogonal polyline as an SVG path with rounded bends. */
1409
+ function roundedPolyline(points) {
1410
+ if (points.length === 0) return "";
1411
+ let d = `M ${points[0].x} ${points[0].y}`;
1412
+ for (let i = 1; i < points.length - 1; i += 1) {
1413
+ const prev = points[i - 1];
1414
+ const at = points[i];
1415
+ const next = points[i + 1];
1416
+ const inLen = Math.hypot(at.x - prev.x, at.y - prev.y);
1417
+ const outLen = Math.hypot(next.x - at.x, next.y - at.y);
1418
+ const r = Math.min(ROUTE_CORNER, inLen / 2, outLen / 2);
1419
+ if (r < .5) {
1420
+ d += ` L ${at.x} ${at.y}`;
1421
+ continue;
1422
+ }
1423
+ const before = {
1424
+ x: at.x - (at.x - prev.x) / inLen * r,
1425
+ y: at.y - (at.y - prev.y) / inLen * r
1426
+ };
1427
+ const after = {
1428
+ x: at.x + (next.x - at.x) / outLen * r,
1429
+ y: at.y + (next.y - at.y) / outLen * r
1430
+ };
1431
+ d += ` L ${before.x} ${before.y} Q ${at.x} ${at.y} ${after.x} ${after.y}`;
1432
+ }
1433
+ const last = points[points.length - 1];
1434
+ return `${d} L ${last.x} ${last.y}`;
1435
+ }
1436
+ /** The point halfway along a polyline, where an unlabelled route's insert
1437
+ * control or problem chip sits. */
1438
+ function polylineMidpoint(points) {
1439
+ const lengths = points.slice(1).map((p, i) => Math.hypot(p.x - points[i].x, p.y - points[i].y));
1440
+ let remaining = lengths.reduce((sum, len) => sum + len, 0) / 2;
1441
+ for (let i = 0; i < lengths.length; i += 1) {
1442
+ if (remaining <= lengths[i] && lengths[i] > 0) {
1443
+ const t = remaining / lengths[i];
1444
+ return {
1445
+ x: points[i].x + (points[i + 1].x - points[i].x) * t,
1446
+ y: points[i].y + (points[i + 1].y - points[i].y) * t
1447
+ };
1448
+ }
1449
+ remaining -= lengths[i];
1450
+ }
1451
+ return points[points.length - 1] ?? {
1452
+ x: 0,
1453
+ y: 0
1454
+ };
1455
+ }
1456
+ /**
1457
+ * The graph `buildFlowGraph` laid out, re-laid by ELK when it branches,
1458
+ * guards or loops (see elk-layout.ts).
1459
+ *
1460
+ * While ELK works on a new structure the graph ON SCREEN stays, so a relayout
1461
+ * is one move from what the reader sees to the new layout — never a detour
1462
+ * through an older laid-out graph. Before anything has been shown the canvas
1463
+ * stays hidden (`ready` false) rather than flashing a layout it is about to
1464
+ * replace, but only until {@link REVEAL_DEADLINE_MS}: ELK that is slow, absent
1465
+ * or failing then leaves the synchronous layout on screen, so no failure can
1466
+ * leave the canvas blank. `ready` gates the first reveal only.
1467
+ */
1468
+ function useElkLayout(base, direction, compact, canvasAspect, foldFailureInputs) {
1469
+ const wanted = wantsElkLayout(base);
1470
+ const [laid, setLaid] = useState(null);
1471
+ const [deadlinePassed, setDeadlinePassed] = useState(false);
1472
+ const shownRef = useRef(null);
1473
+ useEffect(() => {
1474
+ if (!wanted || canvasAspect === null) return;
1475
+ let live = true;
1476
+ layoutWithElk(base, direction, compact, canvasAspect, { foldFailureInputs }).then((graph) => {
1477
+ if (live) setLaid({
1478
+ base,
1479
+ graph: graph ?? base
1480
+ });
1481
+ });
1482
+ return () => {
1483
+ live = false;
1484
+ };
1485
+ }, [
1486
+ base,
1487
+ direction,
1488
+ wanted,
1489
+ canvasAspect,
1490
+ foldFailureInputs
1491
+ ]);
1492
+ useEffect(() => {
1493
+ if (deadlinePassed) return;
1494
+ const timer = setTimeout(() => setDeadlinePassed(true), REVEAL_DEADLINE_MS);
1495
+ return () => clearTimeout(timer);
1496
+ }, [deadlinePassed]);
1497
+ let graph;
1498
+ let settled;
1499
+ if (!wanted) {
1500
+ graph = base;
1501
+ settled = true;
1502
+ } else if (laid?.base === base) {
1503
+ graph = laid.graph;
1504
+ settled = true;
1505
+ } else {
1506
+ graph = shownRef.current ?? base;
1507
+ settled = false;
1508
+ }
1509
+ const ready = settled || shownRef.current !== null || deadlinePassed;
1510
+ if (ready) shownRef.current = graph;
1511
+ return {
1512
+ graph,
1513
+ ready
1514
+ };
1515
+ }
1516
+ /** How long a first ELK layout may keep the canvas hidden before the
1517
+ * synchronous layout is shown instead. */
1518
+ const REVEAL_DEADLINE_MS = 600;
1519
+ /**
1520
+ * The canvas's shape, in three buckets: portrait (a phone), square, and the
1521
+ * wide panel the workflow pages use. Bucketed so a resize re-lays the graph
1522
+ * out only when its shape class changes, never on every pixel. Null until
1523
+ * the canvas has been measured.
1524
+ *
1525
+ * Returns a CALLBACK ref: the canvas element can mount after the graph does
1526
+ * (a first render with no YAML shows a placeholder instead), and a mount-time
1527
+ * effect would never see it — leaving the canvas unmeasured and ELK unrun.
1528
+ */
1529
+ function useCanvasAspect() {
1530
+ const [aspect, setAspect] = useState(null);
1531
+ const observerRef = useRef(null);
1532
+ return [aspect, useCallback((el) => {
1533
+ observerRef.current?.disconnect();
1534
+ observerRef.current = null;
1535
+ if (!el) return;
1536
+ const measure = () => {
1537
+ const { width, height } = el.getBoundingClientRect();
1538
+ if (width === 0 || height === 0) return;
1539
+ const raw = width / height;
1540
+ setAspect(raw < .9 ? .6 : raw < 1.6 ? 1.2 : 2.2);
1541
+ };
1542
+ measure();
1543
+ if (typeof ResizeObserver === "undefined") {
1544
+ setAspect((current) => current ?? 2.2);
1545
+ return;
1546
+ }
1547
+ observerRef.current = new ResizeObserver(measure);
1548
+ observerRef.current.observe(el);
1549
+ }, [])];
1550
+ }
1551
+ /** "4 agents in parallel" when each lane is one agent; "4 parallel lanes ·
1552
+ * 8 agents" when a lane is a chain (a reviewer and its verifier). */
1553
+ function parallelCaption(frame) {
1554
+ if (frame.agents > frame.lanes) return `${frame.lanes} parallel lanes · ${frame.agents} agents`;
1555
+ return frame.agents === frame.lanes ? `${frame.lanes} agents in parallel` : `${frame.lanes} in parallel`;
1556
+ }
1557
+ /** Frames behind parallel fan-outs, drawn in flow coordinates under the
1558
+ * nodes. Pointer-transparent: a frame annotates, it is never a target. */
1559
+ function ParallelFrames({ frames }) {
1560
+ return /* @__PURE__ */ jsx(ViewportPortal, { children: frames.map((frame) => /* @__PURE__ */ jsx("div", {
1561
+ "data-testid": "wf-parallel-frame",
1562
+ className: "pointer-events-none absolute rounded-xl border border-primary/30 border-dashed bg-primary/[0.04]",
1563
+ style: {
1564
+ transform: `translate(${frame.x}px, ${frame.y}px)`,
1565
+ width: frame.width,
1566
+ height: frame.height,
1567
+ zIndex: -1
1568
+ },
1569
+ children: /* @__PURE__ */ jsx("span", {
1570
+ className: "absolute top-1 left-2.5 font-medium text-[10px] text-primary/80 uppercase tracking-wide",
1571
+ children: parallelCaption(frame)
1572
+ })
1573
+ }, frame.id)) });
1574
+ }
1575
+ /** Below this zoom a compact node's name is too small to read, so it grows to
1576
+ * hold a readable size on screen; the tile, the layout and the edges keep
1577
+ * their scale. Capped so a name never outgrows the corridor beside it. */
1578
+ const TITLE_READABLE_ZOOM = .75;
1579
+ const TITLE_MAX_SCALE = 2.4;
1580
+ /** Past this the subtitle is dropped: two grown lines would crowd the next
1581
+ * row's tiles, and the title is the one a reader scans for. */
1582
+ const SUBTITLE_MAX_SCALE = 1.25;
1583
+ /**
1584
+ * Semantic zoom for a compact node's name. A whole 20-step factory framed in
1585
+ * one canvas sits near zoom 0.35, where 12px names render at 4px — the graph's
1586
+ * SHAPE reads but no step does. Quantised to tenths so a zoom gesture
1587
+ * re-renders a node a handful of times, not on every frame.
1588
+ */
1589
+ function useCompactTitleScale(enabled) {
1590
+ return useStore(useCallback((state) => {
1591
+ if (!enabled) return 1;
1592
+ const zoom = state.transform[2];
1593
+ if (zoom >= TITLE_READABLE_ZOOM) return 1;
1594
+ return Math.min(TITLE_MAX_SCALE, Math.round(TITLE_READABLE_ZOOM / zoom * 10) / 10);
1595
+ }, [enabled]));
1596
+ }
1597
+ /** Past this many nodes the full canvas carries a minimap, so a reader zoomed
1598
+ * into one corner of a large graph can see where they are in it. Below it
1599
+ * the wrapped layout fits the canvas whole, and a minimap would only cover
1600
+ * part of the graph it summarises. */
1601
+ const MINIMAP_MIN_NODES = 40;
947
1602
  /** Chip styling shared by the guard summary and the cycle badge, so an edge's
948
1603
  * two possible annotations read as one pair rather than two designs. */
949
1604
  const EDGE_CHIP_CLASS = "rounded-full border border-border bg-card/90 px-1.5 py-[1px] text-[10px] leading-tight backdrop-blur";
@@ -959,7 +1614,11 @@ const EDGE_CHIP_CLASS = "rounded-full border border-border bg-card/90 px-1.5 py-
959
1614
  */
960
1615
  function WfEdgeRenderer({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, markerEnd, style }) {
961
1616
  const insertStep = useContext(EdgeInsertContext);
962
- const [path, labelX, labelY] = getSmoothStepPath({
1617
+ const sourceNode = useInternalNodePosition(source);
1618
+ const targetNode = useInternalNodePosition(target);
1619
+ const route = data?.route;
1620
+ const onRoute = route !== void 0 && sourceNode !== null && targetNode !== null && samePoint(sourceNode, route.from) && samePoint(targetNode, route.to);
1621
+ const [stepPath, stepLabelX, stepLabelY] = getSmoothStepPath({
963
1622
  sourceX,
964
1623
  sourceY,
965
1624
  sourcePosition,
@@ -967,6 +1626,24 @@ function WfEdgeRenderer({ id, source, target, sourceX, sourceY, targetX, targetY
967
1626
  targetY,
968
1627
  targetPosition
969
1628
  });
1629
+ const routePoints = onRoute ? [
1630
+ {
1631
+ x: sourceX,
1632
+ y: sourceY
1633
+ },
1634
+ ...route.points.slice(1, -1),
1635
+ {
1636
+ x: targetX,
1637
+ y: targetY
1638
+ }
1639
+ ] : [];
1640
+ const path = onRoute ? roundedPolyline(routePoints) : stepPath;
1641
+ const labelAt = onRoute ? route.label ?? polylineMidpoint(routePoints) : {
1642
+ x: stepLabelX,
1643
+ y: stepLabelY
1644
+ };
1645
+ const labelX = labelAt.x;
1646
+ const labelY = labelAt.y;
970
1647
  const backEdge = data?.backEdge === true;
971
1648
  const whenLabel = data?.whenLabel;
972
1649
  const visits = data?.maxNodeVisits;
@@ -1109,7 +1786,7 @@ function decorateAuthoringEdges(edges, byEdge, insertable, hasRunOverlay) {
1109
1786
  };
1110
1787
  });
1111
1788
  }
1112
- function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompact, onCompactChange, className, wrap = false, nodeState, edges: declaredEdges, maxNodeVisits, selectedNodeId, onNodeClick, onEdgeConnect, onEdgeDelete, onEdgeClick, onEdgeInsert, onNodeInsert, onTriggerAdd, onTriggerDelete, problems }) {
1789
+ function WorkflowGraph({ yaml, variant = "full", direction: directionProp, defaultCompact, onCompactChange, className, wrap = false, nodeState, edges: declaredEdges, maxNodeVisits, selectedNodeId, onNodeClick, onEdgeConnect, onEdgeDelete, onEdgeClick, onEdgeInsert, onNodeInsert, onTriggerAdd, onTriggerDelete, problems }) {
1113
1790
  const editable = onEdgeConnect !== void 0;
1114
1791
  const insertOnEdge = editable ? onEdgeInsert : void 0;
1115
1792
  const insertArmed = insertOnEdge !== void 0;
@@ -1130,14 +1807,17 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1130
1807
  * `userCompact`, which is the only thing React may read for that.
1131
1808
  */
1132
1809
  const nextDensityFrom = useRef(userCompact);
1810
+ const [fullscreen, setFullscreen] = useState(false);
1133
1811
  const compact = isPreview || userCompact;
1134
1812
  const [displayCompact, setDisplayCompact] = useState(compact);
1135
1813
  const rfRef = useRef(null);
1136
1814
  const wrapperRef = useRef(null);
1815
+ const [canvasAspect, measureCanvas] = useCanvasAspect();
1816
+ const direction = directionProp ?? (canvasAspect !== null && canvasAspect < .9 ? "TB" : "LR");
1137
1817
  const framedRef = useRef(false);
1138
1818
  const [framingCue, setFramingCue] = useState(0);
1139
1819
  const hasRunOverlay = nodeState !== void 0;
1140
- const structural = useMemo(() => buildFlowGraph(yaml, {
1820
+ const { graph: structural, ready: laidOut } = useElkLayout(useMemo(() => buildFlowGraph(yaml, {
1141
1821
  nodeState: hasRunOverlay ? {} : void 0,
1142
1822
  direction,
1143
1823
  compact,
@@ -1152,7 +1832,9 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1152
1832
  wrap,
1153
1833
  insertArmed,
1154
1834
  declaredEdges
1155
- ]);
1835
+ ]), direction, compact, canvasAspect, !editable);
1836
+ const shownCompact = structural.compact ?? compact;
1837
+ const shownDirection = structural.direction ?? direction;
1156
1838
  const colorMode = useColorMode(wrapperRef, !structural.error && structural.nodes.length > 0);
1157
1839
  const nodeBoxes = useMemo(() => structural.nodes.map((node) => ({
1158
1840
  x: node.position.x,
@@ -1164,6 +1846,7 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1164
1846
  const styledEdges = useMemo(() => {
1165
1847
  return decorateAuthoringEdges(buildStyledEdges(structural.edges, nodeState, maxNodeVisits), problemIndex.byEdge, (edge) => insertArmed && isEditableEdge(edge), hasRunOverlay).map((e) => ({
1166
1848
  ...e,
1849
+ ...e.hidden && nodeState?.[e.source]?.status === "failed" ? { hidden: false } : {},
1167
1850
  deletable: editable ? isEditableEdge(e) : false
1168
1851
  }));
1169
1852
  }, [
@@ -1193,7 +1876,10 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1193
1876
  setNodes
1194
1877
  ]);
1195
1878
  const didInitialSeedRef = useRef(false);
1879
+ const revealedRef = useRef(laidOut);
1196
1880
  useEffect(() => {
1881
+ const firstReveal = laidOut && !revealedRef.current;
1882
+ revealedRef.current = laidOut;
1197
1883
  const withRunState = (nodes) => {
1198
1884
  const state = nodeStateRef.current;
1199
1885
  if (!state) return nodes;
@@ -1208,27 +1894,28 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1208
1894
  if (!didInitialSeedRef.current) {
1209
1895
  didInitialSeedRef.current = true;
1210
1896
  setNodes(withRunState(structural.nodes));
1211
- setDisplayCompact(compact);
1897
+ setDisplayCompact(shownCompact);
1212
1898
  return;
1213
1899
  }
1214
1900
  const inst = rfRef.current;
1215
1901
  const frame = wrapperRef.current?.getBoundingClientRect();
1216
1902
  if (!inst || !frame || frame.width === 0 || frame.height === 0) {
1217
1903
  setNodes(withRunState(structural.nodes));
1218
- setDisplayCompact(compact);
1904
+ setDisplayCompact(shownCompact);
1219
1905
  return;
1220
1906
  }
1221
- const endViewport = framingViewport(layoutBounds(structural.nodes), frame.width, frame.height, compact);
1907
+ const endViewport = openingViewport(structural, frame.width, frame.height, shownCompact, shownDirection);
1222
1908
  framedRef.current = true;
1223
1909
  const prev = nodesRef.current;
1224
1910
  const prevById = new Map(prev.map((n) => [n.id, n]));
1225
- if (!(prev.length === structural.nodes.length && structural.nodes.every((n) => prevById.has(n.id))) || !motionAllowed() || typeof requestAnimationFrame === "undefined") {
1911
+ const sameNodeSet = prev.length === structural.nodes.length && structural.nodes.every((n) => prevById.has(n.id));
1912
+ if (firstReveal || !sameNodeSet || !motionAllowed() || typeof requestAnimationFrame === "undefined") {
1226
1913
  setNodes(withRunState(structural.nodes));
1227
- setDisplayCompact(compact);
1914
+ setDisplayCompact(shownCompact);
1228
1915
  inst.setViewport(endViewport);
1229
1916
  return;
1230
1917
  }
1231
- const flipTimer = setTimeout(() => setDisplayCompact(compact), 220 * (compact ? DENSITY_FLIP_AT.shrink : DENSITY_FLIP_AT.grow));
1918
+ const flipTimer = setTimeout(() => setDisplayCompact(shownCompact), 220 * (shownCompact ? DENSITY_FLIP_AT.shrink : DENSITY_FLIP_AT.grow));
1232
1919
  const startViewport = inst.getViewport();
1233
1920
  const startedAt = performance.now();
1234
1921
  let raf = requestAnimationFrame(function step(now) {
@@ -1267,9 +1954,26 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1267
1954
  };
1268
1955
  }, [
1269
1956
  structural,
1270
- compact,
1271
- setNodes
1957
+ shownCompact,
1958
+ setNodes,
1959
+ laidOut,
1960
+ shownDirection
1272
1961
  ]);
1962
+ const structuralRef = useRef(structural);
1963
+ structuralRef.current = structural;
1964
+ const compactRef = useRef(shownCompact);
1965
+ compactRef.current = shownCompact;
1966
+ const directionRef = useRef(shownDirection);
1967
+ directionRef.current = shownDirection;
1968
+ const reframe = useCallback(() => {
1969
+ requestAnimationFrame(() => {
1970
+ const inst = rfRef.current;
1971
+ const frame = wrapperRef.current?.getBoundingClientRect();
1972
+ if (!inst || !frame || frame.width === 0 || frame.height === 0) return;
1973
+ const graph = structuralRef.current;
1974
+ inst.setViewport(openingViewport(graph, frame.width, frame.height, compactRef.current, directionRef.current));
1975
+ });
1976
+ }, []);
1273
1977
  /**
1274
1978
  * Own the INITIAL frame once the instance and canvas have a size. React
1275
1979
  * Flow's one-time `fitView` can run after this effect and overwrite the
@@ -1281,12 +1985,13 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1281
1985
  const inst = rfRef.current;
1282
1986
  const frame = wrapperRef.current?.getBoundingClientRect();
1283
1987
  if (!inst || !frame || frame.width === 0 || frame.height === 0) return;
1284
- inst.setViewport(framingViewport(layoutBounds(structural.nodes), frame.width, frame.height, compact));
1988
+ inst.setViewport(openingViewport(structural, frame.width, frame.height, shownCompact, shownDirection));
1285
1989
  framedRef.current = true;
1286
1990
  }, [
1287
1991
  framingCue,
1288
1992
  structural,
1289
- compact
1993
+ shownCompact,
1994
+ shownDirection
1290
1995
  ]);
1291
1996
  useEffect(() => {
1292
1997
  const el = wrapperRef.current;
@@ -1301,6 +2006,20 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1301
2006
  observer.observe(el);
1302
2007
  return () => observer.disconnect();
1303
2008
  }, []);
2009
+ const fullscreenSeen = useRef(fullscreen);
2010
+ useEffect(() => {
2011
+ if (fullscreenSeen.current === fullscreen) return;
2012
+ fullscreenSeen.current = fullscreen;
2013
+ reframe();
2014
+ if (!fullscreen) return;
2015
+ const onKey = (event) => {
2016
+ if (event.key !== "Escape" || event.defaultPrevented) return;
2017
+ if (document.querySelector("[aria-modal=\"true\"]")) return;
2018
+ setFullscreen(false);
2019
+ };
2020
+ window.addEventListener("keydown", onKey);
2021
+ return () => window.removeEventListener("keydown", onKey);
2022
+ }, [fullscreen, reframe]);
1304
2023
  const handleNodeClick = useCallback((_event, node) => {
1305
2024
  onNodeClick?.(node.id, node.data);
1306
2025
  }, [onNodeClick]);
@@ -1363,7 +2082,7 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1363
2082
  children: structural.error ?? "Nothing to show"
1364
2083
  });
1365
2084
  return /* @__PURE__ */ jsx(DirectionContext.Provider, {
1366
- value: direction,
2085
+ value: shownDirection,
1367
2086
  children: /* @__PURE__ */ jsx(DensityContext.Provider, {
1368
2087
  value: displayCompact,
1369
2088
  children: /* @__PURE__ */ jsx(RunModeContext.Provider, {
@@ -1381,8 +2100,12 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1381
2100
  children: /* @__PURE__ */ jsx(EdgeInsertContext.Provider, {
1382
2101
  value: insertOnEdge ?? null,
1383
2102
  children: /* @__PURE__ */ jsx("div", {
1384
- ref: wrapperRef,
1385
- className: `wf-graph ${className ?? ""}`,
2103
+ ref: (el) => {
2104
+ wrapperRef.current = el;
2105
+ measureCanvas(el);
2106
+ },
2107
+ className: `wf-graph ${laidOut ? "" : "opacity-0"} ${fullscreen ? "!fixed !inset-3 !z-40 !h-auto !w-auto overflow-hidden rounded-xl border border-border bg-background shadow-2xl" : className ?? ""}`,
2108
+ "data-layout": laidOut ? "ready" : "pending",
1386
2109
  children: /* @__PURE__ */ jsxs(ReactFlow, {
1387
2110
  nodes,
1388
2111
  edges,
@@ -1421,42 +2144,70 @@ function WorkflowGraph({ yaml, variant = "full", direction = "LR", defaultCompac
1421
2144
  preventScrolling: false,
1422
2145
  minZoom: .2,
1423
2146
  maxZoom: 1.5,
1424
- children: [/* @__PURE__ */ jsx(Background, {
1425
- gap: 18,
1426
- size: 1.4,
1427
- color: "hsl(var(--muted-foreground) / 0.38)"
1428
- }), !isPreview && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Controls, {
1429
- showInteractive: false,
1430
- position: "bottom-right",
1431
- fitViewOptions: { padding: FIT_VIEW.padding }
1432
- }), /* @__PURE__ */ jsxs(Panel, {
1433
- position: "top-left",
1434
- className: "flex items-center gap-1",
1435
- children: [addTrigger && /* @__PURE__ */ jsxs("button", {
1436
- type: "button",
1437
- "data-testid": "wf-trigger-add",
1438
- onClick: addTrigger,
1439
- title: "Add a trigger",
1440
- "aria-label": "Add a trigger",
1441
- className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
1442
- children: [/* @__PURE__ */ jsx(Plus, {
1443
- size: 12,
1444
- "aria-hidden": true
1445
- }), "Trigger"]
1446
- }), /* @__PURE__ */ jsxs("button", {
1447
- type: "button",
1448
- onClick: () => {
1449
- const next = !nextDensityFrom.current;
1450
- nextDensityFrom.current = next;
1451
- setUserCompact(next);
1452
- onCompactChange?.(next);
2147
+ children: [
2148
+ /* @__PURE__ */ jsx(Background, {
2149
+ gap: 18,
2150
+ size: 1.4,
2151
+ color: "hsl(var(--muted-foreground) / 0.38)"
2152
+ }),
2153
+ structural.groups && structural.groups.length > 0 && /* @__PURE__ */ jsx(ParallelFrames, { frames: structural.groups }),
2154
+ !isPreview && structural.nodes.length > MINIMAP_MIN_NODES && /* @__PURE__ */ jsx(MiniMap, {
2155
+ pannable: true,
2156
+ zoomable: true,
2157
+ position: "bottom-left",
2158
+ style: {
2159
+ width: 168,
2160
+ height: 104
1453
2161
  },
1454
- "aria-pressed": userCompact,
1455
- title: userCompact ? "Expand nodes" : "Collapse nodes",
1456
- className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
1457
- children: [userCompact ? /* @__PURE__ */ jsx(Maximize2, { size: 12 }) : /* @__PURE__ */ jsx(Minimize2, { size: 12 }), userCompact ? "Expand" : "Compact"]
1458
- })]
1459
- })] })]
2162
+ className: "!hidden sm:!block",
2163
+ ariaLabel: "Workflow overview"
2164
+ }),
2165
+ !isPreview && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Controls, {
2166
+ showInteractive: false,
2167
+ position: "bottom-right",
2168
+ fitViewOptions: { padding: FIT_VIEW.padding }
2169
+ }), /* @__PURE__ */ jsxs(Panel, {
2170
+ position: "top-left",
2171
+ className: "flex items-center gap-1",
2172
+ children: [
2173
+ addTrigger && /* @__PURE__ */ jsxs("button", {
2174
+ type: "button",
2175
+ "data-testid": "wf-trigger-add",
2176
+ onClick: addTrigger,
2177
+ title: "Add a trigger",
2178
+ "aria-label": "Add a trigger",
2179
+ className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
2180
+ children: [/* @__PURE__ */ jsx(Plus, {
2181
+ size: 12,
2182
+ "aria-hidden": true
2183
+ }), "Trigger"]
2184
+ }),
2185
+ /* @__PURE__ */ jsxs("button", {
2186
+ type: "button",
2187
+ onClick: () => {
2188
+ const next = !nextDensityFrom.current;
2189
+ nextDensityFrom.current = next;
2190
+ setUserCompact(next);
2191
+ onCompactChange?.(next);
2192
+ },
2193
+ "aria-pressed": userCompact,
2194
+ title: userCompact ? "Expand nodes" : "Collapse nodes",
2195
+ className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
2196
+ children: [userCompact ? /* @__PURE__ */ jsx(Maximize2, { size: 12 }) : /* @__PURE__ */ jsx(Minimize2, { size: 12 }), userCompact ? "Expand" : "Compact"]
2197
+ }),
2198
+ /* @__PURE__ */ jsxs("button", {
2199
+ type: "button",
2200
+ "data-testid": "wf-fullscreen",
2201
+ onClick: () => setFullscreen((on) => !on),
2202
+ "aria-pressed": fullscreen,
2203
+ title: fullscreen ? "Exit full screen (Esc)" : "Full screen",
2204
+ "aria-label": fullscreen ? "Exit full screen" : "Full screen",
2205
+ className: "flex items-center gap-1 rounded-md border border-border bg-card/80 px-2 py-1 text-[11px] text-muted-foreground shadow-sm backdrop-blur transition hover:text-foreground",
2206
+ children: [fullscreen ? /* @__PURE__ */ jsx(Shrink, { size: 12 }) : /* @__PURE__ */ jsx(Expand, { size: 12 }), fullscreen ? "Exit" : "Full screen"]
2207
+ })
2208
+ ]
2209
+ })] })
2210
+ ]
1460
2211
  })
1461
2212
  })
1462
2213
  })