@principal-ai/principal-view-react 0.15.5 → 0.15.7

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.
@@ -5,7 +5,7 @@
5
5
  * Uses namespaces to determine swimlanes and event order for vertical positioning.
6
6
  */
7
7
 
8
- import React, { useCallback, useMemo } from 'react';
8
+ import React, { useCallback, useMemo, useState } from 'react';
9
9
  import {
10
10
  ReactFlow,
11
11
  Background,
@@ -33,6 +33,7 @@ import {
33
33
  type SequenceEdge,
34
34
  type UseSequenceLayoutOptions,
35
35
  type Swimlane,
36
+ type ParentHeader,
36
37
  } from '../hooks/useSequenceLayout';
37
38
 
38
39
  /**
@@ -250,7 +251,6 @@ function SequenceArrowParticipantEdge({
250
251
  width={barWidth}
251
252
  height={barHeight}
252
253
  fill={strokeColor}
253
- fillOpacity={0.15}
254
254
  stroke={strokeColor}
255
255
  strokeWidth={2}
256
256
  rx={2}
@@ -261,15 +261,21 @@ function SequenceArrowParticipantEdge({
261
261
  <div
262
262
  style={{
263
263
  position: 'absolute',
264
- transform: `translate(0, -50%) translate(${sourceX + 15}px,${barY + barHeight / 2}px)`,
264
+ transform: `translate(-100%, -50%) translate(${sourceX - 7}px,${barY + barHeight / 2}px)`,
265
265
  background: isSourceSelected ? strokeColor : theme.colors.background,
266
- padding: isSourceSelected ? '4px 10px' : '2px 8px',
267
- borderRadius: 4,
266
+ padding: '2px 8px',
267
+ borderTopLeftRadius: 4,
268
+ borderTopRightRadius: 0,
269
+ borderBottomLeftRadius: 4,
270
+ borderBottomRightRadius: 0,
268
271
  fontSize: theme.fontSizes[0],
269
272
  fontWeight: isSourceSelected ? theme.fontWeights.bold : theme.fontWeights.medium,
270
273
  fontFamily: theme.fonts.body,
271
274
  color: isSourceSelected ? theme.colors.background : strokeColor,
272
- border: `${isSourceSelected ? 2 : 1}px solid ${strokeColor}`,
275
+ borderTop: `${isSourceSelected ? 2 : 1}px solid ${strokeColor}`,
276
+ borderLeft: `${isSourceSelected ? 2 : 1}px solid ${strokeColor}`,
277
+ borderBottom: `${isSourceSelected ? 2 : 1}px solid ${strokeColor}`,
278
+ borderRight: `1px solid ${strokeColor}`,
273
279
  pointerEvents: 'all',
274
280
  whiteSpace: 'nowrap',
275
281
  cursor: 'pointer',
@@ -292,7 +298,12 @@ function SequenceArrowParticipantEdge({
292
298
 
293
299
  // Draw horizontal arrow at the midpoint between source and target Y positions
294
300
  const arrowY = (safeSourceY + safeTargetY) / 2;
295
- const path = `M ${sourceX} ${arrowY} L ${targetX} ${arrowY}`;
301
+ // Inset arrow endpoints so they stop just before the lifelines instead of crossing them
302
+ const lifelineInset = 6;
303
+ const direction = targetX > sourceX ? 1 : -1;
304
+ const startX = sourceX + direction * lifelineInset;
305
+ const endX = targetX - direction * lifelineInset;
306
+ const path = `M ${startX} ${arrowY} L ${endX} ${arrowY}`;
296
307
  const labelX = (sourceX + targetX) / 2;
297
308
  const labelY = arrowY;
298
309
 
@@ -314,13 +325,19 @@ function SequenceArrowParticipantEdge({
314
325
  position: 'absolute',
315
326
  transform: `translate(-50%, -50%) translate(${labelX}px,${labelY - 12}px)`,
316
327
  background: isSourceSelected ? strokeColor : theme.colors.background,
317
- padding: isSourceSelected ? '5px 12px' : (isMoveEvent ? '3px 10px' : '2px 8px'),
318
- borderRadius: 4,
328
+ padding: isMoveEvent ? '3px 10px' : '2px 8px',
329
+ borderTopLeftRadius: 4,
330
+ borderTopRightRadius: 4,
331
+ borderBottomLeftRadius: 0,
332
+ borderBottomRightRadius: 0,
319
333
  fontSize: theme.fontSizes[0],
320
334
  fontWeight: isSourceSelected ? theme.fontWeights.bold : (isMoveEvent ? theme.fontWeights.bold : theme.fontWeights.medium),
321
335
  fontFamily: theme.fonts.body,
322
336
  color: isSourceSelected ? theme.colors.background : strokeColor,
323
- border: `${isSourceSelected ? 3 : (isMoveEvent ? 2 : 1)}px solid ${strokeColor}`,
337
+ borderTop: `${isSourceSelected ? 3 : (isMoveEvent ? 2 : 1)}px solid ${strokeColor}`,
338
+ borderLeft: `${isSourceSelected ? 3 : (isMoveEvent ? 2 : 1)}px solid ${strokeColor}`,
339
+ borderRight: `${isSourceSelected ? 3 : (isMoveEvent ? 2 : 1)}px solid ${strokeColor}`,
340
+ borderBottom: `${isMoveEvent ? 2 : 1}px solid ${strokeColor}`,
324
341
  pointerEvents: 'all',
325
342
  whiteSpace: 'nowrap',
326
343
  cursor: 'pointer',
@@ -344,6 +361,42 @@ const defaultSequenceNodeTypes: NodeTypes = {
344
361
  sequenceMarker: SequenceMarkerNode,
345
362
  };
346
363
 
364
+ /**
365
+ * Shared transition for swimlane chrome — interpolates positions and sizes
366
+ * when lanes shift due to drill toggles.
367
+ */
368
+ const swimlaneTransition =
369
+ 'top 350ms ease-out, left 350ms ease-out, width 350ms ease-out, height 350ms ease-out';
370
+
371
+ /** Duration of the child slide-up exit animation. The close handler waits
372
+ * this long before applying the data change so the diagram body doesn't
373
+ * shift while the children are still on screen. */
374
+ const SWIMLANE_CLOSE_EXIT_MS = 280;
375
+
376
+ const swimlaneAnimationStyles = `
377
+ @keyframes swimlaneFadeIn {
378
+ from { opacity: 0; }
379
+ to { opacity: 1; }
380
+ }
381
+ @keyframes swimlaneFadeOut {
382
+ from { opacity: 1; }
383
+ to { opacity: 0; }
384
+ }
385
+ @keyframes swimlaneChildSlideDown {
386
+ from { transform: translateY(-100%); opacity: 0; }
387
+ to { transform: translateY(0); opacity: 1; }
388
+ }
389
+ @keyframes swimlaneChildSlideUp {
390
+ from { transform: translateY(0); opacity: 1; }
391
+ to { transform: translateY(-100%); opacity: 0; }
392
+ }
393
+ .swimlane-fade-in { animation: swimlaneFadeIn 250ms ease-out both; }
394
+ .swimlane-child-bg-in { animation: swimlaneFadeIn 300ms ease-out 250ms both; }
395
+ .swimlane-child-in { animation: swimlaneChildSlideDown 300ms ease-out 250ms both; }
396
+ .swimlane-child-out { animation: swimlaneChildSlideUp 280ms ease-in both; }
397
+ .swimlane-fade-out { animation: swimlaneFadeOut 250ms ease-in both; }
398
+ `;
399
+
347
400
  /**
348
401
  * Default edge types including sequence arrow and participant arrow
349
402
  */
@@ -357,10 +410,18 @@ const defaultSequenceEdgeTypes: EdgeTypes = {
357
410
  */
358
411
  interface SwimlaneLayerProps {
359
412
  swimlanes: Swimlane[];
413
+ parentHeaders: ParentHeader[];
414
+ headerRows: number;
360
415
  laneWidth: number;
416
+ /** Per-row height (each row in the header strip is this tall) */
361
417
  headerHeight: number;
362
418
  totalHeight: number;
363
- onToggleCollapse?: (namespace: string) => void;
419
+ /** Called when the user clicks a chevron or parent header to toggle its drilled state */
420
+ onToggleNamespace?: (namespace: string) => void;
421
+ /** Namespaces mid-close. Their children are still in the data but render
422
+ * with the exit animation so the body can stay put until the animation
423
+ * finishes and the data change applies. */
424
+ closingNamespaces?: Set<string>;
364
425
  stickyHeaders?: boolean;
365
426
  /** When true, render lane and header backgrounds as transparent. */
366
427
  transparent?: boolean;
@@ -371,11 +432,13 @@ interface SwimlaneLayerProps {
371
432
  */
372
433
  function SwimlaneLayer({
373
434
  swimlanes,
435
+ headerRows,
374
436
  laneWidth,
375
437
  headerHeight,
376
438
  totalHeight,
377
439
  transparent = false,
378
440
  }: SwimlaneLayerProps) {
441
+ const totalHeaderHeight = headerHeight * headerRows;
379
442
  const { x, y, zoom } = useViewport();
380
443
  const viewportHeight = useStore((s) => s.height);
381
444
  const { theme } = useTheme();
@@ -404,9 +467,13 @@ function SwimlaneLayer({
404
467
  : isEven
405
468
  ? theme.colors.muted
406
469
  : theme.colors.background;
470
+ const fadeClass = lane.isParentOpened
471
+ ? 'swimlane-child-bg-in'
472
+ : 'swimlane-fade-in';
407
473
  return (
408
474
  <div
409
475
  key={`bg-${lane.namespace}`}
476
+ className={fadeClass}
410
477
  style={{
411
478
  position: 'absolute',
412
479
  left: lane.x - laneWidth / 2,
@@ -415,6 +482,7 @@ function SwimlaneLayer({
415
482
  height: extendedHeight,
416
483
  backgroundColor: laneBackground,
417
484
  borderRight: `1px solid ${theme.colors.border}`,
485
+ transition: swimlaneTransition,
418
486
  }}
419
487
  />
420
488
  );
@@ -424,14 +492,18 @@ function SwimlaneLayer({
424
492
  {swimlanes.map((lane) => (
425
493
  <div
426
494
  key={`lifeline-${lane.namespace}`}
495
+ className={
496
+ lane.isParentOpened ? 'swimlane-child-bg-in' : 'swimlane-fade-in'
497
+ }
427
498
  style={{
428
499
  position: 'absolute',
429
500
  left: lane.x,
430
- top: headerHeight,
501
+ top: totalHeaderHeight,
431
502
  width: 2,
432
- height: extendedHeight - headerHeight,
433
- backgroundColor: theme.colors.border,
503
+ height: extendedHeight - totalHeaderHeight,
504
+ backgroundColor: 'rgba(255, 255, 255, 0.4)',
434
505
  transform: 'translateX(-1px)',
506
+ transition: swimlaneTransition,
435
507
  }}
436
508
  />
437
509
  ))}
@@ -439,24 +511,141 @@ function SwimlaneLayer({
439
511
  );
440
512
  }
441
513
 
514
+ /**
515
+ * Two offset rounded rectangles signaling that a header has nested lanes
516
+ * underneath it. When `opened`, the front layer is filled to indicate the
517
+ * stack is currently expanded.
518
+ */
519
+ function StackIcon({
520
+ opened = false,
521
+ hovered = false,
522
+ accentColor,
523
+ }: {
524
+ opened?: boolean;
525
+ hovered?: boolean;
526
+ accentColor?: string;
527
+ }) {
528
+ // Closed: rects compactly stacked, both outlined in text color.
529
+ // Hovered: strokes tint to the accent color (preview of the action).
530
+ // Opened: front rect drifts down/right and fades to filled in the accent.
531
+ const rectTransition =
532
+ 'transform 280ms cubic-bezier(0.2, 0, 0, 1), fill-opacity 280ms ease, stroke 200ms ease';
533
+ const useAccent = (opened || hovered) && !!accentColor;
534
+ const stroke = useAccent ? accentColor : 'currentColor';
535
+ const fill = accentColor || 'currentColor';
536
+ return (
537
+ <svg
538
+ width={22}
539
+ height={22}
540
+ viewBox="0 0 14 14"
541
+ fill="none"
542
+ strokeWidth={1.3}
543
+ strokeLinejoin="round"
544
+ aria-hidden="true"
545
+ style={{
546
+ flexShrink: 0,
547
+ opacity: opened ? 0.95 : hovered ? 0.9 : 0.75,
548
+ overflow: 'visible',
549
+ transition: 'opacity 200ms ease',
550
+ }}
551
+ >
552
+ <rect
553
+ x={2}
554
+ y={3}
555
+ width={7}
556
+ height={5}
557
+ rx={1}
558
+ stroke={stroke}
559
+ style={{
560
+ transition: rectTransition,
561
+ transform: opened
562
+ ? 'translate(-1.5px, -1.5px)'
563
+ : 'translate(0px, 0px)',
564
+ }}
565
+ />
566
+ <rect
567
+ x={5}
568
+ y={6}
569
+ width={7}
570
+ height={5}
571
+ rx={1}
572
+ stroke={stroke}
573
+ fill={fill}
574
+ style={{
575
+ transition: rectTransition,
576
+ transform: opened
577
+ ? 'translate(1.5px, 1.5px)'
578
+ : 'translate(0px, 0px)',
579
+ fillOpacity: opened ? 0.35 : 0,
580
+ }}
581
+ />
582
+ </svg>
583
+ );
584
+ }
585
+
442
586
  /**
443
587
  * Swimlane headers layer that renders on top of nodes for clickability
444
588
  */
445
589
  function SwimlaneHeadersLayer({
446
590
  swimlanes,
591
+ parentHeaders,
447
592
  laneWidth,
448
593
  headerHeight,
449
- onToggleCollapse,
594
+ onToggleNamespace,
595
+ closingNamespaces,
450
596
  stickyHeaders = true,
451
597
  transparent = false,
452
598
  }: SwimlaneLayerProps) {
453
599
  const { x, y, zoom } = useViewport();
454
600
  const { theme } = useTheme();
601
+ const [hoveredNamespace, setHoveredNamespace] = useState<string | null>(null);
455
602
 
456
603
  // When sticky headers are enabled, compensate for vertical viewport panning
457
604
  // to keep headers at the top of the screen
458
605
  const headerTop = stickyHeaders ? -y / zoom : 0;
459
606
 
607
+ // Build a unified header list: each namespace currently in view (whether a
608
+ // leaf or an opened ancestor) gets ONE DOM element keyed by namespace, so
609
+ // clicking ▶ smoothly morphs the same cell from leaf-shape to parent-shape
610
+ // (wider, possibly across multiple lanes) instead of unmounting+remounting.
611
+ type HeaderCell = {
612
+ namespace: string;
613
+ label: string;
614
+ x: number;
615
+ width: number;
616
+ depth: number;
617
+ isOpened: boolean; // currently in `openedNamespaces`
618
+ isParentOpened: boolean;
619
+ canExpand: boolean; // only meaningful when !isOpened
620
+ };
621
+
622
+ const headers: HeaderCell[] = useMemo(
623
+ () => [
624
+ ...parentHeaders.map((h) => ({
625
+ namespace: h.namespace,
626
+ label: h.label,
627
+ x: h.x,
628
+ width: h.width,
629
+ depth: h.depth,
630
+ isOpened: true,
631
+ isParentOpened: h.depth > 1,
632
+ canExpand: false,
633
+ })),
634
+ ...swimlanes.map((lane) => ({
635
+ namespace: lane.namespace,
636
+ label: lane.label,
637
+ x: lane.x,
638
+ width: laneWidth,
639
+ depth: lane.namespace.split('.').length,
640
+ isOpened: false,
641
+ isParentOpened: lane.isParentOpened,
642
+ canExpand: lane.canExpand,
643
+ })),
644
+ ],
645
+ [parentHeaders, swimlanes, laneWidth]
646
+ );
647
+
648
+
460
649
  return (
461
650
  <div
462
651
  style={{
@@ -469,54 +658,132 @@ function SwimlaneHeadersLayer({
469
658
  zIndex: 10,
470
659
  }}
471
660
  >
472
- {/* Lane headers */}
473
- {swimlanes.map((lane) => {
474
- const hasChildren = lane.children.length > 0;
661
+ {headers.map((header) => {
662
+ const rowTop = headerTop + (header.depth - 1) * headerHeight;
663
+ // Child leaves (under an opened parent) get the differentiated, lighter
664
+ // styling. Top-level leaves AND opened parents share the original
665
+ // header look — so the cell you clicked doesn't change appearance, it
666
+ // just grows to span its children.
667
+ const isChild = header.isParentOpened && !header.isOpened;
668
+ const showOpen = header.canExpand && !header.isOpened;
669
+ const isClickable = header.isOpened || showOpen;
670
+ // If this child's parent is mid-close, play the exit animation
671
+ // instead of the entry. After SWIMLANE_CLOSE_EXIT_MS the data
672
+ // change applies and the child unmounts.
673
+ const parentNs =
674
+ header.depth > 1
675
+ ? header.namespace.split('.').slice(0, -1).join('.')
676
+ : undefined;
677
+ const isExiting =
678
+ isChild && !!parentNs && !!closingNamespaces?.has(parentNs);
679
+ const cellClassName = isExiting
680
+ ? 'swimlane-child-out'
681
+ : isChild
682
+ ? 'swimlane-child-in'
683
+ : 'swimlane-fade-in';
475
684
  return (
476
685
  <div
477
- key={`header-${lane.namespace}`}
686
+ key={header.namespace}
687
+ className={cellClassName}
688
+ role={isClickable ? 'button' : undefined}
689
+ aria-label={
690
+ header.isOpened
691
+ ? `Close ${header.namespace}`
692
+ : showOpen
693
+ ? `Open ${header.namespace}`
694
+ : undefined
695
+ }
696
+ title={
697
+ header.isOpened
698
+ ? `${header.namespace} (click to close)`
699
+ : showOpen
700
+ ? `${header.namespace} (click to open)`
701
+ : header.namespace
702
+ }
478
703
  style={{
479
704
  position: 'absolute',
480
- left: lane.x - laneWidth / 2,
481
- top: headerTop,
482
- width: laneWidth,
705
+ left: header.x - header.width / 2,
706
+ top: rowTop,
707
+ width: header.width,
483
708
  height: headerHeight,
484
709
  display: 'flex',
485
710
  alignItems: 'center',
486
711
  justifyContent: 'center',
487
712
  padding: '0 8px',
488
713
  boxSizing: 'border-box',
489
- backgroundColor: transparent ? 'transparent' : theme.colors.muted,
490
- borderBottom: `2px solid ${theme.colors.border}`,
491
- fontWeight: theme.fontWeights.semibold,
492
- fontSize: theme.fontSizes[2],
714
+ backgroundColor: transparent
715
+ ? 'transparent'
716
+ : isChild
717
+ ? theme.colors.background
718
+ : theme.colors.muted,
719
+ transition: swimlaneTransition,
720
+ borderBottom: isChild
721
+ ? `1px solid ${theme.colors.border}`
722
+ : `2px solid ${theme.colors.border}`,
723
+ borderLeft: isChild
724
+ ? `1px solid ${theme.colors.border}`
725
+ : 'none',
726
+ borderRight: isChild
727
+ ? `1px solid ${theme.colors.border}`
728
+ : 'none',
729
+ fontWeight: isChild
730
+ ? theme.fontWeights.medium
731
+ : theme.fontWeights.semibold,
732
+ fontSize: isChild ? theme.fontSizes[1] : theme.fontSizes[2],
493
733
  fontFamily: theme.fonts.heading,
494
- color: theme.colors.text,
734
+ color: isChild ? theme.colors.textSecondary : theme.colors.text,
495
735
  pointerEvents: 'auto',
496
- cursor: hasChildren ? 'pointer' : 'default',
497
736
  userSelect: 'none',
737
+ cursor: isClickable ? 'pointer' : 'default',
738
+ gap: 6,
739
+ // Parent (opened) cells sit above leaves so children slide out
740
+ // from behind them rather than over the top.
741
+ zIndex: header.isOpened ? 2 : 1,
498
742
  }}
499
- onClick={() => hasChildren && onToggleCollapse?.(lane.namespace)}
743
+ onClick={
744
+ isClickable
745
+ ? (e) => {
746
+ e.stopPropagation();
747
+ onToggleNamespace?.(header.namespace);
748
+ }
749
+ : undefined
750
+ }
751
+ onMouseEnter={
752
+ isClickable
753
+ ? () => setHoveredNamespace(header.namespace)
754
+ : undefined
755
+ }
756
+ onMouseLeave={
757
+ isClickable
758
+ ? () =>
759
+ setHoveredNamespace((current) =>
760
+ current === header.namespace ? null : current
761
+ )
762
+ : undefined
763
+ }
500
764
  >
501
- {hasChildren && (
502
- <span style={{ marginRight: 6, fontSize: 10 }}>
503
- {lane.isCollapsed ? '▼' : '▶'}
504
- </span>
505
- )}
506
765
  <span
507
766
  style={{
508
767
  overflowWrap: 'anywhere',
509
768
  wordBreak: 'break-word',
510
769
  lineHeight: 1.2,
511
770
  textAlign: 'center',
771
+ flex: 1,
512
772
  }}
513
- title={lane.label}
514
773
  >
515
- {lane.label}
774
+ {header.label}
516
775
  </span>
776
+ {(header.isOpened || showOpen) && (
777
+ <StackIcon
778
+ opened={header.isOpened}
779
+ hovered={hoveredNamespace === header.namespace}
780
+ accentColor={theme.colors.primary}
781
+ />
782
+ )}
517
783
  </div>
518
784
  );
519
785
  })}
786
+
520
787
  </div>
521
788
  );
522
789
  }
@@ -540,8 +807,12 @@ export interface SequenceDiagramRendererProps {
540
807
  /** Optional custom edge types */
541
808
  edgeTypes?: EdgeTypes;
542
809
 
543
- /** Callback when a namespace collapse state is toggled */
544
- onToggleCollapse?: (namespace: string) => void;
810
+ /**
811
+ * Called when the user toggles a lane's drill state via the header
812
+ * chevrons. Update `layoutOptions.openedNamespaces` in response to
813
+ * open/close the lane.
814
+ */
815
+ onToggleNamespace?: (namespace: string) => void;
545
816
 
546
817
  /** Callback when a node is clicked */
547
818
  onNodeClick?: (nodeId: string, event: React.MouseEvent) => void;
@@ -588,7 +859,7 @@ function SequenceDiagramInner({
588
859
  layoutOptions = {},
589
860
  nodeTypes: customNodeTypes,
590
861
  edgeTypes: customEdgeTypes,
591
- onToggleCollapse,
862
+ onToggleNamespace,
592
863
  onNodeClick,
593
864
  showControls = true,
594
865
  showBackground = false, // Default to false since swimlanes provide visual structure
@@ -602,6 +873,67 @@ function SequenceDiagramInner({
602
873
  // Extract layout params
603
874
  const { laneWidth = 250, headerHeight = 60 } = layoutOptions;
604
875
 
876
+ // openedNamespaces is controlled if provided in layoutOptions, otherwise
877
+ // we manage it internally so chevrons work out of the box.
878
+ const isOpenedControlled = layoutOptions.openedNamespaces !== undefined;
879
+ const [internalOpened, setInternalOpened] = useState<string[]>([]);
880
+ const effectiveOpened = isOpenedControlled
881
+ ? layoutOptions.openedNamespaces
882
+ : internalOpened;
883
+
884
+ // Namespaces currently mid-close. While one is here, the children still
885
+ // render in the data (lifelines, events, edges unchanged) but their header
886
+ // cells flip to the exit animation. After the exit completes we apply the
887
+ // real data change, so the diagram body shifts in sync with the parent
888
+ // header shrink instead of ahead of it.
889
+ const [closingNamespaces, setClosingNamespaces] = useState<Set<string>>(
890
+ () => new Set()
891
+ );
892
+
893
+ const handleToggleNamespace = useCallback(
894
+ (namespace: string) => {
895
+ const openedSet =
896
+ effectiveOpened instanceof Set
897
+ ? effectiveOpened
898
+ : new Set(effectiveOpened ?? []);
899
+ const isCurrentlyOpened = openedSet.has(namespace);
900
+
901
+ if (isCurrentlyOpened) {
902
+ // Stage the close: animate children out first, then apply data change.
903
+ setClosingNamespaces((prev) => {
904
+ if (prev.has(namespace)) return prev;
905
+ const next = new Set(prev);
906
+ next.add(namespace);
907
+ return next;
908
+ });
909
+ setTimeout(() => {
910
+ if (!isOpenedControlled) {
911
+ setInternalOpened((prev) => prev.filter((n) => n !== namespace));
912
+ }
913
+ onToggleNamespace?.(namespace);
914
+ setClosingNamespaces((prev) => {
915
+ if (!prev.has(namespace)) return prev;
916
+ const next = new Set(prev);
917
+ next.delete(namespace);
918
+ return next;
919
+ });
920
+ }, SWIMLANE_CLOSE_EXIT_MS);
921
+ } else {
922
+ // Open: data change is immediate; children animate in via CSS.
923
+ if (!isOpenedControlled) {
924
+ setInternalOpened((prev) => [...prev, namespace]);
925
+ }
926
+ onToggleNamespace?.(namespace);
927
+ }
928
+ },
929
+ [effectiveOpened, isOpenedControlled, onToggleNamespace]
930
+ );
931
+
932
+ const effectiveLayoutOptions = useMemo(
933
+ () => ({ ...layoutOptions, openedNamespaces: effectiveOpened }),
934
+ [layoutOptions, effectiveOpened]
935
+ );
936
+
605
937
  // Merge custom node/edge types with sequence defaults
606
938
  const nodeTypes = useMemo(
607
939
  () => ({ ...defaultSequenceNodeTypes, ...customNodeTypes }),
@@ -613,11 +945,15 @@ function SequenceDiagramInner({
613
945
  );
614
946
 
615
947
  // Compute layout
616
- const { nodes: layoutNodes, edges, swimlanes, totalWidth, totalHeight } = useSequenceLayout(
617
- events,
618
- sequenceEdges,
619
- layoutOptions
620
- );
948
+ const {
949
+ nodes: layoutNodes,
950
+ edges,
951
+ swimlanes,
952
+ parentHeaders,
953
+ headerRows,
954
+ totalWidth,
955
+ totalHeight,
956
+ } = useSequenceLayout(events, sequenceEdges, effectiveLayoutOptions);
621
957
 
622
958
  // Mark selected node and add showEventLabels to node data
623
959
  const nodes = useMemo(() => {
@@ -714,6 +1050,7 @@ function SequenceDiagramInner({
714
1050
  translateExtent={translateExtent}
715
1051
  style={{ background: transparent ? 'transparent' : theme.colors.background }}
716
1052
  >
1053
+ <style>{swimlaneAnimationStyles}</style>
717
1054
  {/* SVG defs for arrow markers */}
718
1055
  <svg style={{ position: 'absolute', width: 0, height: 0 }}>
719
1056
  <defs>
@@ -758,6 +1095,8 @@ function SequenceDiagramInner({
758
1095
  {/* Swimlane layer - renders behind nodes */}
759
1096
  <SwimlaneLayer
760
1097
  swimlanes={swimlanes}
1098
+ parentHeaders={parentHeaders}
1099
+ headerRows={headerRows}
761
1100
  laneWidth={laneWidth}
762
1101
  headerHeight={headerHeight}
763
1102
  totalHeight={totalHeight}
@@ -767,16 +1106,18 @@ function SequenceDiagramInner({
767
1106
  {/* Swimlane headers layer - renders on top for clickability */}
768
1107
  <SwimlaneHeadersLayer
769
1108
  swimlanes={swimlanes}
1109
+ parentHeaders={parentHeaders}
1110
+ headerRows={headerRows}
770
1111
  laneWidth={laneWidth}
771
1112
  headerHeight={headerHeight}
772
1113
  totalHeight={totalHeight}
773
- onToggleCollapse={onToggleCollapse}
1114
+ onToggleNamespace={handleToggleNamespace}
1115
+ closingNamespaces={closingNamespaces}
774
1116
  stickyHeaders={stickyHeaders}
775
1117
  transparent={transparent}
776
1118
  />
777
1119
 
778
- {/* Collapse toggle panel (for namespaces with children) */}
779
- {swimlanes.some((s) => s.children.length > 0) && (
1120
+ {(swimlanes.some((s) => s.canExpand) || parentHeaders.length > 0) && (
780
1121
  <Panel position="top-right">
781
1122
  <div
782
1123
  style={{
@@ -787,9 +1128,15 @@ function SequenceDiagramInner({
787
1128
  fontSize: theme.fontSizes[0],
788
1129
  fontFamily: theme.fonts.body,
789
1130
  color: theme.colors.textSecondary,
1131
+ display: 'flex',
1132
+ alignItems: 'center',
1133
+ gap: 6,
790
1134
  }}
791
1135
  >
792
- Click lane headers to expand/collapse
1136
+ <StackIcon accentColor={theme.colors.primary} />
1137
+ <span>to drill in · click parent header</span>
1138
+ <StackIcon opened accentColor={theme.colors.primary} />
1139
+ <span>to close</span>
793
1140
  </div>
794
1141
  </Panel>
795
1142
  )}
@@ -146,8 +146,8 @@ function convertWorkflowToSequence(
146
146
  const isMoveEvent = eventToMoveEventMap.get(eventName) ?? true;
147
147
 
148
148
  // When the canvas provides a scope, prefix it so the event lands in that
149
- // participant's lane. Otherwise, use the event name as-is so the layout's
150
- // namespace strategy can derive the participant from the event name itself.
149
+ // participant's lane (the layout uses the first dotted segment as the
150
+ // default lane). Otherwise, use the event name as-is.
151
151
  const name = canvasScope ? `${canvasScope}.${eventName}` : eventName;
152
152
  const participant = canvasScope ?? eventName.split('.')[0] ?? eventName;
153
153
 
@@ -186,10 +186,7 @@ function convertWorkflowToSequence(
186
186
  * scenario={workflowScenario}
187
187
  * canvas={otelCanvas}
188
188
  * height={600}
189
- * layoutOptions={{
190
- * namespaceStrategy: 'first',
191
- * eventSpacing: 80,
192
- * }}
189
+ * layoutOptions={{ eventSpacing: 80 }}
193
190
  * />
194
191
  * ```
195
192
  */