@principal-ai/principal-view-react 0.16.57 → 0.16.59

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.
@@ -2,6 +2,9 @@ import { describe, expect, test } from 'bun:test';
2
2
  import {
3
3
  convertSubsystemToNodes,
4
4
  convertSubsystemToEdges,
5
+ convertSubsystemToGroups,
6
+ getSubsystemRegions,
7
+ processGroupNodeId,
5
8
  buildSubsystemGraph,
6
9
  deriveNameFromSymbol,
7
10
  formatPurl,
@@ -121,6 +124,59 @@ describe('subsystem graph model', () => {
121
124
  expect(formatPurl('not-a-purl')).toBe('not-a-purl');
122
125
  });
123
126
 
127
+ test('getSubsystemRegions groups by process, skipping process-less nodes', () => {
128
+ const regions = getSubsystemRegions({
129
+ components: [
130
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
131
+ { id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
132
+ { id: 'c', name: 'c', construct: 'function', file: 'c.ts', purl: 'pkg:github/acme/app', process: 'app/renderer' },
133
+ { id: 'd', name: 'd', construct: 'function', file: 'd.ts', purl: 'pkg:github/acme/app' },
134
+ ],
135
+ });
136
+ expect(regions.map((r) => r.key)).toEqual(['app/host', 'app/renderer']);
137
+ expect(regions[0]!.memberIds).toEqual(['a', 'b']);
138
+ });
139
+
140
+ test('convertSubsystemToNodes stamps parentId for process members only', () => {
141
+ const nodes = convertSubsystemToNodes({
142
+ components: [
143
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
144
+ { id: 'd', name: 'd', construct: 'function', file: 'd.ts', purl: 'pkg:github/acme/app' },
145
+ ],
146
+ edges: [],
147
+ });
148
+ expect((nodes.find((n) => n.id === 'a') as { parentId?: string }).parentId).toBe(
149
+ processGroupNodeId('app/host'),
150
+ );
151
+ expect((nodes.find((n) => n.id === 'd') as { parentId?: string }).parentId).toBeUndefined();
152
+ });
153
+
154
+ test('convertSubsystemToGroups emits one parent per process', () => {
155
+ const groups = convertSubsystemToGroups({
156
+ components: [
157
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
158
+ { id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
159
+ ],
160
+ });
161
+ expect(groups).toHaveLength(1);
162
+ expect(groups[0]!.id).toBe(processGroupNodeId('app/host'));
163
+ expect(groups[0]!.type).toBe('subsystem-group');
164
+ });
165
+
166
+ test('buildSubsystemGraph drops singleton process frames (no parentId, no group)', async () => {
167
+ const { nodes, regions } = await buildSubsystemGraph({
168
+ components: [
169
+ { id: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
170
+ { id: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
171
+ { id: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
172
+ ],
173
+ edges: [{ id: 'e1', from: 'a', to: 'b', mechanism: 'calls' }],
174
+ });
175
+ expect(regions.map((r) => r.key)).toEqual(['app/host']);
176
+ expect((nodes.find((n) => n.id === 'solo') as { parentId?: string }).parentId).toBeUndefined();
177
+ expect(nodes.find((n) => n.id === processGroupNodeId('app/lonely'))).toBeUndefined();
178
+ });
179
+
124
180
  test('subsystemGraphLayoutKey ignores declarationRef-only changes', () => {
125
181
  const base = { components: comps, edges };
126
182
  const withRef = {
@@ -17,7 +17,7 @@ import {
17
17
  type Edge,
18
18
  type Node,
19
19
  } from '@xyflow/react';
20
- import { computeElkLayout } from '../utils/elkLayout';
20
+ import { computeElkLayout, calculatePathLength } from '../utils/elkLayout';
21
21
  import type { GraphifyComponentDetail } from '../graphify';
22
22
  import type { SubsystemDeclarationRef } from './declarationRef';
23
23
 
@@ -177,6 +177,38 @@ export interface SubsystemComponentEdge {
177
177
  refs?: string[];
178
178
  }
179
179
 
180
+ /**
181
+ * A single site on an existing edge — the exact `file:line` where that edge's
182
+ * seam manifests for a given flow. The edge stays the abstract contract
183
+ * (`from`, `to`, `mechanism`); a throughline step picks the concrete
184
+ * manifestation. One edge can appear in many steps.
185
+ */
186
+ export interface SubsystemThroughlineStep {
187
+ /** Id of the existing edge this hop traverses. */
188
+ edgeId: string;
189
+ /** Repo-root-relative path of the file where the edge fires. */
190
+ file: string;
191
+ /** 1-based line of the site within `file`. */
192
+ line: number;
193
+ /**
194
+ * Frame name for this hop — the function/method/symbol on the stack at
195
+ * this site. Optional so existing throughlines keep working; when set the
196
+ * flows list shows it instead of mechanism + filename.
197
+ */
198
+ symbol?: string;
199
+ }
200
+
201
+ /**
202
+ * An ordered execution story over a graph's edges — each step references an
203
+ * existing edge and the exact site where that relationship fires for a flow;
204
+ * ordering is the array. One throughline per flow (save flow, load flow, …).
205
+ */
206
+ export interface SubsystemThroughline {
207
+ id: string;
208
+ title: string;
209
+ steps: SubsystemThroughlineStep[];
210
+ }
211
+
180
212
  /**
181
213
  * Derive a consistent display `name` from a code `symbol` + construct.
182
214
  *
@@ -250,6 +282,8 @@ export function formatPurl(purl: string): string {
250
282
  export interface SubsystemGraphDocument {
251
283
  components: SubsystemComponent[];
252
284
  edges: SubsystemComponentEdge[];
285
+ /** Ordered execution stories over the graph's edges (one per flow). */
286
+ throughlines?: SubsystemThroughline[];
253
287
  }
254
288
 
255
289
  // ---------------------------------------------------------------------------
@@ -258,15 +292,65 @@ export interface SubsystemGraphDocument {
258
292
 
259
293
  export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
260
294
 
295
+ /**
296
+ * One process boundary region — all components sharing a `process` value.
297
+ * Nodes without a `process` sit outside every boundary (no region).
298
+ */
299
+ export interface SubsystemProcessRegion {
300
+ /** The `process` value (e.g. `trail-viewer/host`). */
301
+ key: string;
302
+ /** Display label for the boundary frame. */
303
+ label: string;
304
+ /** Component ids that are members of this region. */
305
+ memberIds: string[];
306
+ }
307
+
308
+ /** React Flow id for a process boundary group node. */
309
+ export function processGroupNodeId(processKey: string): string {
310
+ return `process:${processKey}`;
311
+ }
312
+
313
+ /**
314
+ * Derive boundary regions from a document — one per distinct non-empty
315
+ * `process` value, in first-appearance order.
316
+ */
317
+ export function getSubsystemRegions(
318
+ doc: Pick<SubsystemGraphDocument, 'components'>,
319
+ ): SubsystemProcessRegion[] {
320
+ const byProcess = new Map<string, string[]>();
321
+ for (const c of doc.components) {
322
+ const p = c.process?.trim();
323
+ if (!p) continue;
324
+ const list = byProcess.get(p) ?? [];
325
+ list.push(c.id);
326
+ byProcess.set(p, list);
327
+ }
328
+ return [...byProcess.entries()].map(([key, memberIds]) => ({
329
+ key,
330
+ label: key,
331
+ memberIds,
332
+ }));
333
+ }
334
+
261
335
  export interface SubsystemGraphNodeData extends Record<string, unknown> {
262
336
  component: SubsystemComponent;
263
337
  /** Set while a file is open in the drawer: true if this node's component
264
338
  * lives in that file (spotlighted), false otherwise (dimmed). Absent when
265
339
  * no file is open — render neutrally. */
266
340
  fileMatch?: boolean;
341
+ /** True while this node is on an opened-but-unselected flow. */
342
+ dimmed?: boolean;
267
343
  }
268
344
 
269
- export type SubsystemGraphNode = Node<SubsystemGraphNodeData, SubsystemGraphNodeType>;
345
+ export interface SubsystemGroupNodeData extends Record<string, unknown> {
346
+ region: SubsystemProcessRegion;
347
+ /** True while the region's members are dimmed by flow focus. */
348
+ dimmed?: boolean;
349
+ }
350
+
351
+ export type SubsystemGraphNode =
352
+ | Node<SubsystemGraphNodeData, 'subsystem-component'>
353
+ | Node<SubsystemGroupNodeData, 'subsystem-group'>;
270
354
 
271
355
  export interface SubsystemGraphEdgeData extends Record<string, unknown> {
272
356
  mechanism: SubsystemEdgeMechanism;
@@ -277,6 +361,8 @@ export interface SubsystemGraphEdgeData extends Record<string, unknown> {
277
361
  /** ELK-computed label midpoint (from the actual edge path, not node centers). */
278
362
  labelX?: number;
279
363
  labelY?: number;
364
+ /** Polyline length in flow-space units (for capping screen-space label size). */
365
+ pathLength?: number;
280
366
  /** ELK-computed SVG edge path (overrides React Flow's default path). */
281
367
  elkPath?: string;
282
368
  }
@@ -358,12 +444,11 @@ export const ROLE_LABEL: Record<SubsystemComponentRole, string> = {
358
444
  };
359
445
 
360
446
  /**
361
- * Convert a subsystem graph document into React Flow nodes. We render **flat**
362
- * (no React Flow parent/group nodes) for robustness: package regions are laid
363
- * out in a grid and each component carries its package + a `pkgBounds`
364
- * rectangle on its node data so the group wrapper (drawn by the graph
365
- * component) can frame it. Only components' real positions matter to React
366
- * Flow; the package boundary is a visual region, not a sub-flow node.
447
+ * Convert a subsystem graph document into React Flow nodes. Components that
448
+ * carry a `process` get a `parentId` pointing at their boundary group node
449
+ * (`process:<process>`); nodes without one stay top-level (outside every
450
+ * boundary). The initial grid groups by `process ?? purl` so the pre-ELK
451
+ * positions are already clustered; ELK then refines with compound layout.
367
452
  */
368
453
  export function convertSubsystemToNodes(
369
454
  doc: SubsystemGraphDocument,
@@ -411,9 +496,11 @@ export function convertSubsystemToNodes(
411
496
  const cssBorder = 4; // 2px border each side
412
497
  const rawWidth = Math.max(cssMinWidth, textWidth + cssPadding + cssBorder);
413
498
  const nodeWidth = Math.max(cssMinWidth, Math.min(cap, rawWidth));
499
+ const processKey = c.process?.trim();
414
500
  nodes.push({
415
501
  id: c.id,
416
502
  type: 'subsystem-component',
503
+ ...(processKey ? { parentId: processGroupNodeId(processKey) } : {}),
417
504
  position: { x: PAD + col * COL_W, y: cursorY + row * ROW_H },
418
505
  width: nodeWidth,
419
506
  height: 84,
@@ -425,6 +512,24 @@ export function convertSubsystemToNodes(
425
512
  return nodes;
426
513
  }
427
514
 
515
+ /**
516
+ * Convert boundary regions into React Flow parent (group) nodes. One per
517
+ * distinct `process` value; member components point at these via `parentId`.
518
+ * Positions/sizes are placeholders — ELK compound layout overwrites them.
519
+ */
520
+ export function convertSubsystemToGroups(
521
+ doc: Pick<SubsystemGraphDocument, 'components'>,
522
+ ): SubsystemGraphNode[] {
523
+ return getSubsystemRegions(doc).map((region) => ({
524
+ id: processGroupNodeId(region.key),
525
+ type: 'subsystem-group',
526
+ position: { x: 0, y: 0 },
527
+ width: 400,
528
+ height: 300,
529
+ data: { region },
530
+ }));
531
+ }
532
+
428
533
  /**
429
534
  * Convert a subsystem graph document into React Flow edges. Edges whose target
430
535
  * is an external label (not a component id) point at a synthetic stub so the
@@ -447,7 +552,7 @@ export function convertSubsystemToEdges(doc: SubsystemGraphDocument): SubsystemG
447
552
  target: targetId,
448
553
  data: { mechanism: e.mechanism, refs: e.refs },
449
554
  type: 'subsystem-edge',
450
- markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
555
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: 32, height: 32 },
451
556
  style: { color, stroke: color, strokeDasharray: style === 'dashed' ? '6 4' : undefined },
452
557
  // `label` feeds ELK's label-space reservation only; the visible label is
453
558
  // rendered by the custom SubsystemEdge as an HTML overlay.
@@ -486,10 +591,23 @@ export async function buildSubsystemGraph(
486
591
  ): Promise<{
487
592
  nodes: SubsystemGraphNode[];
488
593
  edges: SubsystemGraphEdge[];
594
+ regions: SubsystemProcessRegion[];
489
595
  }> {
490
596
  const { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights } = opts;
491
597
  const nodes = convertSubsystemToNodes(doc, { maxNodeWidth });
492
598
  const edges = convertSubsystemToEdges(doc);
599
+ // Boundary regions: one per multi-member process. Singletons get no frame —
600
+ // strip the parentId convertSubsystemToNodes stamped so React Flow never
601
+ // points at a non-existent parent.
602
+ const regions = getSubsystemRegions(doc).filter((r) => r.memberIds.length >= 2);
603
+ const regionKeys = new Set(regions.map((r) => r.key));
604
+ for (const n of nodes) {
605
+ if (n.type !== 'subsystem-component') continue;
606
+ const proc = (n.data as SubsystemGraphNodeData).component?.process?.trim();
607
+ if (proc && !regionKeys.has(proc)) {
608
+ delete (n as { parentId?: string }).parentId;
609
+ }
610
+ }
493
611
 
494
612
  // External edge targets that aren't real components → create stub nodes so
495
613
  // cross-package edges have something to land on.
@@ -538,10 +656,12 @@ export async function buildSubsystemGraph(
538
656
  }
539
657
  }
540
658
 
541
- // ELK auto-layout: position nodes (layered, minimized crossings).
659
+ // ELK auto-layout: position nodes (layered, minimized crossings) with
660
+ // process partitions as compound parents so boundaries shape the layout.
542
661
  let placedNodes = nodes;
543
662
  let labelPositions = new Map<string, { x: number; y: number }>();
544
663
  let elkPathStrings = new Map<string, string>();
664
+ let elkPathPoints = new Map<string, { x: number; y: number }[]>();
545
665
  if (nodes.length > 0) {
546
666
  try {
547
667
  const result = await computeElkLayout(nodes, edges, {
@@ -553,10 +673,29 @@ export async function buildSubsystemGraph(
553
673
  interLayerSpacing: 120,
554
674
  preserveNodePositions: false,
555
675
  edgeLabels: showEdgeLabels === false ? { enabled: false } : { enabled: true, placement: 'CENTER' },
676
+ groups: regions.map((r) => ({
677
+ id: processGroupNodeId(r.key),
678
+ memberIds: r.memberIds,
679
+ })),
680
+ });
681
+ const groupNodes: SubsystemGraphNode[] = regions.flatMap((region) => {
682
+ const bounds = result.groupBounds.get(processGroupNodeId(region.key));
683
+ if (!bounds) return [];
684
+ const group: SubsystemGraphNode = {
685
+ id: processGroupNodeId(region.key),
686
+ type: 'subsystem-group',
687
+ position: { x: bounds.x, y: bounds.y },
688
+ width: Math.max(200, bounds.width),
689
+ height: Math.max(160, bounds.height),
690
+ data: { region },
691
+ };
692
+ return [group];
556
693
  });
557
- placedNodes = result.nodes as SubsystemGraphNode[];
694
+ // Parents first React Flow resolves children via parentId.
695
+ placedNodes = [...groupNodes, ...(result.nodes as SubsystemGraphNode[])];
558
696
  labelPositions = result.edgeLabelPositions;
559
697
  elkPathStrings = result.edgePaths;
698
+ elkPathPoints = result.edgePathPoints;
560
699
  } catch (err) {
561
700
  // Fall back to the (unpositioned) grid if ELK is unavailable.
562
701
  console.warn('[subsystem-graph] ELK layout failed, using manual positions:', err);
@@ -577,7 +716,12 @@ export async function buildSubsystemGraph(
577
716
  const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
578
717
  d.elkPath = elkP;
579
718
  }
719
+ const pts = elkPathPoints.get(e.id);
720
+ if (pts && pts.length > 1) {
721
+ const d = (e as SubsystemGraphEdge).data as SubsystemGraphEdgeData;
722
+ d.pathLength = calculatePathLength(pts);
723
+ }
580
724
  }
581
725
 
582
- return { nodes: placedNodes, edges };
726
+ return { nodes: placedNodes, edges, regions };
583
727
  }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility, hexWithAlpha } from './nodes';
3
+
4
+ describe('hexWithAlpha', () => {
5
+ test('appends a two-digit alpha to #rrggbb', () => {
6
+ expect(hexWithAlpha('#4ec9b0', 1)).toBe('#4ec9b0ff');
7
+ expect(hexWithAlpha('#4ec9b0', EDGE_DIM_ALPHA)).toBe('#4ec9b026');
8
+ });
9
+
10
+ test('expands #rgb', () => {
11
+ expect(hexWithAlpha('#abc', 1)).toBe('#aabbccff');
12
+ });
13
+
14
+ test('leaves non-hex values alone', () => {
15
+ expect(hexWithAlpha('teal', 0.15)).toBe('teal');
16
+ });
17
+ });
18
+
19
+ describe('fileMatchForNode', () => {
20
+ test('no open file → neutral', () => {
21
+ expect(fileMatchForNode('src/a.ts', null, false)).toBeUndefined();
22
+ });
23
+
24
+ test('open file spotlights matches and dims others', () => {
25
+ expect(fileMatchForNode('src/a.ts', 'src/a.ts', false)).toBe(true);
26
+ expect(fileMatchForNode('src/b.ts', 'src/a.ts', false)).toBe(false);
27
+ });
28
+
29
+ test('focused-edge endpoint is not dimmed when it lives in another file', () => {
30
+ expect(fileMatchForNode('src/target.ts', 'src/source.ts', true)).toBeUndefined();
31
+ expect(fileMatchForNode('src/source.ts', 'src/source.ts', true)).toBe(true);
32
+ });
33
+ });
34
+
35
+ describe('flowElementVisibility', () => {
36
+ test('nothing open or selected → everything full', () => {
37
+ expect(flowElementVisibility({ inOpened: false, inSelected: false, anyOpened: false, anySelected: false }))
38
+ .toEqual({ hidden: false, dimmed: false });
39
+ });
40
+
41
+ test('opened, nothing selected → opened members full, rest hidden', () => {
42
+ expect(flowElementVisibility({ inOpened: true, inSelected: false, anyOpened: true, anySelected: false }))
43
+ .toEqual({ hidden: false, dimmed: false });
44
+ expect(flowElementVisibility({ inOpened: false, inSelected: false, anyOpened: true, anySelected: false }))
45
+ .toEqual({ hidden: true, dimmed: false });
46
+ });
47
+
48
+ test('selected flow or step full; other opened members dimmed; rest hidden', () => {
49
+ expect(flowElementVisibility({ inOpened: true, inSelected: true, anyOpened: true, anySelected: true }))
50
+ .toEqual({ hidden: false, dimmed: false });
51
+ expect(flowElementVisibility({ inOpened: true, inSelected: false, anyOpened: true, anySelected: true }))
52
+ .toEqual({ hidden: false, dimmed: true });
53
+ expect(flowElementVisibility({ inOpened: false, inSelected: false, anyOpened: true, anySelected: true }))
54
+ .toEqual({ hidden: true, dimmed: false });
55
+ });
56
+ });
@@ -11,6 +11,7 @@ import { useState } from 'react';
11
11
  import {
12
12
  Handle,
13
13
  Position,
14
+ type Node,
14
15
  type NodeProps,
15
16
  type EdgeProps,
16
17
  } from '@xyflow/react';
@@ -21,7 +22,9 @@ import {
21
22
  ROLE_COLOR,
22
23
  ROLE_LABEL,
23
24
  deriveNameFromSymbol,
24
- type SubsystemGraphNode,
25
+ packageColor,
26
+ type SubsystemGraphNodeData,
27
+ type SubsystemGroupNodeData,
25
28
  type SubsystemGraphEdge,
26
29
  } from './model';
27
30
  import { constructColorsFromPierreTheme } from '../pierre/constructColors';
@@ -70,7 +73,7 @@ export interface SubsystemGraphCallbacks {
70
73
  /** Root callbacks carried through node data (injected by the graph component). */
71
74
  export const SUBSYSTEM_CALLBACKS: SubsystemGraphCallbacks = {};
72
75
 
73
- export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
76
+ export function SubsystemComponentNode(props: NodeProps<Node<SubsystemGraphNodeData, 'subsystem-component'>>) {
74
77
  const { theme, mode } = useTheme();
75
78
  const { data, selected, width: nodeWidth, height: nodeHeight } = props;
76
79
  const c = data.component;
@@ -126,7 +129,7 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
126
129
  boxShadow: fileMatch
127
130
  ? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
128
131
  : '0 1px 4px rgba(0,0,0,0.25)',
129
- opacity: fileMatch === false ? 0.18 : 1,
132
+ opacity: fileMatch === false || data.dimmed === true ? 0.18 : 1,
130
133
  transition: 'opacity 150ms ease',
131
134
  cursor: 'pointer',
132
135
  fontFamily: theme.fonts.body,
@@ -267,6 +270,117 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
267
270
  );
268
271
  }
269
272
 
273
+ /**
274
+ * Process boundary frame — a React Flow parent node. Members render inside
275
+ * via `parentId`; this draws the labeled container only (no handles, no
276
+ * selection). The border color derives deterministically from the process key
277
+ * so each deployment unit reads as its own region.
278
+ */
279
+ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData, 'subsystem-group'>>) {
280
+ const { theme } = useTheme();
281
+ const { data, width, height, selected } = props as unknown as {
282
+ data: SubsystemGroupNodeData;
283
+ width?: number;
284
+ height?: number;
285
+ selected?: boolean;
286
+ };
287
+ const region = data.region;
288
+ const color = packageColor(region?.key ?? 'process');
289
+ const dimmed = data.dimmed === true;
290
+ const hidden = (data as { hidden?: boolean }).hidden === true;
291
+
292
+ if (!region) return null;
293
+
294
+ return (
295
+ <div
296
+ style={{
297
+ position: 'relative',
298
+ width: width ?? 400,
299
+ height: height ?? 300,
300
+ boxSizing: 'border-box',
301
+ borderRadius: 12,
302
+ border: `2px ${selected ? 'solid' : 'dashed'} ${color}`,
303
+ background: `${color}14`,
304
+ opacity: hidden ? 0 : dimmed ? 0.35 : 1,
305
+ transition: 'opacity 150ms ease',
306
+ pointerEvents: 'none',
307
+ }}
308
+ >
309
+ <div
310
+ style={{
311
+ position: 'absolute',
312
+ top: -13,
313
+ left: 12,
314
+ fontFamily: theme.fonts.monospace,
315
+ fontSize: theme.fontSizes[0],
316
+ fontWeight: 700,
317
+ letterSpacing: 0.6,
318
+ color,
319
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
320
+ border: `1px solid ${color}`,
321
+ borderRadius: 4,
322
+ padding: '1px 7px',
323
+ whiteSpace: 'nowrap',
324
+ }}
325
+ >
326
+ {`process: ${region.label}`}
327
+ </div>
328
+ </div>
329
+ );
330
+ }
331
+
332
+ /** `#rrggbb` + alpha → `#rrggbbaa`. Used to dim a stroke/marker by color so
333
+ * each opacity gets its own SVG marker id — path `opacity` leaks across every
334
+ * edge that shares a `url(#marker)` (the focused edge's arrowhead dims). */
335
+ export function hexWithAlpha(hex: string, alpha: number): string {
336
+ const raw = hex.replace('#', '');
337
+ const full = raw.length === 3 ? [...raw].map((c) => c + c).join('') : raw;
338
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) return hex;
339
+ const a = Math.round(Math.min(1, Math.max(0, alpha)) * 255)
340
+ .toString(16)
341
+ .padStart(2, '0');
342
+ return `#${full}${a}`;
343
+ }
344
+
345
+ /**
346
+ * File-open spotlight flag for a node. `true` = lives in the open file,
347
+ * `false` = dim, `undefined` = render neutrally.
348
+ *
349
+ * Focused-edge endpoints stay neutral when they don't live in the open file
350
+ * (the target of a focused edge must not dim with the rest).
351
+ */
352
+ export function fileMatchForNode(
353
+ nodeFile: string | undefined,
354
+ openFile: string | null,
355
+ isFocusEndpoint: boolean,
356
+ ): boolean | undefined {
357
+ if (!openFile) return undefined;
358
+ if (nodeFile === openFile) return true;
359
+ if (isFocusEndpoint) return undefined;
360
+ return false;
361
+ }
362
+
363
+ /**
364
+ * Hide / dim a node or edge while flows are open.
365
+ * - not in any opened flow → hidden
366
+ * - in an opened flow, but not the selected flow/step → dimmed
367
+ * - in the selected flow or step (or opened with nothing selected) → full
368
+ */
369
+ export function flowElementVisibility(opts: {
370
+ inOpened: boolean;
371
+ inSelected: boolean;
372
+ anyOpened: boolean;
373
+ anySelected: boolean;
374
+ }): { hidden: boolean; dimmed: boolean } {
375
+ const { inOpened, inSelected, anyOpened, anySelected } = opts;
376
+ if (!anyOpened && !anySelected) return { hidden: false, dimmed: false };
377
+ if (!inOpened && !inSelected) return { hidden: true, dimmed: false };
378
+ if (anySelected && !inSelected) return { hidden: false, dimmed: true };
379
+ return { hidden: false, dimmed: false };
380
+ }
381
+
382
+ export const EDGE_DIM_ALPHA = 0.15;
383
+
270
384
  /** Subsystem edge — SVG path only. The mechanism label is rendered as an
271
385
  * absolutely-positioned HTML overlay OUTSIDE the ReactFlow tree (by the
272
386
  * parent Inner component) so it sits above the pane and receives pointer
@@ -282,7 +396,10 @@ export function SubsystemEdge({
282
396
  // observational relationships: hierarchy, registration, watches).
283
397
  const isDashed = MECHANISM_STYLE[mechanism] === 'dashed';
284
398
  const dimmed = data?.dimmed === true;
285
- const opacity = dimmed ? 0.15 : 1;
399
+ // Dim the stroke by color, never via path `opacity`. SVG markers are shared
400
+ // by id; opacity on the referencing path paints every arrowhead that uses
401
+ // the same marker — including the focused edge's target.
402
+ const stroke = dimmed ? hexWithAlpha(color, EDGE_DIM_ALPHA) : color;
286
403
 
287
404
  return (
288
405
  <>
@@ -301,10 +418,9 @@ export function SubsystemEdge({
301
418
  <path
302
419
  d={path}
303
420
  fill="none"
304
- stroke={color}
421
+ stroke={stroke}
305
422
  strokeWidth={1.5}
306
423
  strokeDasharray={isDashed ? '6 4' : undefined}
307
- opacity={opacity}
308
424
  markerEnd={markerEnd}
309
425
  style={{ pointerEvents: 'none' }}
310
426
  />