@principal-ai/subsystems-react 0.51.1 → 0.51.3

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 (79) 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 +82 -14
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +29 -14
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +71 -57
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/IssueList.d.ts.map +1 -1
  14. package/dist/subsystem/IssueList.js +11 -2
  15. package/dist/subsystem/IssueList.js.map +1 -1
  16. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  17. package/dist/subsystem/SubsystemComponentGraph.js +66 -3
  18. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  19. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  20. package/dist/subsystem/TrailsPanel.js +8 -4
  21. package/dist/subsystem/TrailsPanel.js.map +1 -1
  22. package/dist/subsystem/c4.d.ts +31 -92
  23. package/dist/subsystem/c4.d.ts.map +1 -1
  24. package/dist/subsystem/c4.js +6 -285
  25. package/dist/subsystem/c4.js.map +1 -1
  26. package/dist/subsystem/c4Evidence.d.ts +1 -1
  27. package/dist/subsystem/c4Evidence.js +2 -2
  28. package/dist/subsystem/c4Evidence.js.map +1 -1
  29. package/dist/subsystem/model.d.ts +8 -0
  30. package/dist/subsystem/model.d.ts.map +1 -1
  31. package/dist/subsystem/model.js.map +1 -1
  32. package/dist/subsystem/techIcons/bun.d.ts +11 -0
  33. package/dist/subsystem/techIcons/bun.d.ts.map +1 -0
  34. package/dist/subsystem/techIcons/bun.js +11 -0
  35. package/dist/subsystem/techIcons/bun.js.map +1 -0
  36. package/dist/subsystem/techIcons/index.d.ts +36 -0
  37. package/dist/subsystem/techIcons/index.d.ts.map +1 -0
  38. package/dist/subsystem/techIcons/index.js +55 -0
  39. package/dist/subsystem/techIcons/index.js.map +1 -0
  40. package/dist/subsystem/techIcons/node.d.ts +12 -0
  41. package/dist/subsystem/techIcons/node.d.ts.map +1 -0
  42. package/dist/subsystem/techIcons/node.js +39 -0
  43. package/dist/subsystem/techIcons/node.js.map +1 -0
  44. package/dist/subsystem/techIcons/react.d.ts +12 -0
  45. package/dist/subsystem/techIcons/react.d.ts.map +1 -0
  46. package/dist/subsystem/techIcons/react.js +19 -0
  47. package/dist/subsystem/techIcons/react.js.map +1 -0
  48. package/dist/utils/elkLayout.d.ts.map +1 -1
  49. package/dist/utils/elkLayout.js +16 -5
  50. package/dist/utils/elkLayout.js.map +1 -1
  51. package/package.json +3 -3
  52. package/src/index.ts +4 -6
  53. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +212 -0
  54. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +21 -8
  55. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
  56. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +168 -2
  57. package/src/subsystem/C4Graph.tsx +127 -12
  58. package/src/subsystem/C4NodeCard.test.ts +86 -55
  59. package/src/subsystem/C4NodeCard.tsx +92 -71
  60. package/src/subsystem/IssueList.tsx +12 -1
  61. package/src/subsystem/SubsystemComponentGraph.tsx +79 -2
  62. package/src/subsystem/TrailsPanel.tsx +10 -5
  63. package/src/subsystem/c4.ts +30 -363
  64. package/src/subsystem/c4Evidence.test.ts +5 -5
  65. package/src/subsystem/c4Evidence.ts +2 -2
  66. package/src/subsystem/model.ts +8 -0
  67. package/src/subsystem/techIcons/bun.ts +10 -0
  68. package/src/subsystem/techIcons/index.tsx +73 -0
  69. package/src/subsystem/techIcons/node.ts +38 -0
  70. package/src/subsystem/techIcons/react.ts +18 -0
  71. package/src/subsystem/techIcons/techIcons.test.ts +39 -0
  72. package/src/utils/elkLayout.ts +16 -5
  73. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +0 -493
  74. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +0 -1
  75. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +0 -329
  76. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +0 -1
  77. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +0 -293
  78. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +0 -366
  79. package/src/subsystem/c4.test.ts +0 -573
@@ -7,22 +7,24 @@
7
7
  *
8
8
  * The encoding has one job: make confirmation state legible at a glance. A box
9
9
  * nobody has looked at must not read the same as a box a person signed off.
10
- * Kind says what the box is — shape, size, and a written tag — and everything
11
- * else in C4 notation (technology, description) is surfaced in the order a
12
- * reader wants it.
10
+ * Kind is carried by shape and size — plus an icon for a container's sort,
11
+ * application vs data-store — and C4 notation is surfaced in the order a reader
12
+ * wants it: technology on the top row, description under the label.
13
13
  */
14
14
 
15
15
  import { useTheme } from '@principal-ade/industry-theme';
16
- import { describeConstructBreakdown } from './model';
16
+ import { AppWindow, Database } from 'lucide-react';
17
+ import type { LucideIcon } from 'lucide-react';
18
+ import { TechMark, technologyBrand } from './techIcons';
17
19
  import type { C4Node } from './c4';
18
20
 
19
21
  /**
20
22
  * Card box for a container: a wide rectangle. A container carries four things
21
- * at once (tag, label, type + technology, and the notation gap it still has),
22
- * and a strip would force the last two to truncate.
23
+ * at once (technology row, label, description, and the notation gap it still
24
+ * has), and a strip would force the last two to wrap hard.
23
25
  */
24
- export const NODE_W = 210;
25
- export const NODE_H = 138;
26
+ export const NODE_W = 250;
27
+ export const NODE_H = 150;
26
28
 
27
29
  /**
28
30
  * A component is a proper square — equal sides, and visibly smaller than the
@@ -30,7 +32,7 @@ export const NODE_H = 138;
30
32
  * these are different C4 levels, and it survives grayscale and colour-blind
31
33
  * reading where a border-weight difference alone does not.
32
34
  */
33
- export const COMPONENT_SIZE = 132;
35
+ export const COMPONENT_SIZE = 140;
34
36
 
35
37
  /**
36
38
  * Per-level box size. The graph lays out against these, so a square component
@@ -43,12 +45,10 @@ export function nodeSize(node: C4Node): { width: number; height: number } {
43
45
  }
44
46
 
45
47
  /**
46
- * Fixed slot heights, so rows line up across every card regardless of which
47
- * optional fields are present. Without these a card missing its notation-gap
48
- * marker is two lines shorter and a row of cards reads as ragged.
48
+ * Fixed label slot, so the description below sits at the same y on every card
49
+ * even when a label is one line rather than two.
49
50
  */
50
51
  const LABEL_SLOT_H = 40;
51
- const GAP_SLOT_H = 18;
52
52
 
53
53
  type Theme = ReturnType<typeof useTheme>['theme'];
54
54
 
@@ -89,32 +89,18 @@ export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeSty
89
89
  // Dashed — an agent asked; a human has not answered.
90
90
  return { color: theme.colors.warning ?? '#e8a33a', dash: 'dashed', width: weight };
91
91
  case 'rejected':
92
- // Should never be drawn (buildC4Model drops it); belt-and-braces.
92
+ // Should never be drawn (a rejected element is not projected); belt-and-braces.
93
93
  return { color: muted, dash: 'dotted', width: 1 };
94
94
  }
95
95
  }
96
96
 
97
- function baseName(path: string): string {
98
- const parts = path.split('/').filter(Boolean);
99
- return parts[parts.length - 1] ?? path;
100
- }
101
-
102
97
  /**
103
- * The kind + technology line under the label.
104
- *
105
- * A container shows its `containerKind` (application / data-store / queue);
106
- * C4 has no kind for the other three, so they show technology or nothing.
98
+ * The line under the label: the element's one-line responsibility, which C4
99
+ * asks every element to carry. No fallback — when nothing is stated, the
100
+ * notation-gap row says so rather than the card inventing a summary.
107
101
  */
108
102
  export function nodeSubtitle(node: C4Node): string {
109
- const parts = [node.containerKind, node.technology].filter(Boolean);
110
- if (parts.length > 0) return parts.join(' · ');
111
-
112
- // No confirmed attributes: fall back to what the document told us.
113
- if (node.kind === 'component') {
114
- return `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`;
115
- }
116
- const count = node.members.length;
117
- return `${describeConstructBreakdown(node.constructs)}${count > 0 ? ` · ${count} component${count === 1 ? '' : 's'}` : ''}`;
103
+ return node.description ?? '';
118
104
  }
119
105
 
120
106
  /**
@@ -127,6 +113,29 @@ export function nodeTag(node: C4Node): string {
127
113
  return node.kind;
128
114
  }
129
115
 
116
+ /**
117
+ * The icon on the top row: a container's sort, **application vs data-store**.
118
+ *
119
+ * The C4 kind itself does not need a glyph — the box shape already carries it
120
+ * (square vs rectangle vs pill), and the row's one text slot is worth more
121
+ * spent on the technology. Only containers have a C4-recognised split, so the
122
+ * other three kinds return nothing.
123
+ */
124
+ export function nodeKindIcon(node: C4Node): LucideIcon | undefined {
125
+ if (node.kind !== 'container') return undefined;
126
+ return node.containerKind === 'data-store' ? Database : AppWindow;
127
+ }
128
+
129
+ /**
130
+ * The text on the top row: the technology the box is built with (Bun, React,
131
+ * Postgres…). Falls back to the container kind / C4 kind when no technology is
132
+ * stated, so the row is never blank.
133
+ */
134
+ export function nodeTopLabel(node: C4Node): string {
135
+ if (node.technology) return node.technology;
136
+ return node.kind === 'container' ? (node.containerKind ?? 'container') : node.kind;
137
+ }
138
+
130
139
  /** How a box is drawn. Shape carries the C4 kind, independent of colour. */
131
140
  export type C4ShapeKind = 'rect' | 'square' | 'pill';
132
141
 
@@ -150,8 +159,8 @@ export interface NodeShape {
150
159
  * An external system is separated from a container by radius alone, which is
151
160
  * a weak cue. That is deliberate rather than an oversight: the border dash is
152
161
  * the only channel that could say "outside our control" loudly, and it is
153
- * already spent on proposed-vs-accepted. The kind tag says it in words, and
154
- * the frame position says it structurally.
162
+ * already spent on proposed-vs-accepted. The kind icon says it, and the frame
163
+ * position says it structurally.
155
164
  */
156
165
  export function nodeShape(node: C4Node): NodeShape {
157
166
  switch (node.kind) {
@@ -196,9 +205,15 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
196
205
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
197
206
  const style = nodeStyle(node, theme, selected);
198
207
  const shape = nodeShape(node);
199
- const missing = nodeMissing(node);
208
+ const Icon = nodeKindIcon(node);
209
+ const brand = technologyBrand(node.technology);
210
+ const topLabel = nodeTopLabel(node);
211
+ const subtitle = nodeSubtitle(node);
200
212
 
201
213
  const size = nodeSize(node);
214
+ // A pill (a person) has no straight left edge, so left-aligned text reads as
215
+ // spilling out of the oval. Centre the text and inset it from the curve.
216
+ const centered = shape.kind === 'pill';
202
217
 
203
218
  return (
204
219
  <div
@@ -214,7 +229,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
214
229
  // height on every card. In a graph grid that makes the row unscannable.
215
230
  justifyContent: 'flex-start',
216
231
  gap: 3,
217
- padding: '10px 12px',
232
+ padding: centered ? '10px 30px' : '10px 12px',
218
233
  borderRadius: shape.radius,
219
234
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
220
235
  border: `${style.width} ${style.dash} ${style.color}`,
@@ -223,23 +238,47 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
223
238
  fontFamily: theme.fonts.body,
224
239
  }}
225
240
  >
226
- {/* The kind is what the box IS. Confirmation state is carried by the
227
- border (see `nodeStyle`), not by a word: the card says what it is,
228
- and the styling says who vouched for it. */}
241
+ {/* Left: the brand mark when we hold the official one, then the
242
+ technology. Right: a container's C4 sort, application vs data-store.
243
+ The kind itself stays on the shape, confirmation on the border (see
244
+ `nodeShape`, `nodeStyle`). */}
229
245
  <span
230
246
  style={{
247
+ display: 'flex',
248
+ alignItems: 'center',
249
+ justifyContent: centered ? 'center' : 'space-between',
250
+ gap: 6,
231
251
  fontFamily: theme.fonts.monospace,
232
252
  fontSize: theme.fontSizes[0],
233
- color: style.color,
234
- fontWeight: 600,
253
+ color: muted,
235
254
  letterSpacing: 0.5,
236
255
  textTransform: 'uppercase',
237
- whiteSpace: 'nowrap',
238
- overflow: 'hidden',
239
- textOverflow: 'ellipsis',
256
+ minWidth: 0,
240
257
  }}
241
258
  >
242
- {nodeTag(node)}
259
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
260
+ {brand && <TechMark brand={brand} />}
261
+ <span
262
+ style={{
263
+ fontWeight: 600,
264
+ whiteSpace: 'nowrap',
265
+ overflow: 'hidden',
266
+ textOverflow: 'ellipsis',
267
+ minWidth: 0,
268
+ }}
269
+ >
270
+ {topLabel}
271
+ </span>
272
+ </span>
273
+ {Icon && (
274
+ <span
275
+ title={node.containerKind ?? node.kind}
276
+ aria-label={node.containerKind ?? node.kind}
277
+ style={{ display: 'inline-flex', flexShrink: 0, color: style.color }}
278
+ >
279
+ <Icon size={13} strokeWidth={2.25} />
280
+ </span>
281
+ )}
243
282
  </span>
244
283
  {/* Two lines rather than an ellipsis: a truncated label hides the very
245
284
  thing that distinguishes two containers from each other. The fixed
@@ -256,43 +295,25 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
256
295
  WebkitLineClamp: 2,
257
296
  WebkitBoxOrient: 'vertical',
258
297
  overflow: 'hidden',
298
+ textAlign: centered ? 'center' : 'left',
259
299
  }}
260
300
  title={node.label}
261
301
  >
262
302
  {node.label}
263
303
  </span>
264
- <span
265
- style={{
266
- fontFamily: theme.fonts.monospace,
267
- fontSize: theme.fontSizes[0],
268
- color: muted,
269
- whiteSpace: 'nowrap',
270
- overflow: 'hidden',
271
- textOverflow: 'ellipsis',
272
- }}
273
- title={node.description ?? node.members.join(', ')}
274
- >
275
- {nodeSubtitle(node)}
276
- </span>
277
- {/* Reserved row: without it, a card with no notation gap is two lines
278
- shorter and the row of cards reads as ragged. */}
279
- <span style={{ height: GAP_SLOT_H }} aria-hidden="true" />
280
- {missing.length > 0 && (
304
+ {subtitle && (
281
305
  <span
282
- title={`C4 requires a ${missing.join(' and a ')} on every container`}
283
306
  style={{
284
307
  fontFamily: theme.fonts.monospace,
285
308
  fontSize: theme.fontSizes[0],
286
- color: theme.colors.warning ?? '#e8a33a',
287
- lineHeight: 1.3,
288
- height: GAP_SLOT_H,
289
- display: '-webkit-box',
290
- WebkitLineClamp: 2,
291
- WebkitBoxOrient: 'vertical',
292
- overflow: 'hidden',
309
+ color: muted,
310
+ lineHeight: 1.35,
311
+ whiteSpace: 'normal',
312
+ overflowWrap: 'anywhere',
313
+ textAlign: centered ? 'center' : 'left',
293
314
  }}
294
315
  >
295
- needs {missing.join(' + ')}
316
+ {subtitle}
296
317
  </span>
297
318
  )}
298
319
  {handles}
@@ -17,10 +17,12 @@ import {
17
17
  ChevronDown,
18
18
  ChevronRight,
19
19
  CircleCheck,
20
+ CircleHelp,
20
21
  Component,
21
22
  FileX,
22
23
  FolderGit2,
23
24
  Footprints,
25
+ History,
24
26
  MapPin,
25
27
  Network,
26
28
  Route,
@@ -104,6 +106,8 @@ const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
104
106
  boundary_module_file_mismatch: 'static-topology',
105
107
  boundary_process_nest_disagree: 'dynamic-topology',
106
108
  trail: 'trail',
109
+ step_unconfirmed: 'trail',
110
+ step_stale: 'trail',
107
111
  };
108
112
 
109
113
  /**
@@ -164,6 +168,9 @@ export const ISSUE_KIND_ICON: Record<string, LucideIcon> = {
164
168
  // One module, more than one claimed process parent — a containment that can't
165
169
  // be drawn, which is why the region sits at the root instead of nested.
166
170
  boundary_process_nest_disagree: Split,
171
+ // Trail step verification: unconfirmed = no augmentation yet, stale = code changed
172
+ step_unconfirmed: CircleHelp,
173
+ step_stale: History,
167
174
  };
168
175
 
169
176
  /**
@@ -244,6 +251,8 @@ const KIND_LABEL: Record<string, string> = {
244
251
  boundary_module_file_mismatch: 'Declaration file outside module',
245
252
  boundary_process_nest_disagree: 'Module spans multiple process contexts',
246
253
  trail: 'Step issue',
254
+ step_unconfirmed: 'Call site not verified',
255
+ step_stale: 'Call site changed',
247
256
  };
248
257
 
249
258
  /** Label for an issue's kind. Explicit `kindLabel` wins; unknown kinds humanize. */
@@ -275,8 +284,10 @@ const KIND_ORDER: Record<string, number> = {
275
284
  boundary_module_file_mismatch: 23,
276
285
  // Runtime topology — process containment
277
286
  boundary_process_nest_disagree: 30,
278
- // Trail
287
+ // Trail — mechanical issues first, then step verification
279
288
  trail: 40,
289
+ step_unconfirmed: 41,
290
+ step_stale: 42,
280
291
  };
281
292
 
282
293
  const KIND_ORDER_FALLBACK = 100;
@@ -1184,6 +1184,33 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1184
1184
  return map;
1185
1185
  }, [issues]);
1186
1186
 
1187
+ // Per-edge step verification badge: maps edge IDs to step verification status.
1188
+ // Keyed by the React Flow edge id (from → mechanism → to). An edge can have
1189
+ // multiple steps with issues; we show the worst status (stale > unconfirmed).
1190
+ const stepIssueBadgeByEdgeId = useMemo(() => {
1191
+ const map = new Map<string, { kind: 'step_unconfirmed' | 'step_stale'; severity: 'info' | 'error'; count: number }>();
1192
+ if (!issues?.length || !trails?.length) {
1193
+ return map;
1194
+ }
1195
+ for (const issue of issues) {
1196
+ if (issue.kind !== 'step_unconfirmed' && issue.kind !== 'step_stale') continue;
1197
+ const stepInfo = issueStep(issue);
1198
+ if (!stepInfo) continue;
1199
+ const step = stepInfo.trail.steps[stepInfo.stepIndex];
1200
+ if (!step) continue;
1201
+ const edgeId = trailStepGraphEdgeId(step);
1202
+ const prev = map.get(edgeId);
1203
+ // step_stale is worse than step_unconfirmed
1204
+ const isWorse = issue.kind === 'step_stale' && prev?.kind !== 'step_stale';
1205
+ map.set(edgeId, {
1206
+ kind: isWorse || !prev ? issue.kind as 'step_unconfirmed' | 'step_stale' : prev.kind,
1207
+ severity: prev?.severity === 'error' || issue.severity === 'error' ? 'error' : 'info',
1208
+ count: (prev?.count ?? 0) + 1,
1209
+ });
1210
+ }
1211
+ return map;
1212
+ }, [issues, trails, issueStep]);
1213
+
1187
1214
  // Expanded verification layers in the diagnostics list. When any layer with
1188
1215
  // findings is expanded, the canvas dims everything that layer does not
1189
1216
  // implicate (see `issueFocus`). The list publishes this through
@@ -2452,7 +2479,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2452
2479
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
2453
2480
  // the actual edge path (not node-center approximations).
2454
2481
  const edgeLabels = useMemo(() => {
2455
- return dispEdges
2482
+ const labels = dispEdges
2456
2483
  .filter((e) => {
2457
2484
  if (e.hidden) return false;
2458
2485
  const d = e?.data as { dimmed?: boolean } | undefined;
@@ -2467,6 +2494,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2467
2494
  labelY?: number;
2468
2495
  pathLength?: number;
2469
2496
  } | undefined;
2497
+ const stepIssue = stepIssueBadgeByEdgeId.get(e.id);
2470
2498
  return {
2471
2499
  id: e.id,
2472
2500
  mechanism: d?.mechanism ?? 'uses',
@@ -2476,9 +2504,11 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2476
2504
  midY: d?.labelY ?? 0,
2477
2505
  pathLength: d?.pathLength ?? 0,
2478
2506
  stepNos: selectedFlowStepNos?.get(e.id),
2507
+ stepIssue,
2479
2508
  };
2480
2509
  });
2481
- }, [dispEdges, selectedFlowStepNos]);
2510
+ return labels;
2511
+ }, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId]);
2482
2512
 
2483
2513
  // Unique source files across components → sidebar file trees.
2484
2514
  const treeFilePaths = treeFiles;
@@ -2958,6 +2988,53 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2958
2988
  >
2959
2989
  {text}
2960
2990
  </span>
2991
+ {lbl.stepIssue && (() => {
2992
+ const StepIssueIcon = ISSUE_KIND_ICON[lbl.stepIssue.kind];
2993
+ // Match IssueList: error severity = red, info severity = amber
2994
+ const issueColor = lbl.stepIssue.severity === 'error'
2995
+ ? (theme.colors.error ?? '#e5534b')
2996
+ : (theme.colors.warning ?? '#d4a017');
2997
+ const badgeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
2998
+ return (
2999
+ <span
3000
+ aria-hidden
3001
+ title={lbl.stepIssue.kind === 'step_stale'
3002
+ ? 'Call site changed since verification'
3003
+ : 'Call site not yet verified'}
3004
+ style={{
3005
+ position: 'absolute',
3006
+ right: -7,
3007
+ bottom: -6,
3008
+ zIndex: 2,
3009
+ display: 'inline-flex',
3010
+ alignItems: 'center',
3011
+ justifyContent: 'center',
3012
+ gap: 3,
3013
+ minWidth: 18,
3014
+ padding: '2px 4px',
3015
+ borderRadius: 4,
3016
+ border: `1.5px solid ${issueColor}`,
3017
+ background: badgeBg,
3018
+ pointerEvents: 'none',
3019
+ }}
3020
+ >
3021
+ {StepIssueIcon && (
3022
+ <StepIssueIcon size={12} color={issueColor} />
3023
+ )}
3024
+ {lbl.stepIssue.count > 1 && (
3025
+ <span style={{
3026
+ color: issueColor,
3027
+ fontFamily: theme.fonts.monospace,
3028
+ fontSize: 10,
3029
+ fontWeight: 700,
3030
+ lineHeight: 1,
3031
+ }}>
3032
+ {lbl.stepIssue.count}
3033
+ </span>
3034
+ )}
3035
+ </span>
3036
+ );
3037
+ })()}
2961
3038
  </div>
2962
3039
  );
2963
3040
  })}
@@ -20,6 +20,7 @@ import {
20
20
  reorderTrails,
21
21
  trailStepGraphEdgeId,
22
22
  type SubsystemTrail,
23
+ type SubsystemTrailStep,
23
24
  } from './model';
24
25
  import { buildStepBrief } from './trailBrief';
25
26
 
@@ -90,10 +91,14 @@ function TrailFlow({
90
91
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
91
92
  const hoverBg = theme.colors.background;
92
93
  const wholeFlowActive = active !== null && active.stepIndex === null;
93
- // A step is "proposed" when either endpoint is a proposed component.
94
- const stepProposed = (from: string, to: string) =>
95
- proposedAliases != null && (proposedAliases.has(from) || proposedAliases.has(to));
96
- const touchesProposed = trail.steps.some((s) => stepProposed(s.from, s.to));
94
+ // A step is "proposed" when it says so itself OR either endpoint component
95
+ // is proposed — the step flag covers planned seams whose endpoints already
96
+ // exist as real components.
97
+ const stepProposed = (step: SubsystemTrailStep) =>
98
+ step.proposed === true ||
99
+ (proposedAliases != null &&
100
+ (proposedAliases.has(step.from) || proposedAliases.has(step.to)));
101
+ const touchesProposed = trail.steps.some((s) => stepProposed(s));
97
102
  const [headerHover, setHeaderHover] = useState(false);
98
103
  const [collapseHover, setCollapseHover] = useState(false);
99
104
  const [playHover, setPlayHover] = useState(false);
@@ -366,7 +371,7 @@ function TrailFlow({
366
371
  >
367
372
  {trail.steps.map((step, i) => {
368
373
  const stepActive = active !== null && active.stepIndex === i;
369
- const proposed = stepProposed(step.from, step.to);
374
+ const proposed = stepProposed(step);
370
375
  const revealed =
371
376
  stepActive || hoveredStep === i || focusedStep === i || copiedStep === i;
372
377
  const copied = copiedStep === i;