@principal-ai/subsystems-react 0.36.2 → 0.37.1

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.
Files changed (47) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreWalkthroughCodeView.d.ts +19 -1
  6. package/dist/pierre/PierreWalkthroughCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreWalkthroughCodeView.js +53 -2
  8. package/dist/pierre/PierreWalkthroughCodeView.js.map +1 -1
  9. package/dist/subsystem/ComponentDeclaration.d.ts +13 -1
  10. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  11. package/dist/subsystem/ComponentDeclaration.js +58 -7
  12. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  13. package/dist/subsystem/ConstructsCatalog.d.ts +6 -3
  14. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  15. package/dist/subsystem/ConstructsCatalog.js +206 -217
  16. package/dist/subsystem/ConstructsCatalog.js.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.d.ts +10 -0
  18. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.js +108 -31
  20. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  21. package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -1
  22. package/dist/subsystem/SymbolInspectionCard.js +1 -1
  23. package/dist/subsystem/SymbolInspectionCard.js.map +1 -1
  24. package/dist/subsystem/model.d.ts.map +1 -1
  25. package/dist/subsystem/model.js +7 -1
  26. package/dist/subsystem/model.js.map +1 -1
  27. package/dist/utils/edgeLabel.d.ts +51 -0
  28. package/dist/utils/edgeLabel.d.ts.map +1 -0
  29. package/dist/utils/edgeLabel.js +81 -0
  30. package/dist/utils/edgeLabel.js.map +1 -0
  31. package/dist/utils/elkLayout.d.ts +6 -0
  32. package/dist/utils/elkLayout.d.ts.map +1 -1
  33. package/dist/utils/elkLayout.js +27 -6
  34. package/dist/utils/elkLayout.js.map +1 -1
  35. package/package.json +3 -2
  36. package/src/index.ts +7 -0
  37. package/src/pierre/PierreWalkthroughCodeView.tsx +75 -1
  38. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +222 -0
  39. package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +443 -0
  40. package/src/subsystem/ComponentDeclaration.tsx +100 -7
  41. package/src/subsystem/ConstructsCatalog.tsx +291 -323
  42. package/src/subsystem/SubsystemComponentGraph.tsx +173 -30
  43. package/src/subsystem/SymbolInspectionCard.tsx +10 -9
  44. package/src/subsystem/model.ts +7 -1
  45. package/src/utils/edgeLabel.test.ts +23 -0
  46. package/src/utils/edgeLabel.ts +95 -0
  47. package/src/utils/elkLayout.ts +41 -6
@@ -53,6 +53,7 @@ import {
53
53
  } from './model';
54
54
  import { ConstructsCatalog } from './ConstructsCatalog';
55
55
  import type { SubsystemOpenFileOptions } from './declarationRef';
56
+ import type { WalkthroughSymbolQuery } from '../pierre/PierreWalkthroughCodeView';
56
57
  import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
57
58
  import { SubsystemDiagnosticToggle, type SubsystemDiagnostic } from './DiagnosticToggle';
58
59
  import {
@@ -69,12 +70,13 @@ import type { DeclarationSymbolRef, SymbolInspection } from './symbolRefs';
69
70
  import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
70
71
  import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
71
72
  import { WalkthroughsPanel, WALKTHROUGH_PLAY_PAUSE_MS } from './WalkthroughsPanel';
72
-
73
- /** Cap screen-space edge labels to this fraction of the edge's on-screen length. */
74
- const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
75
- /** Rough monospace width at fontSize 10 + horizontal padding/border. */
76
- const EDGE_LABEL_CHAR_PX = 6.2;
77
- const EDGE_LABEL_PAD_PX = 18;
73
+ import {
74
+ EDGE_LABEL_WIDTH,
75
+ EDGE_LABEL_HEIGHT,
76
+ EDGE_LABEL_FONT_SIZE,
77
+ EDGE_LABEL_CLOUD_PATH,
78
+ EDGE_LABEL_CLOUD_EXTRA_TOP,
79
+ } from '../utils/edgeLabel';
78
80
 
79
81
  /** Context passed to `renderWalkthroughViewer` when a flow/step is focused. */
80
82
  export interface WalkthroughViewerContext {
@@ -88,6 +90,28 @@ export interface WalkthroughViewerContext {
88
90
  * but whose endpoint is proposed can be labelled as planned, not missing.
89
91
  */
90
92
  proposedAliases: ReadonlySet<string>;
93
+ /**
94
+ * Resolve a token in a step's snippet to a construct the step touches (its
95
+ * `from`/`to` component), returning that component's alias. `null` when the
96
+ * token names no touched construct. Forward to the code view so constructs
97
+ * read as clickable.
98
+ */
99
+ resolveSymbol?: (query: WalkthroughSymbolQuery) => string | null;
100
+ /** A clicked construct token — open that construct's declaration line. */
101
+ onSymbolClick?: (symbol: string, query: WalkthroughSymbolQuery) => void;
102
+ }
103
+
104
+ /** Identifiers a token could match to name this component as a construct. */
105
+ function constructIdentifiers(comp: SubsystemComponent): string[] {
106
+ const ids = new Set<string>();
107
+ if (comp.name) ids.add(comp.name);
108
+ if (comp.symbol) {
109
+ ids.add(comp.symbol);
110
+ for (const part of comp.symbol.split('.')) {
111
+ if (part) ids.add(part);
112
+ }
113
+ }
114
+ return [...ids];
91
115
  }
92
116
 
93
117
  type DrawerTarget =
@@ -390,14 +414,29 @@ const WalkthroughDrawerContent = memo(function WalkthroughDrawerContent({
390
414
  stepIndex,
391
415
  onOpenFile,
392
416
  proposedAliases,
417
+ resolveSymbol,
418
+ onSymbolClick,
393
419
  }: {
394
420
  render: (ctx: WalkthroughViewerContext) => ReactNode;
395
421
  walkthrough: SubsystemWalkthrough;
396
422
  stepIndex: number | null;
397
423
  onOpenFile: (path: string, opts?: SubsystemOpenFileOptions) => void;
398
424
  proposedAliases: ReadonlySet<string>;
425
+ resolveSymbol?: (query: WalkthroughSymbolQuery) => string | null;
426
+ onSymbolClick?: (symbol: string, query: WalkthroughSymbolQuery) => void;
399
427
  }) {
400
- return <>{render({ walkthrough, stepIndex, onOpenFile, proposedAliases })}</>;
428
+ return (
429
+ <>
430
+ {render({
431
+ walkthrough,
432
+ stepIndex,
433
+ onOpenFile,
434
+ proposedAliases,
435
+ resolveSymbol,
436
+ onSymbolClick,
437
+ })}
438
+ </>
439
+ );
401
440
  });
402
441
 
403
442
  interface InnerProps extends SubsystemComponentGraphProps {
@@ -570,6 +609,10 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
570
609
  if (drawerTarget?.kind !== 'walkthrough' || !walkthroughs) return null;
571
610
  return walkthroughs.find((t) => t.id === drawerTarget.walkthroughId) ?? null;
572
611
  }, [drawerTarget, walkthroughs]);
612
+ // Ref mirror of the drawer's walkthrough id so the symbol resolver stays
613
+ // stable across graph re-renders (the drawer is memoized on callback identity).
614
+ const drawerWalkthroughIdRef = useRef<string | null>(null);
615
+ drawerWalkthroughIdRef.current = focusedWalkthrough?.id ?? null;
573
616
 
574
617
  // Walkthrough shown on the canvas title chip (focus or hover/autoplay highlight).
575
618
  const overlayWalkthroughTitle = useMemo(() => {
@@ -1576,6 +1619,60 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1576
1619
  [components, onSelect],
1577
1620
  );
1578
1621
 
1622
+ // Construct tokens inside a walkthrough snippet. A step's line is an edge
1623
+ // between its `from`/`to` components, so a token naming either of those
1624
+ // constructs should navigate to that construct's declaration. Index the
1625
+ // matchable identifiers per step (`walkthroughId:index`).
1626
+ const walkthroughSymbolIndex = useMemo(() => {
1627
+ const byAlias = new Map(components.map((c) => [c.alias, c]));
1628
+ const index = new Map<string, Map<string, string>>();
1629
+ for (const wt of walkthroughs ?? []) {
1630
+ wt.steps.forEach((step, i) => {
1631
+ const tokens = new Map<string, string>();
1632
+ for (const alias of [step.from, step.to]) {
1633
+ const comp = byAlias.get(alias);
1634
+ if (!comp) continue;
1635
+ for (const ident of constructIdentifiers(comp)) tokens.set(ident, alias);
1636
+ }
1637
+ index.set(`${wt.id}:${i}`, tokens);
1638
+ });
1639
+ }
1640
+ return index;
1641
+ }, [components, walkthroughs]);
1642
+ const walkthroughSymbolIndexRef = useRef(walkthroughSymbolIndex);
1643
+ walkthroughSymbolIndexRef.current = walkthroughSymbolIndex;
1644
+
1645
+ // Stable resolver: reads the live index + focused walkthrough from refs so
1646
+ // the memoized walkthrough drawer isn't rebuilt on every graph render.
1647
+ const resolveWalkthroughSymbol = useCallback(
1648
+ (query: WalkthroughSymbolQuery): string | null => {
1649
+ const walkthroughId = drawerWalkthroughIdRef.current;
1650
+ if (walkthroughId == null) return null;
1651
+ return (
1652
+ walkthroughSymbolIndexRef.current
1653
+ .get(`${walkthroughId}:${query.stepIndex}`)
1654
+ ?.get(query.tokenText) ?? null
1655
+ );
1656
+ },
1657
+ [],
1658
+ );
1659
+
1660
+ // A construct token click navigates to that construct's declaration: open
1661
+ // its file at the anchored declaration line (same path as the declaration
1662
+ // panel's file link), falling back to the file top when unanchored.
1663
+ const openConstructDeclaration = useCallback(
1664
+ (alias: string) => {
1665
+ const comp = components.find((c) => c.alias === alias);
1666
+ if (!comp?.file) return;
1667
+ const startLine = comp.declarationRef?.startLine;
1668
+ onOpenDeclarationFile(
1669
+ comp.file,
1670
+ startLine != null ? { startLine } : undefined,
1671
+ );
1672
+ },
1673
+ [components, onOpenDeclarationFile],
1674
+ );
1675
+
1579
1676
  // Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
1580
1677
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
1581
1678
  // the actual edge path (not node-center approximations).
@@ -1940,14 +2037,6 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1940
2037
  const text = lbl.stepNos?.length
1941
2038
  ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}`
1942
2039
  : lbl.mechanism;
1943
- // Labels stay readable at full size until they'd exceed a share of
1944
- // the edge's screen length, then shrink with zoom.
1945
- const estWidth = text.length * EDGE_LABEL_CHAR_PX + EDGE_LABEL_PAD_PX;
1946
- const screenEdgeLen = lbl.pathLength * viewport.zoom;
1947
- const scale =
1948
- lbl.pathLength > 0 && estWidth > 0
1949
- ? Math.min(1, (screenEdgeLen * EDGE_LABEL_MAX_EDGE_FRACTION) / estWidth)
1950
- : 1;
1951
2040
  return (
1952
2041
  <div
1953
2042
  key={lbl.id}
@@ -1961,29 +2050,81 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1961
2050
  position: 'absolute',
1962
2051
  left: screenX,
1963
2052
  top: screenY,
1964
- // Center on the flow-space midpoint. Labels live in screen
1965
- // space (fixed size when zoomed out), so top-left anchoring
1966
- // would drift them right/down of the edge as zoom drops.
1967
- transform: `translate(-50%, -50%) scale(${scale})`,
2053
+ // Center on the flow-space midpoint, and scale with the graph
2054
+ // (flow-space size): labels shrink as you zoom out instead of
2055
+ // staying fixed-screen-size and dominating the smaller graph.
2056
+ transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
1968
2057
  transformOrigin: 'center center',
1969
2058
  display: 'flex',
1970
2059
  alignItems: 'center',
1971
- fontSize: 10,
1972
- lineHeight: 1,
1973
- fontFamily: theme.fonts.monospace,
1974
- fontWeight: 500,
1975
- color,
1976
- background: 'rgba(21,21,21,0.9)',
1977
- border: verifiable ? `0.5px solid ${color}` : `1px dashed ${color}`,
1978
- borderRadius: verifiable ? 4 : '10px 14px 12px 16px / 14px 10px 16px 12px',
1979
- padding: '3px 8px',
2060
+ justifyContent: 'center',
2061
+ width: EDGE_LABEL_WIDTH,
2062
+ height: EDGE_LABEL_HEIGHT,
2063
+ boxSizing: 'border-box',
2064
+ padding: '7px 8px',
1980
2065
  cursor: 'pointer',
1981
2066
  pointerEvents: 'auto',
1982
2067
  opacity: lbl.dimmed ? 0.15 : 1,
1983
- whiteSpace: 'nowrap',
1984
2068
  }}
1985
2069
  >
1986
- {text}
2070
+ {/* Background layer. Opaque theme surface (matches the node fill)
2071
+ so the edge line behind is hidden. Verifiable mechanisms get a
2072
+ crisp rounded box; soft / ambiguous ones (uses, feeds,
2073
+ watches …) get a real lobed cloud silhouette — ambiguous, not
2074
+ a dashed proposal (or a pill, which border-radius can only
2075
+ ever make). */}
2076
+ {verifiable ? (
2077
+ <span
2078
+ aria-hidden
2079
+ style={{
2080
+ position: 'absolute',
2081
+ inset: 0,
2082
+ borderRadius: 4,
2083
+ border: `0.5px solid ${color}`,
2084
+ background:
2085
+ theme.colors.backgroundSecondary ?? theme.colors.background,
2086
+ pointerEvents: 'none',
2087
+ }}
2088
+ />
2089
+ ) : (
2090
+ <svg
2091
+ aria-hidden
2092
+ width={EDGE_LABEL_WIDTH}
2093
+ height={EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP}
2094
+ viewBox={`0 0 ${EDGE_LABEL_WIDTH} ${EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP}`}
2095
+ style={{
2096
+ position: 'absolute',
2097
+ left: 0,
2098
+ top: -EDGE_LABEL_CLOUD_EXTRA_TOP,
2099
+ pointerEvents: 'none',
2100
+ }}
2101
+ >
2102
+ <path
2103
+ d={EDGE_LABEL_CLOUD_PATH}
2104
+ fill={theme.colors.backgroundSecondary ?? theme.colors.background}
2105
+ stroke={hexWithAlpha(color, 0.75)}
2106
+ strokeWidth={1.2}
2107
+ strokeLinejoin="round"
2108
+ />
2109
+ </svg>
2110
+ )}
2111
+ <span
2112
+ style={{
2113
+ position: 'relative',
2114
+ display: 'block',
2115
+ maxWidth: '100%',
2116
+ overflow: 'hidden',
2117
+ textOverflow: 'ellipsis',
2118
+ whiteSpace: 'nowrap',
2119
+ fontSize: EDGE_LABEL_FONT_SIZE,
2120
+ lineHeight: 1,
2121
+ fontFamily: theme.fonts.monospace,
2122
+ fontWeight: 500,
2123
+ color,
2124
+ }}
2125
+ >
2126
+ {text}
2127
+ </span>
1987
2128
  </div>
1988
2129
  );
1989
2130
  })}
@@ -2210,6 +2351,8 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
2210
2351
  stepIndex={drawerTarget.stepIndex}
2211
2352
  onOpenFile={onOpenFileFromWalkthrough}
2212
2353
  proposedAliases={proposedAliases}
2354
+ resolveSymbol={resolveWalkthroughSymbol}
2355
+ onSymbolClick={openConstructDeclaration}
2213
2356
  />
2214
2357
  ) : drawerTarget?.kind === 'file' ? (
2215
2358
  <FileDrawerContent
@@ -337,7 +337,7 @@ export function SymbolInspectionCard({
337
337
  ×
338
338
  </button>
339
339
 
340
- {/* Top: the declaration itself, full-bleed (or status when there's none). */}
340
+ {/* Top: the declaration itself, full-bleed (or just the name on a miss). */}
341
341
  <div
342
342
  style={{
343
343
  padding: '12px 32px 12px 12px',
@@ -363,20 +363,21 @@ export function SymbolInspectionCard({
363
363
  )}
364
364
 
365
365
  {!error && !hasBody && (
366
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
367
- <span style={{ color: theme.colors.accent ?? theme.colors.secondary, fontWeight: 600 }}>
368
- {symbolRef.name}
369
- </span>
370
- <span style={{ color: muted, fontFamily: theme.fonts.body, fontSize: body }}>
371
- {statusText}
372
- </span>
373
- </div>
366
+ <span style={{ color: theme.colors.accent ?? theme.colors.secondary, fontWeight: 600 }}>
367
+ {symbolRef.name}
368
+ </span>
374
369
  )}
375
370
  </div>
376
371
 
377
372
  {/* Meta: where it lives, ambiguity, and the action. */}
378
373
  {!error && (
379
374
  <div style={{ padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 }}>
375
+ {!hasBody && (
376
+ <span style={{ color: muted, fontFamily: theme.fonts.body, fontSize: body }}>
377
+ {statusText}
378
+ </span>
379
+ )}
380
+
380
381
  {node?.sourceFile && (
381
382
  <StackedRow label="Source">
382
383
  {onOpenFile ? (
@@ -18,6 +18,7 @@ import {
18
18
  type Node,
19
19
  } from '@xyflow/react';
20
20
  import { computeElkLayout, calculatePathLength } from '../utils/elkLayout';
21
+ import { EDGE_LABEL_SIDE_PADDING, EDGE_ARROW_INSET } from '../utils/edgeLabel';
21
22
  import type { GraphifyComponentDetail } from '../graphify';
22
23
  import type { SubsystemDeclarationRef } from './declarationRef';
23
24
  import { purlOwnerName, purlRepoKey } from './paths';
@@ -1642,7 +1643,12 @@ export async function buildSubsystemGraph(
1642
1643
  nodeSpacing: 60,
1643
1644
  edgeSpacing: 30,
1644
1645
  edgeNodeSpacing: 60,
1645
- interLayerSpacing: 120,
1646
+ // With labels on, ELK's CENTER_LAYER strategy applies this spacing on
1647
+ // both sides of the label layer, so it IS the per-side clearance — the
1648
+ // label box itself is reserved separately in elkLayout. A large value
1649
+ // here would be counted twice. Labels off → ordinary layer gap.
1650
+ interLayerSpacing: showEdgeLabels === false ? 120 : EDGE_LABEL_SIDE_PADDING,
1651
+ endpointInset: EDGE_ARROW_INSET,
1646
1652
  preserveNodePositions: false,
1647
1653
  edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
1648
1654
  groups: layoutGroups.map((g) => ({
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Tests for the shared edge-label geometry.
3
+ *
4
+ * The overlay and the ELK reservation both import these values, so the
5
+ * invariants here (fixed box, min edge run covers the box) are what keep
6
+ * rendered labels consistent and non-overlapping.
7
+ */
8
+
9
+ import { describe, test, expect } from 'bun:test';
10
+ import {
11
+ EDGE_LABEL_WIDTH,
12
+ EDGE_LABEL_SIDE_PADDING,
13
+ EDGE_LABEL_EDGE_GAP,
14
+ } from './edgeLabel';
15
+
16
+ describe('edge-label geometry', () => {
17
+ test('the labelled-edge gap is the box plus clearance on both sides', () => {
18
+ expect(EDGE_LABEL_EDGE_GAP).toBe(
19
+ EDGE_LABEL_WIDTH + EDGE_LABEL_SIDE_PADDING * 2,
20
+ );
21
+ expect(EDGE_LABEL_EDGE_GAP).toBeGreaterThan(EDGE_LABEL_WIDTH);
22
+ });
23
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Shared geometry for the HTML edge-label overlay and the ELK layout that
3
+ * reserves room for it.
4
+ *
5
+ * The overlay (SubsystemComponentGraph) and the layout (elkLayout) must agree
6
+ * on one fixed box size, or the space ELK leaves for a label drifts from what
7
+ * actually renders. Keeping the geometry here lets both import the same values
8
+ * without a component → util → component import cycle.
9
+ */
10
+
11
+ /** Fixed width of every edge label, in flow/screen px. Wide enough for the
12
+ * longest mechanism (`registers-into`) plus horizontal padding at the label
13
+ * font size. */
14
+ export const EDGE_LABEL_WIDTH = 140;
15
+
16
+ /** Fixed height of every edge label. Sized to the label font plus padding. */
17
+ export const EDGE_LABEL_HEIGHT = 40;
18
+
19
+ /** Label font size. Fixed so every label renders at the same text size. */
20
+ export const EDGE_LABEL_FONT_SIZE = 14;
21
+
22
+ /**
23
+ * Clear space we want on each side of a label box.
24
+ *
25
+ * Passed as ELK's `interLayerSpacing` (`nodeNodeBetweenLayers`). ELK's
26
+ * `CENTER_LAYER` label strategy inserts a dedicated label layer and applies
27
+ * that spacing on *both* sides of it, so the resulting gap between the two
28
+ * nodes an edge connects is:
29
+ *
30
+ * EDGE_LABEL_WIDTH + 2 * EDGE_LABEL_SIDE_PADDING
31
+ *
32
+ * i.e. the reserved label box plus this clearance at each end. Setting a big
33
+ * "min edge length" here double-counts (it is applied twice), which is how the
34
+ * gap silently ballooned before.
35
+ */
36
+ export const EDGE_LABEL_SIDE_PADDING = 32;
37
+
38
+ /** Actual end-to-end gap ELK leaves for a labelled edge (box + both clearances). */
39
+ export const EDGE_LABEL_EDGE_GAP =
40
+ EDGE_LABEL_WIDTH + EDGE_LABEL_SIDE_PADDING * 2;
41
+
42
+ /**
43
+ * How far (in flow px) the target end of an edge stops short of the node
44
+ * border. ELK ends the path exactly on the border, so the arrowhead tip lands
45
+ * on the border line; pulling it back this much lets the arrow visually touch
46
+ * the node without overlapping its border. 0 = flush (old behaviour).
47
+ */
48
+ export const EDGE_ARROW_INSET = 2;
49
+
50
+ /**
51
+ * Build a "cloud" outline that fills a `w × h` box: a flat-ish bottom with
52
+ * rounded corners and three top lobes (large left, medium middle, small right).
53
+ * Used as the background silhouette for ambiguous (non-verifiable) edge labels —
54
+ * a real cloud shape, which `border-radius` cannot express. Control points are
55
+ * normalized (`0…1`) so the shape scales with the label box.
56
+ */
57
+ function cloudPath(w: number, h: number): string {
58
+ const p = (nx: number, ny: number) =>
59
+ `${(nx * w).toFixed(2)} ${(ny * h).toFixed(2)}`;
60
+ return [
61
+ `M ${p(0.043, 0.65)}`,
62
+ // Bottom-left rounded corner + flat bottom.
63
+ `C ${p(0.014, 0.8)} ${p(0.05, 0.925)} ${p(0.114, 0.925)}`,
64
+ `L ${p(0.829, 0.925)}`,
65
+ // Bottom-right rounded corner up into the right (small) lobe.
66
+ `C ${p(0.914, 0.925)} ${p(0.986, 0.825)} ${p(0.986, 0.675)}`,
67
+ `C ${p(0.986, 0.475)} ${p(0.943, 0.325)} ${p(0.879, 0.325)}`,
68
+ `C ${p(0.829, 0.325)} ${p(0.793, 0.425)} ${p(0.771, 0.525)}`,
69
+ // Middle lobe.
70
+ `C ${p(0.736, 0.275)} ${p(0.664, 0.15)} ${p(0.6, 0.2)}`,
71
+ `C ${p(0.564, 0.225)} ${p(0.536, 0.3)} ${p(0.521, 0.375)}`,
72
+ // Big left lobe.
73
+ `C ${p(0.486, 0.1)} ${p(0.4, 0.025)} ${p(0.336, 0.075)}`,
74
+ `C ${p(0.279, 0.1)} ${p(0.243, 0.225)} ${p(0.221, 0.375)}`,
75
+ // Down the left side, back to the corner.
76
+ `C ${p(0.2, 0.5)} ${p(0.143, 0.475)} ${p(0.1, 0.525)}`,
77
+ `C ${p(0.064, 0.55)} ${p(0.057, 0.6)} ${p(0.043, 0.65)}`,
78
+ 'Z',
79
+ ].join(' ');
80
+ }
81
+
82
+ /**
83
+ * Extra height (flow px) the cloud rises above the label box. The cloud and
84
+ * its text share the label box, but ambiguous labels draw a taller silhouette
85
+ * that overhangs upward — so the cloud gets more presence without moving the
86
+ * text or changing the verifiable labels' box.
87
+ */
88
+ export const EDGE_LABEL_CLOUD_EXTRA_TOP = 14;
89
+
90
+ /** Cloud silhouette for ambiguous edge labels, matched to the label box. */
91
+ export const EDGE_LABEL_CLOUD_PATH = cloudPath(
92
+ EDGE_LABEL_WIDTH,
93
+ EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP,
94
+ );
95
+
@@ -7,6 +7,11 @@
7
7
 
8
8
  import ELK, { type ElkNode, type ElkExtendedEdge, type LayoutOptions } from 'elkjs/lib/elk.bundled.js';
9
9
  import type { Node, Edge } from '@xyflow/react';
10
+ import {
11
+ EDGE_LABEL_WIDTH,
12
+ EDGE_LABEL_HEIGHT,
13
+ EDGE_LABEL_SIDE_PADDING,
14
+ } from './edgeLabel';
10
15
 
11
16
  /** ELK layout options for different routing styles */
12
17
  export type ElkRoutingStyle = 'orthogonal' | 'splines' | 'polyline';
@@ -54,6 +59,13 @@ export interface ElkLayoutOptions {
54
59
  */
55
60
  interLayerSpacing?: number;
56
61
 
62
+ /**
63
+ * Pull the target end of each edge back from the node border by this many
64
+ * flow px, so the arrowhead tip touches the node without sitting on its
65
+ * border. @default 0
66
+ */
67
+ endpointInset?: number;
68
+
57
69
  /**
58
70
  * Reserve space along edges for inline labels so they don't overlap nodes
59
71
  * or other edges. When enabled ELK places labels inline on the edge with the
@@ -486,6 +498,7 @@ export async function computeElkLayout(
486
498
  ): Promise<ElkLayoutResult> {
487
499
  const { preserveNodePositions = true, keepSingletonGroups = false } = options;
488
500
  const edgeLabels = options.edgeLabels;
501
+ const endpointInset = options.endpointInset ?? 0;
489
502
  const direction = options.direction ?? 'RIGHT';
490
503
 
491
504
  // Build a map of original node positions BEFORE passing to ELK
@@ -620,13 +633,12 @@ export async function computeElkLayout(
620
633
  sources: [sourcePort],
621
634
  targets: [targetPort],
622
635
  };
623
- // Estimated label size so ELK reserves room to render the inline label
624
- // without it overlapping nodes or sibling edges.
636
+ // Reserve the fixed label box so ELK leaves the same room for every label,
637
+ // regardless of text length. The overlay renders into this exact box.
625
638
  if (edgeLabels?.enabled !== false && typeof edge.label === 'string') {
626
- const text = edge.label;
627
- const labelWidth = Math.max(20, text.length * 7); // ~7px per mono char
628
- const labelHeight = 14;
629
- elkEdge.labels = [{ text, width: labelWidth, height: labelHeight }];
639
+ elkEdge.labels = [
640
+ { text: edge.label, width: EDGE_LABEL_WIDTH, height: EDGE_LABEL_HEIGHT },
641
+ ];
630
642
  }
631
643
  return elkEdge;
632
644
  });
@@ -647,6 +659,12 @@ export async function computeElkLayout(
647
659
  // edge by ~13px) plus a gap before the first child.
648
660
  'elk.padding': '[top=64,left=24,bottom=24,right=24]',
649
661
  'elk.spacing.nodeNode': '40',
662
+ // Edges inside a frame host labels too: ELK applies this on both sides of
663
+ // the reserved label layer, so it is the per-side clearance (same as root).
664
+ // Labels off falls back to a plain between-layer gap.
665
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(
666
+ edgeLabels?.enabled === false ? 40 : EDGE_LABEL_SIDE_PADDING,
667
+ ),
650
668
  };
651
669
 
652
670
  const plan = planCompoundGroups(groupDefs, elkById.keys(), keepSingletonGroups);
@@ -900,6 +918,23 @@ export async function computeElkLayout(
900
918
  allPoints.push(end);
901
919
  }
902
920
 
921
+ // Pull the target end back from the node border so the arrowhead tip
922
+ // touches the node without overlapping its border.
923
+ if (endpointInset > 0 && allPoints.length >= 2) {
924
+ const end = allPoints[allPoints.length - 1];
925
+ const prev = allPoints[allPoints.length - 2];
926
+ const dx = end.x - prev.x;
927
+ const dy = end.y - prev.y;
928
+ const segLen = Math.hypot(dx, dy);
929
+ if (segLen > endpointInset) {
930
+ const t = (segLen - endpointInset) / segLen;
931
+ allPoints[allPoints.length - 1] = {
932
+ x: prev.x + dx * t,
933
+ y: prev.y + dy * t,
934
+ };
935
+ }
936
+ }
937
+
903
938
  // Convert to path
904
939
  const path =
905
940
  options.routingStyle === 'orthogonal'