@principal-ai/subsystems-react 0.37.0 → 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 (40) 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/subsystem/ComponentDeclaration.d.ts +13 -1
  6. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  7. package/dist/subsystem/ComponentDeclaration.js +58 -7
  8. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  9. package/dist/subsystem/ConstructsCatalog.d.ts +6 -3
  10. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -1
  11. package/dist/subsystem/ConstructsCatalog.js +206 -217
  12. package/dist/subsystem/ConstructsCatalog.js.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  14. package/dist/subsystem/SubsystemComponentGraph.js +36 -28
  15. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  16. package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -1
  17. package/dist/subsystem/SymbolInspectionCard.js +1 -1
  18. package/dist/subsystem/SymbolInspectionCard.js.map +1 -1
  19. package/dist/subsystem/model.d.ts.map +1 -1
  20. package/dist/subsystem/model.js +7 -1
  21. package/dist/subsystem/model.js.map +1 -1
  22. package/dist/utils/edgeLabel.d.ts +51 -0
  23. package/dist/utils/edgeLabel.d.ts.map +1 -0
  24. package/dist/utils/edgeLabel.js +81 -0
  25. package/dist/utils/edgeLabel.js.map +1 -0
  26. package/dist/utils/elkLayout.d.ts +6 -0
  27. package/dist/utils/elkLayout.d.ts.map +1 -1
  28. package/dist/utils/elkLayout.js +27 -6
  29. package/dist/utils/elkLayout.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/index.ts +7 -0
  32. package/src/stories/Subsystem/ConstructsCatalog.stories.tsx +443 -0
  33. package/src/subsystem/ComponentDeclaration.tsx +100 -7
  34. package/src/subsystem/ConstructsCatalog.tsx +291 -323
  35. package/src/subsystem/SubsystemComponentGraph.tsx +74 -29
  36. package/src/subsystem/SymbolInspectionCard.tsx +10 -9
  37. package/src/subsystem/model.ts +7 -1
  38. package/src/utils/edgeLabel.test.ts +23 -0
  39. package/src/utils/edgeLabel.ts +95 -0
  40. package/src/utils/elkLayout.ts +41 -6
@@ -70,12 +70,13 @@ import type { DeclarationSymbolRef, SymbolInspection } from './symbolRefs';
70
70
  import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
71
71
  import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
72
72
  import { WalkthroughsPanel, WALKTHROUGH_PLAY_PAUSE_MS } from './WalkthroughsPanel';
73
-
74
- /** Cap screen-space edge labels to this fraction of the edge's on-screen length. */
75
- const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
76
- /** Rough monospace width at fontSize 10 + horizontal padding/border. */
77
- const EDGE_LABEL_CHAR_PX = 6.2;
78
- 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';
79
80
 
80
81
  /** Context passed to `renderWalkthroughViewer` when a flow/step is focused. */
81
82
  export interface WalkthroughViewerContext {
@@ -2036,14 +2037,6 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
2036
2037
  const text = lbl.stepNos?.length
2037
2038
  ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}`
2038
2039
  : lbl.mechanism;
2039
- // Labels stay readable at full size until they'd exceed a share of
2040
- // the edge's screen length, then shrink with zoom.
2041
- const estWidth = text.length * EDGE_LABEL_CHAR_PX + EDGE_LABEL_PAD_PX;
2042
- const screenEdgeLen = lbl.pathLength * viewport.zoom;
2043
- const scale =
2044
- lbl.pathLength > 0 && estWidth > 0
2045
- ? Math.min(1, (screenEdgeLen * EDGE_LABEL_MAX_EDGE_FRACTION) / estWidth)
2046
- : 1;
2047
2040
  return (
2048
2041
  <div
2049
2042
  key={lbl.id}
@@ -2057,29 +2050,81 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
2057
2050
  position: 'absolute',
2058
2051
  left: screenX,
2059
2052
  top: screenY,
2060
- // Center on the flow-space midpoint. Labels live in screen
2061
- // space (fixed size when zoomed out), so top-left anchoring
2062
- // would drift them right/down of the edge as zoom drops.
2063
- 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})`,
2064
2057
  transformOrigin: 'center center',
2065
2058
  display: 'flex',
2066
2059
  alignItems: 'center',
2067
- fontSize: 10,
2068
- lineHeight: 1,
2069
- fontFamily: theme.fonts.monospace,
2070
- fontWeight: 500,
2071
- color,
2072
- background: 'rgba(21,21,21,0.9)',
2073
- border: verifiable ? `0.5px solid ${color}` : `1px dashed ${color}`,
2074
- borderRadius: verifiable ? 4 : '10px 14px 12px 16px / 14px 10px 16px 12px',
2075
- padding: '3px 8px',
2060
+ justifyContent: 'center',
2061
+ width: EDGE_LABEL_WIDTH,
2062
+ height: EDGE_LABEL_HEIGHT,
2063
+ boxSizing: 'border-box',
2064
+ padding: '7px 8px',
2076
2065
  cursor: 'pointer',
2077
2066
  pointerEvents: 'auto',
2078
2067
  opacity: lbl.dimmed ? 0.15 : 1,
2079
- whiteSpace: 'nowrap',
2080
2068
  }}
2081
2069
  >
2082
- {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>
2083
2128
  </div>
2084
2129
  );
2085
2130
  })}
@@ -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'