@principal-ai/subsystems-react 0.51.2 → 0.51.4

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 (85) hide show
  1. package/dist/index.d.ts +5 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/C4Graph.d.ts +12 -1
  6. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  7. package/dist/subsystem/C4Graph.js +115 -22
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +22 -22
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +56 -77
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  14. package/dist/subsystem/SubsystemComponentGraph.js +32 -13
  15. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  16. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  17. package/dist/subsystem/TrailsPanel.js +8 -4
  18. package/dist/subsystem/TrailsPanel.js.map +1 -1
  19. package/dist/subsystem/c4.d.ts +24 -86
  20. package/dist/subsystem/c4.d.ts.map +1 -1
  21. package/dist/subsystem/c4.js +6 -285
  22. package/dist/subsystem/c4.js.map +1 -1
  23. package/dist/subsystem/c4Evidence.js +1 -1
  24. package/dist/subsystem/c4Evidence.js.map +1 -1
  25. package/dist/subsystem/model.d.ts +14 -0
  26. package/dist/subsystem/model.d.ts.map +1 -1
  27. package/dist/subsystem/model.js.map +1 -1
  28. package/dist/subsystem/nodes.d.ts.map +1 -1
  29. package/dist/subsystem/nodes.js +4 -2
  30. package/dist/subsystem/nodes.js.map +1 -1
  31. package/dist/subsystem/techIcons/bun.d.ts +11 -0
  32. package/dist/subsystem/techIcons/bun.d.ts.map +1 -0
  33. package/dist/subsystem/techIcons/bun.js +11 -0
  34. package/dist/subsystem/techIcons/bun.js.map +1 -0
  35. package/dist/subsystem/techIcons/index.d.ts +36 -0
  36. package/dist/subsystem/techIcons/index.d.ts.map +1 -0
  37. package/dist/subsystem/techIcons/index.js +55 -0
  38. package/dist/subsystem/techIcons/index.js.map +1 -0
  39. package/dist/subsystem/techIcons/node.d.ts +12 -0
  40. package/dist/subsystem/techIcons/node.d.ts.map +1 -0
  41. package/dist/subsystem/techIcons/node.js +39 -0
  42. package/dist/subsystem/techIcons/node.js.map +1 -0
  43. package/dist/subsystem/techIcons/react.d.ts +12 -0
  44. package/dist/subsystem/techIcons/react.d.ts.map +1 -0
  45. package/dist/subsystem/techIcons/react.js +19 -0
  46. package/dist/subsystem/techIcons/react.js.map +1 -0
  47. package/dist/utils/edgeLabel.d.ts +1 -1
  48. package/dist/utils/edgeLabel.d.ts.map +1 -1
  49. package/dist/utils/edgeLabel.js +1 -1
  50. package/dist/utils/edgeLabel.js.map +1 -1
  51. package/dist/utils/elkLayout.d.ts +18 -0
  52. package/dist/utils/elkLayout.d.ts.map +1 -1
  53. package/dist/utils/elkLayout.js +90 -9
  54. package/dist/utils/elkLayout.js.map +1 -1
  55. package/package.json +3 -3
  56. package/src/index.ts +4 -6
  57. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +212 -0
  58. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +20 -10
  59. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
  60. package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
  61. package/src/subsystem/C4Graph.tsx +171 -19
  62. package/src/subsystem/C4NodeCard.test.ts +71 -77
  63. package/src/subsystem/C4NodeCard.tsx +65 -95
  64. package/src/subsystem/SubsystemComponentGraph.tsx +41 -13
  65. package/src/subsystem/TrailsPanel.tsx +10 -5
  66. package/src/subsystem/c4.ts +23 -357
  67. package/src/subsystem/c4Evidence.test.ts +1 -1
  68. package/src/subsystem/c4Evidence.ts +1 -1
  69. package/src/subsystem/model.ts +14 -0
  70. package/src/subsystem/nodes.tsx +5 -2
  71. package/src/subsystem/techIcons/bun.ts +10 -0
  72. package/src/subsystem/techIcons/index.tsx +73 -0
  73. package/src/subsystem/techIcons/node.ts +38 -0
  74. package/src/subsystem/techIcons/react.ts +18 -0
  75. package/src/subsystem/techIcons/techIcons.test.ts +39 -0
  76. package/src/utils/edgeLabel.ts +1 -1
  77. package/src/utils/elkLayout.test.ts +34 -0
  78. package/src/utils/elkLayout.ts +88 -9
  79. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +0 -493
  80. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +0 -1
  81. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +0 -329
  82. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +0 -1
  83. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +0 -293
  84. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +0 -366
  85. package/src/subsystem/c4.test.ts +0 -573
@@ -0,0 +1,18 @@
1
+ /**
2
+ * React mark — the official logo, vendored verbatim.
3
+ *
4
+ * Source: https://react.dev (the React logo; mirrored at
5
+ * https://commons.wikimedia.org/wiki/File:React-icon.svg).
6
+ * React and the React logo are trademarks of Meta Platforms, Inc. Used here
7
+ * for identification only, with no endorsement implied.
8
+ *
9
+ * Root sized to 100% so the card's wrapper controls the box.
10
+ */
11
+ export const reactSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="-11.5 -10.23174 23 20.46348">
12
+ <circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
13
+ <g stroke="#61dafb" stroke-width="1" fill="none">
14
+ <ellipse rx="11" ry="4.2"/>
15
+ <ellipse rx="11" ry="4.2" transform="rotate(60)"/>
16
+ <ellipse rx="11" ry="4.2" transform="rotate(120)"/>
17
+ </g>
18
+ </svg>`;
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { TECH_BRANDS, technologyBrand } from './index';
3
+
4
+ describe('technologyBrand', () => {
5
+ test('matches the brand named in the technology string', () => {
6
+ expect(technologyBrand('React 19')?.name).toBe('React');
7
+ expect(technologyBrand('React + ELK')?.name).toBe('React');
8
+ expect(technologyBrand('Bun + Electrobun host')?.name).toBe('Bun');
9
+ expect(technologyBrand('Bun fs')?.name).toBe('Bun');
10
+ });
11
+
12
+ test('does not match a brand name inside another word', () => {
13
+ // "Electrobun" contains "bun" but is not Bun — no word boundary.
14
+ expect(technologyBrand('Electrobun host')).toBeUndefined();
15
+ });
16
+
17
+ test('returns nothing for technologies we hold no official mark for', () => {
18
+ for (const tech of ['HTTP', 'REST', 'JSON files under ~/.principal', 'Electrobun host']) {
19
+ expect(technologyBrand(tech)).toBeUndefined();
20
+ }
21
+ expect(technologyBrand('')).toBeUndefined();
22
+ expect(technologyBrand(undefined)).toBeUndefined();
23
+ });
24
+
25
+ test('matches Node and Node.js', () => {
26
+ expect(technologyBrand('Node')?.name).toBe('Node.js');
27
+ expect(technologyBrand('Node.js 20')?.name).toBe('Node.js');
28
+ });
29
+
30
+ test('every vendored brand carries a source and an SVG', () => {
31
+ for (const brand of TECH_BRANDS) {
32
+ expect(brand.source).toMatch(/^https?:\/\//);
33
+ expect(brand.svg.startsWith('<svg')).toBe(true);
34
+ // Root sized to 100%, so the card wrapper controls the box.
35
+ expect(brand.svg).toContain('width="100%"');
36
+ expect(brand.svg).toContain('height="100%"');
37
+ }
38
+ });
39
+ });
@@ -33,7 +33,7 @@ export const EDGE_LABEL_FONT_SIZE = 14;
33
33
  * "min edge length" here double-counts (it is applied twice), which is how the
34
34
  * gap silently ballooned before.
35
35
  */
36
- export const EDGE_LABEL_SIDE_PADDING = 32;
36
+ export const EDGE_LABEL_SIDE_PADDING = 8;
37
37
 
38
38
  /** Actual end-to-end gap ELK leaves for a labelled edge (box + both clearances). */
39
39
  export const EDGE_LABEL_EDGE_GAP =
@@ -12,6 +12,8 @@ import {
12
12
  pointsToPath,
13
13
  pointsToSmoothPath,
14
14
  calculatePathMidpoint,
15
+ pointAlongPath,
16
+ fractionAlongPath,
15
17
  planCompoundGroups,
16
18
  type Point,
17
19
  } from './elkLayout';
@@ -129,6 +131,38 @@ describe('planCompoundGroups', () => {
129
131
  });
130
132
 
131
133
  describe('elkLayout helper functions', () => {
134
+ describe('pointAlongPath / fractionAlongPath', () => {
135
+ const bent: Point[] = [
136
+ { x: 0, y: 0 },
137
+ { x: 100, y: 0 },
138
+ { x: 100, y: 100 },
139
+ ];
140
+
141
+ test('half is the arc-length midpoint, not a corner', () => {
142
+ // Total length 200; half is 100 along, which is the corner (100,0).
143
+ const mid = pointAlongPath(bent, 0.5);
144
+ expect(mid).toEqual({ x: 100, y: 0 });
145
+ });
146
+
147
+ test('a fraction lands on the correct segment', () => {
148
+ // 0.75 of 200 = 150 → 50 down the second segment.
149
+ expect(pointAlongPath(bent, 0.75)).toEqual({ x: 100, y: 50 });
150
+ // 0.25 of 200 = 50 → half along the first segment.
151
+ expect(pointAlongPath(bent, 0.25)).toEqual({ x: 50, y: 0 });
152
+ });
153
+
154
+ test('clamps and handles degenerate input', () => {
155
+ expect(pointAlongPath(bent, -1)).toEqual({ x: 0, y: 0 });
156
+ expect(pointAlongPath(bent, 2)).toEqual({ x: 100, y: 100 });
157
+ expect(pointAlongPath([{ x: 5, y: 5 }], 0.5)).toEqual({ x: 5, y: 5 });
158
+ });
159
+
160
+ test('fractionAlongPath inverts pointAlongPath', () => {
161
+ const at = pointAlongPath(bent, 0.75);
162
+ expect(fractionAlongPath(bent, at)).toBeCloseTo(0.75, 5);
163
+ });
164
+ });
165
+
132
166
  describe('pointsToPath', () => {
133
167
  test('should return empty string for empty array', () => {
134
168
  expect(pointsToPath([])).toBe('');
@@ -10,7 +10,7 @@ import type { Node, Edge } from '@xyflow/react';
10
10
  import {
11
11
  EDGE_LABEL_WIDTH,
12
12
  EDGE_LABEL_HEIGHT,
13
- EDGE_LABEL_SIDE_PADDING,
13
+ EDGE_LABEL_EDGE_GAP,
14
14
  } from './edgeLabel';
15
15
 
16
16
  /** ELK layout options for different routing styles */
@@ -330,6 +330,70 @@ export function calculatePathLength(points: Point[]): number {
330
330
  return total;
331
331
  }
332
332
 
333
+ /**
334
+ * Point a given fraction along a polyline, by arc length.
335
+ *
336
+ * Using arc length, not the nearest-point snap, is what keeps a label chip on
337
+ * the visual middle of a *bent* route. Nearest-point (`closestPointOnPath`)
338
+ * jumps to whichever segment happens to be nearest the target, so a fanned-out
339
+ * orthogonal route put the chip on a corner near the source node.
340
+ *
341
+ * @public Exported for testing
342
+ */
343
+ export function pointAlongPath(points: Point[], fraction: number): Point {
344
+ if (points.length === 0) return { x: 0, y: 0 };
345
+ if (points.length === 1) return points[0];
346
+ const total = calculatePathLength(points);
347
+ if (total === 0) return points[0];
348
+
349
+ let remaining = Math.max(0, Math.min(1, fraction)) * total;
350
+ for (let i = 1; i < points.length; i++) {
351
+ const a = points[i - 1];
352
+ const b = points[i];
353
+ const seg = Math.hypot(b.x - a.x, b.y - a.y);
354
+ if (seg === 0) continue;
355
+ if (remaining <= seg) {
356
+ const t = remaining / seg;
357
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
358
+ }
359
+ remaining -= seg;
360
+ }
361
+ return points[points.length - 1];
362
+ }
363
+
364
+ /**
365
+ * How far along a polyline a point lies, as a 0…1 fraction of arc length.
366
+ * The point is assumed to lie on (or near) the path.
367
+ *
368
+ * @public Exported for testing
369
+ */
370
+ export function fractionAlongPath(points: Point[], at: Point): number {
371
+ if (points.length < 2) return 0.5;
372
+ const total = calculatePathLength(points);
373
+ if (total === 0) return 0.5;
374
+
375
+ let travelled = 0;
376
+ let bestDist = Infinity;
377
+ let bestTravelled = 0;
378
+ for (let i = 1; i < points.length; i++) {
379
+ const a = points[i - 1];
380
+ const b = points[i];
381
+ const seg = Math.hypot(b.x - a.x, b.y - a.y);
382
+ const dx = b.x - a.x;
383
+ const dy = b.y - a.y;
384
+ const t = seg === 0 ? 0 : Math.max(0, Math.min(1, ((at.x - a.x) * dx + (at.y - a.y) * dy) / (seg * seg)));
385
+ const px = a.x + dx * t;
386
+ const py = a.y + dy * t;
387
+ const dist = Math.hypot(at.x - px, at.y - py);
388
+ if (dist < bestDist) {
389
+ bestDist = dist;
390
+ bestTravelled = travelled + seg * t;
391
+ }
392
+ travelled += seg;
393
+ }
394
+ return bestTravelled / total;
395
+ }
396
+
333
397
  /**
334
398
  * Get ELK layout options based on configuration
335
399
  */
@@ -707,13 +771,24 @@ export async function computeElkLayout(
707
771
  // edge by ~13px) plus a gap before the first child.
708
772
  'elk.padding': '[top=64,left=24,bottom=24,right=24]',
709
773
  'elk.spacing.nodeNode': '40',
710
- // Edges inside a frame host labels too: ELK applies this on both sides of
711
- // the reserved label layer, so it is the per-side clearance (same as root).
712
- // Labels off falls back to a plain between-layer gap.
774
+ // Edges inside a frame host labels too, so the frame's own layout has to
775
+ // reserve the same room the root does — otherwise a nested graph (C4 puts
776
+ // every box in the system frame) lays its edges out with the label space
777
+ // ignored, and a 140px chip overflows a ~72px gap. `EDGE_LABEL_EDGE_GAP` is
778
+ // the full reserved width (label box + clearance at each end); labels off
779
+ // falls back to a plain between-layer gap.
713
780
  'elk.layered.spacing.nodeNodeBetweenLayers': String(
714
- edgeLabels?.enabled === false ? 40 : EDGE_LABEL_SIDE_PADDING,
781
+ edgeLabels?.enabled === false ? 40 : EDGE_LABEL_EDGE_GAP,
715
782
  ),
716
783
  };
784
+ // Match the root's inline-label reservation, so ELK inside the frame leaves a
785
+ // dedicated label layer rather than routing straight through the chip.
786
+ if (edgeLabels?.enabled !== false) {
787
+ const placement = edgeLabels?.placement ?? 'CENTER';
788
+ compoundLayoutOptions['elk.edgeLabels.inline'] = 'true';
789
+ compoundLayoutOptions['elk.edgeLabels.inlinePlacement'] = placement;
790
+ compoundLayoutOptions['elk.layered.edgeLabels.centerLabelPlacementStrategy'] = 'CENTER_LAYER';
791
+ }
717
792
 
718
793
  const plan = planCompoundGroups(groupDefs, elkById.keys(), keepSingletonGroups);
719
794
  const skippedGroups = new Set(plan.skipped);
@@ -947,10 +1022,14 @@ export async function computeElkLayout(
947
1022
  lx += sourceOffset.x + (targetOffset.x - sourceOffset.x) * t;
948
1023
  ly += sourceOffset.y + (targetOffset.y - sourceOffset.y) * t;
949
1024
  }
950
- // Snap onto the polyline stroke. ELK's label box can sit slightly
951
- // off the route (side selection / reserved label space); we keep
952
- // its along-edge placement but center on the actual path.
953
- const onPath = closestPointOnPath(allPoints, { x: lx, y: ly });
1025
+ // Place the chip by arc length, using ELK's label position only as a
1026
+ // hint for *how far along* the route it belongs. Snapping to the
1027
+ // nearest point (the old behaviour) jumped to whichever segment was
1028
+ // nearest the target, which put fanned-out orthogonal labels on a
1029
+ // corner near the source instead of the middle of the line.
1030
+ const hint = closestPointOnPath(allPoints, { x: lx, y: ly });
1031
+ const hintFraction = fractionAlongPath(allPoints, hint);
1032
+ const onPath = pointAlongPath(allPoints, hintFraction);
954
1033
  edgeLabelPositions.set(edge.id, onPath);
955
1034
  }
956
1035