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

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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -264,6 +298,8 @@ export interface SubsystemGraphNodeData extends Record<string, unknown> {
264
298
  * lives in that file (spotlighted), false otherwise (dimmed). Absent when
265
299
  * no file is open — render neutrally. */
266
300
  fileMatch?: boolean;
301
+ /** True while this node is on an opened-but-unselected flow. */
302
+ dimmed?: boolean;
267
303
  }
268
304
 
269
305
  export type SubsystemGraphNode = Node<SubsystemGraphNodeData, SubsystemGraphNodeType>;
@@ -447,7 +483,7 @@ export function convertSubsystemToEdges(doc: SubsystemGraphDocument): SubsystemG
447
483
  target: targetId,
448
484
  data: { mechanism: e.mechanism, refs: e.refs },
449
485
  type: 'subsystem-edge',
450
- markerEnd: { type: MarkerType.ArrowClosed, color, width: 16, height: 16 },
486
+ markerEnd: { type: MarkerType.ArrowClosed, color, width: 32, height: 32 },
451
487
  style: { color, stroke: color, strokeDasharray: style === 'dashed' ? '6 4' : undefined },
452
488
  // `label` feeds ELK's label-space reservation only; the visible label is
453
489
  // rendered by the custom SubsystemEdge as an HTML overlay.
@@ -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
+ });
@@ -126,7 +126,7 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
126
126
  boxShadow: fileMatch
127
127
  ? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
128
128
  : '0 1px 4px rgba(0,0,0,0.25)',
129
- opacity: fileMatch === false ? 0.18 : 1,
129
+ opacity: fileMatch === false || data.dimmed === true ? 0.18 : 1,
130
130
  transition: 'opacity 150ms ease',
131
131
  cursor: 'pointer',
132
132
  fontFamily: theme.fonts.body,
@@ -267,6 +267,58 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
267
267
  );
268
268
  }
269
269
 
270
+ /** `#rrggbb` + alpha → `#rrggbbaa`. Used to dim a stroke/marker by color so
271
+ * each opacity gets its own SVG marker id — path `opacity` leaks across every
272
+ * edge that shares a `url(#marker)` (the focused edge's arrowhead dims). */
273
+ export function hexWithAlpha(hex: string, alpha: number): string {
274
+ const raw = hex.replace('#', '');
275
+ const full = raw.length === 3 ? [...raw].map((c) => c + c).join('') : raw;
276
+ if (!/^[0-9a-fA-F]{6}$/.test(full)) return hex;
277
+ const a = Math.round(Math.min(1, Math.max(0, alpha)) * 255)
278
+ .toString(16)
279
+ .padStart(2, '0');
280
+ return `#${full}${a}`;
281
+ }
282
+
283
+ /**
284
+ * File-open spotlight flag for a node. `true` = lives in the open file,
285
+ * `false` = dim, `undefined` = render neutrally.
286
+ *
287
+ * Focused-edge endpoints stay neutral when they don't live in the open file
288
+ * (the target of a focused edge must not dim with the rest).
289
+ */
290
+ export function fileMatchForNode(
291
+ nodeFile: string | undefined,
292
+ openFile: string | null,
293
+ isFocusEndpoint: boolean,
294
+ ): boolean | undefined {
295
+ if (!openFile) return undefined;
296
+ if (nodeFile === openFile) return true;
297
+ if (isFocusEndpoint) return undefined;
298
+ return false;
299
+ }
300
+
301
+ /**
302
+ * Hide / dim a node or edge while flows are open.
303
+ * - not in any opened flow → hidden
304
+ * - in an opened flow, but not the selected flow/step → dimmed
305
+ * - in the selected flow or step (or opened with nothing selected) → full
306
+ */
307
+ export function flowElementVisibility(opts: {
308
+ inOpened: boolean;
309
+ inSelected: boolean;
310
+ anyOpened: boolean;
311
+ anySelected: boolean;
312
+ }): { hidden: boolean; dimmed: boolean } {
313
+ const { inOpened, inSelected, anyOpened, anySelected } = opts;
314
+ if (!anyOpened && !anySelected) return { hidden: false, dimmed: false };
315
+ if (!inOpened && !inSelected) return { hidden: true, dimmed: false };
316
+ if (anySelected && !inSelected) return { hidden: false, dimmed: true };
317
+ return { hidden: false, dimmed: false };
318
+ }
319
+
320
+ export const EDGE_DIM_ALPHA = 0.15;
321
+
270
322
  /** Subsystem edge — SVG path only. The mechanism label is rendered as an
271
323
  * absolutely-positioned HTML overlay OUTSIDE the ReactFlow tree (by the
272
324
  * parent Inner component) so it sits above the pane and receives pointer
@@ -282,7 +334,10 @@ export function SubsystemEdge({
282
334
  // observational relationships: hierarchy, registration, watches).
283
335
  const isDashed = MECHANISM_STYLE[mechanism] === 'dashed';
284
336
  const dimmed = data?.dimmed === true;
285
- const opacity = dimmed ? 0.15 : 1;
337
+ // Dim the stroke by color, never via path `opacity`. SVG markers are shared
338
+ // by id; opacity on the referencing path paints every arrowhead that uses
339
+ // the same marker — including the focused edge's target.
340
+ const stroke = dimmed ? hexWithAlpha(color, EDGE_DIM_ALPHA) : color;
286
341
 
287
342
  return (
288
343
  <>
@@ -301,10 +356,9 @@ export function SubsystemEdge({
301
356
  <path
302
357
  d={path}
303
358
  fill="none"
304
- stroke={color}
359
+ stroke={stroke}
305
360
  strokeWidth={1.5}
306
361
  strokeDasharray={isDashed ? '6 4' : undefined}
307
- opacity={opacity}
308
362
  markerEnd={markerEnd}
309
363
  style={{ pointerEvents: 'none' }}
310
364
  />
@@ -529,9 +529,10 @@ export async function computeElkLayout(
529
529
  // and apply the same coordinate offset.
530
530
  if (edge.labels && edge.labels.length > 0) {
531
531
  const elkLabel = edge.labels[0];
532
- // Raw ELK label position no conversion.
533
- let lx = elkLabel.x ?? 0;
534
- let ly = elkLabel.y ?? 0;
532
+ // ELK reports the label's top-left; convert to center so screen-space
533
+ // overlays can anchor with translate(-50%, -50%) at any zoom.
534
+ let lx = (elkLabel.x ?? 0) + (elkLabel.width ?? 0) / 2;
535
+ let ly = (elkLabel.y ?? 0) + (elkLabel.height ?? 0) / 2;
535
536
  if (preserveNodePositions && sourceOriginal && sourceElk && targetOriginal && targetElk) {
536
537
  const sourceOffset = {
537
538
  x: sourceOriginal.x - sourceElk.x,