@principal-ai/subsystems-react 0.51.0 → 0.51.2

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 (88) hide show
  1. package/dist/index.d.ts +6 -6
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -4
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts +1 -1
  6. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.js +44 -21
  8. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  9. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +493 -0
  10. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +1 -0
  11. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +329 -0
  12. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +1 -0
  13. package/dist/subsystem/C4Graph.d.ts +2 -2
  14. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  15. package/dist/subsystem/C4Graph.js +25 -16
  16. package/dist/subsystem/C4Graph.js.map +1 -1
  17. package/dist/subsystem/C4NodeCard.d.ts +85 -35
  18. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  19. package/dist/subsystem/C4NodeCard.js +112 -83
  20. package/dist/subsystem/C4NodeCard.js.map +1 -1
  21. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  22. package/dist/subsystem/FileDrawer.js +20 -21
  23. package/dist/subsystem/FileDrawer.js.map +1 -1
  24. package/dist/subsystem/IssueList.d.ts.map +1 -1
  25. package/dist/subsystem/IssueList.js +12 -3
  26. package/dist/subsystem/IssueList.js.map +1 -1
  27. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  28. package/dist/subsystem/SubsystemComponentGraph.js +85 -7
  29. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  30. package/dist/subsystem/c4.d.ts +273 -0
  31. package/dist/subsystem/c4.d.ts.map +1 -0
  32. package/dist/subsystem/c4.js +322 -0
  33. package/dist/subsystem/c4.js.map +1 -0
  34. package/dist/subsystem/c4Evidence.d.ts +143 -0
  35. package/dist/subsystem/c4Evidence.d.ts.map +1 -0
  36. package/dist/subsystem/c4Evidence.js +240 -0
  37. package/dist/subsystem/c4Evidence.js.map +1 -0
  38. package/dist/subsystem/model.d.ts +2 -6
  39. package/dist/subsystem/model.d.ts.map +1 -1
  40. package/dist/subsystem/model.js.map +1 -1
  41. package/package.json +5 -4
  42. package/src/index.ts +26 -28
  43. package/src/pierre/PierreTrailCodeView.tsx +49 -21
  44. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +171 -80
  45. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
  46. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
  47. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
  48. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +169 -3
  49. package/src/subsystem/C4Graph.tsx +39 -24
  50. package/src/subsystem/C4NodeCard.test.ts +204 -62
  51. package/src/subsystem/C4NodeCard.tsx +146 -101
  52. package/src/subsystem/FileDrawer.tsx +32 -34
  53. package/src/subsystem/IssueList.tsx +13 -2
  54. package/src/subsystem/SubsystemComponentGraph.tsx +98 -7
  55. package/src/subsystem/c4.test.ts +573 -0
  56. package/src/subsystem/c4.ts +559 -0
  57. package/src/subsystem/c4Evidence.test.ts +386 -0
  58. package/src/subsystem/c4Evidence.ts +329 -0
  59. package/src/subsystem/model.ts +2 -6
  60. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +0 -95
  61. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +0 -1
  62. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +0 -340
  63. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +0 -1
  64. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -7
  65. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
  66. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -7335
  67. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
  68. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +0 -2
  69. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +0 -1
  70. package/dist/stories/Subsystem/C4Graph/generateFixture.js +0 -145
  71. package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +0 -1
  72. package/dist/subsystem/c4Associations.d.ts +0 -186
  73. package/dist/subsystem/c4Associations.d.ts.map +0 -1
  74. package/dist/subsystem/c4Associations.js +0 -284
  75. package/dist/subsystem/c4Associations.js.map +0 -1
  76. package/dist/subsystem/toC4.d.ts +0 -214
  77. package/dist/subsystem/toC4.d.ts.map +0 -1
  78. package/dist/subsystem/toC4.js +0 -282
  79. package/dist/subsystem/toC4.js.map +0 -1
  80. package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +0 -273
  81. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
  82. package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +0 -344
  83. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -7339
  84. package/src/stories/Subsystem/C4Graph/generateFixture.ts +0 -161
  85. package/src/subsystem/c4Associations.test.ts +0 -484
  86. package/src/subsystem/c4Associations.ts +0 -398
  87. package/src/subsystem/toC4.test.ts +0 -114
  88. package/src/subsystem/toC4.ts +0 -478
@@ -7,23 +7,43 @@
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
- * Everything else here is C4 notation — a type, a technology, a description —
11
- * surfaced in the order a reader wants it.
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.
12
13
  */
13
14
 
14
15
  import { useTheme } from '@principal-ade/industry-theme';
16
+ import { Cloud, Component, Container, UserRound } from 'lucide-react';
17
+ import type { LucideIcon } from 'lucide-react';
15
18
  import { describeConstructBreakdown } from './model';
16
- import type { C4Node } from './toC4';
19
+ import type { C4Node } from './c4';
17
20
 
18
21
  /**
19
- * Card box. Near-square rather than a wide strip: a C4 element carries four
20
- * things at once (tag, label, type + technology, and the notation gap it still
21
- * has), and a wide strip forces the last two to truncate. The extra height is
22
- * what lets the `needs …` marker sit on its own line instead of clipping.
22
+ * Card box for a container: a wide rectangle. A container carries four things
23
+ * at once (tag, label, type + technology, and the notation gap it still has),
24
+ * and a strip would force the last two to truncate.
23
25
  */
24
26
  export const NODE_W = 210;
25
27
  export const NODE_H = 138;
26
28
 
29
+ /**
30
+ * A component is a proper square — equal sides, and visibly smaller than the
31
+ * container it sits inside. Square-vs-rectangle is the clearest signal that
32
+ * these are different C4 levels, and it survives grayscale and colour-blind
33
+ * reading where a border-weight difference alone does not.
34
+ */
35
+ export const COMPONENT_SIZE = 132;
36
+
37
+ /**
38
+ * Per-level box size. The graph lays out against these, so a square component
39
+ * and a rectangular container do not collide at a shared nominal width.
40
+ */
41
+ export function nodeSize(node: C4Node): { width: number; height: number } {
42
+ return node.kind === 'component'
43
+ ? { width: COMPONENT_SIZE, height: COMPONENT_SIZE }
44
+ : { width: NODE_W, height: NODE_H };
45
+ }
46
+
27
47
  /**
28
48
  * Fixed slot heights, so rows line up across every card regardless of which
29
49
  * optional fields are present. Without these a card missing its notation-gap
@@ -36,26 +56,34 @@ type Theme = ReturnType<typeof useTheme>['theme'];
36
56
 
37
57
  export interface NodeStyle {
38
58
  color: string;
39
- dash: string;
59
+ dash: 'solid' | 'dashed' | 'dotted';
40
60
  width: number;
41
61
  }
42
62
 
43
63
  /**
44
- * Border style and colour for one node, by what we know about it.
64
+ * Border style for one node: colour, dash and weight, from confirmation state
65
+ * and level.
66
+ *
67
+ * One axis per channel, so nothing is said twice and nothing is contradicted:
68
+ *
69
+ * colour + dash → confirmation state (the primary axis)
70
+ * weight → C4 level, as a grayscale redundancy for the square
71
+ * shape + size → C4 kind (`nodeShape`, `nodeSize`)
45
72
  *
46
- * Confirmation state wins over C4 kind: an accepted container and a raw
47
- * derived container are both "containers", and the reader's first question
48
- * about either is whether anyone vouched for it.
73
+ * State owning the dash is deliberate. An earlier build gave the dash to kind
74
+ * and drew proposed boxes solid, quietly contradicting the one thing the
75
+ * confirm UI needs a reader to see. Kind now says itself through shape, and
76
+ * the border says who vouched for it.
49
77
  */
50
78
  export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeStyle {
51
79
  const muted = theme.colors.border ?? '#555';
52
80
  if (selected) return { color: theme.colors.primary ?? '#5aa', dash: 'solid', width: 3 };
53
81
 
54
- // Border width carries the C4 level, so a component stays a component even
82
+ // Border weight carries the level, so a component stays a component even
55
83
  // in grayscale or for a reader who cannot separate the hues.
56
84
  const weight = node.kind === 'component' ? 1 : 2;
57
85
 
58
- switch (node.decoration?.state) {
86
+ switch (node.state) {
59
87
  case 'accepted':
60
88
  // Solid and saturated — part of the architecture now.
61
89
  return { color: theme.colors.primary ?? '#4ec9b0', dash: 'solid', width: weight };
@@ -63,21 +91,8 @@ export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeSty
63
91
  // Dashed — an agent asked; a human has not answered.
64
92
  return { color: theme.colors.warning ?? '#e8a33a', dash: 'dashed', width: weight };
65
93
  case 'rejected':
66
- // Should never be drawn (toC4 drops it); belt-and-braces.
94
+ // Should never be drawn (buildC4Model drops it); belt-and-braces.
67
95
  return { color: muted, dash: 'dotted', width: 1 };
68
- default:
69
- break;
70
- }
71
-
72
- // No association at all — the raw derivation. Muted, so confirmed boxes
73
- // read as the stronger signal.
74
- switch (node.kind) {
75
- case 'external':
76
- return { color: theme.colors.warning ?? '#a78bfa', dash: 'solid', width: weight };
77
- case 'actor':
78
- return { color: theme.colors.accent ?? theme.colors.info ?? '#e3b341', dash: 'solid', width: weight };
79
- default:
80
- return { color: muted, dash: 'solid', width: weight };
81
96
  }
82
97
  }
83
98
 
@@ -86,12 +101,17 @@ function baseName(path: string): string {
86
101
  return parts[parts.length - 1] ?? path;
87
102
  }
88
103
 
89
- /** The type + technology line under the label. */
104
+ /**
105
+ * The kind + technology line under the label.
106
+ *
107
+ * The container kind moved up to the icon row (see `nodeTypeLabel`), so it is
108
+ * not repeated here. Technology is the remaining confirmed attribute; without
109
+ * one we fall back to what the document told us.
110
+ */
90
111
  export function nodeSubtitle(node: C4Node): string {
91
- const d = node.decoration;
92
- if (d?.type || d?.technology) return [d.type, d.technology].filter(Boolean).join(' · ');
112
+ if (node.technology) return node.technology;
93
113
 
94
- // No confirmed attributes: fall back to what the document told us.
114
+ // No stated technology: fall back to what the document told us.
95
115
  if (node.kind === 'component') {
96
116
  return `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`;
97
117
  }
@@ -100,68 +120,96 @@ export function nodeSubtitle(node: C4Node): string {
100
120
  }
101
121
 
102
122
  /**
103
- * The C4 level — what the box IS.
104
- *
105
- * Deliberately independent of confirmation state. An accepted container is
106
- * still a container, and a component is a component whether or not anyone has
107
- * reviewed it; collapsing the two into one label is what made the two
108
- * indistinguishable. Confirmation is a separate axis (`nodeStateTag`).
123
+ * What the box IS — its C4 kind. An accepted container is still a container;
124
+ * collapsing the two is what once made containers and components unreadable.
125
+ * Confirmation state is a separate axis carried by the border (see
126
+ * `nodeStyle`), not by a word on the card.
109
127
  */
110
128
  export function nodeTag(node: C4Node): string {
111
129
  return node.kind;
112
130
  }
113
131
 
114
132
  /**
115
- * Confirmation state, as its own short tag. Empty when nothing was proposed —
116
- * which is itself the distinction from a reviewed element.
133
+ * The glyph for the C4 kind, shown in place of a word. The kind is already
134
+ * carried by the box shape (see `nodeShape`); the icon lets the top row spend
135
+ * its one piece of text on the container's kind instead of repeating
136
+ * "container".
117
137
  */
118
- export function nodeStateTag(node: C4Node): string {
119
- switch (node.decoration?.state) {
120
- case 'accepted':
121
- return 'confirmed';
122
- case 'proposed':
123
- return 'proposed';
124
- case 'rejected':
125
- return 'rejected';
126
- default:
127
- return 'unconfirmed';
138
+ export function nodeKindIcon(node: C4Node): LucideIcon {
139
+ switch (node.kind) {
140
+ case 'container':
141
+ return Container;
142
+ case 'component':
143
+ return Component;
144
+ case 'external-system':
145
+ return Cloud;
146
+ case 'person':
147
+ return UserRound;
128
148
  }
129
149
  }
130
150
 
131
151
  /**
132
- * Shape cue for the C4 level, independent of colour and dash pattern.
152
+ * The word beside the kind icon: what sort of container this is — C4's two,
153
+ * `application` or `data-store`. The other three kinds have no such subtype,
154
+ * so they show nothing; the icon and shape are the whole label.
155
+ */
156
+ export function nodeTypeLabel(node: C4Node): string | undefined {
157
+ return node.kind === 'container' ? node.containerKind : undefined;
158
+ }
159
+
160
+ /** How a box is drawn. Shape carries the C4 kind, independent of colour. */
161
+ export type C4ShapeKind = 'rect' | 'square' | 'pill';
162
+
163
+ export interface NodeShape {
164
+ kind: C4ShapeKind;
165
+ /** Corner radius, for the shapes that use one. */
166
+ radius: number;
167
+ }
168
+
169
+ /**
170
+ * Shape cue for the C4 kind, independent of colour and dash — both of which
171
+ * belong to confirmation state.
133
172
  *
134
- * Colour alone is not enough: a container and a component with no association
135
- * both fall back to the muted border, so they were identical in grayscale.
136
173
  * The convention:
137
174
  *
138
- * container solid rectangle — a runtime boundary, drawn to scale
139
- * component rounded rectangle — a part inside one, drawn smaller
140
- * external rounded rectangle + dashed (nothing to confirm inside)
141
- * actor fully rounded — a person, not a box
175
+ * container wide rectangle, sharp corners — a runtime boundary
176
+ * component square — a part inside one (see `nodeSize`)
177
+ * external system wide rectangle, rounded — someone else's system
178
+ * person pill — not a box at all
179
+ *
180
+ * An external system is separated from a container by radius alone, which is
181
+ * a weak cue. That is deliberate rather than an oversight: the border dash is
182
+ * the only channel that could say "outside our control" loudly, and it is
183
+ * already spent on proposed-vs-accepted. The kind icon says it, and the frame
184
+ * position says it structurally.
142
185
  */
143
- export function nodeShape(node: C4Node): { radius: number; dash: 'solid' | 'dashed' } {
186
+ export function nodeShape(node: C4Node): NodeShape {
144
187
  switch (node.kind) {
145
- case 'actor':
146
- return { radius: NODE_H / 2, dash: 'solid' };
147
- case 'external':
148
- return { radius: 14, dash: 'dashed' };
188
+ case 'person':
189
+ return { kind: 'pill', radius: NODE_H / 2 };
190
+ case 'external-system':
191
+ return { kind: 'rect', radius: 14 };
149
192
  case 'component':
150
- return { radius: 14, dash: 'solid' };
193
+ return { kind: 'square', radius: 6 };
151
194
  default:
152
- return { radius: 4, dash: 'solid' };
195
+ return { kind: 'rect', radius: 4 };
153
196
  }
154
197
  }
155
198
 
156
199
  /**
157
- * C4 notation requires a technology and a description on every container.
158
- * Name the gaps rather than leaving them to be noticed.
200
+ * The gaps the C4 notation still wants on this element, named rather than left
201
+ * to be noticed.
202
+ *
203
+ * Containers are where the notation is strictest: it requires a technology and
204
+ * a description on every one. A component needs a technology too, but a
205
+ * component drawn without a parent already reports `unknown_container`, and
206
+ * that is the question worth asking first.
159
207
  */
160
208
  export function nodeMissing(node: C4Node): string[] {
161
209
  if (node.kind !== 'container') return [];
162
210
  const missing: string[] = [];
163
- if (!node.decoration?.technology) missing.push('technology');
164
- if (!node.decoration?.description) missing.push('description');
211
+ if (!node.technology) missing.push('technology');
212
+ if (!node.description) missing.push('description');
165
213
  return missing;
166
214
  }
167
215
 
@@ -179,14 +227,17 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
179
227
  const style = nodeStyle(node, theme, selected);
180
228
  const shape = nodeShape(node);
181
229
  const missing = nodeMissing(node);
182
- const unconfirmed = !node.decoration?.state;
230
+ const Icon = nodeKindIcon(node);
231
+ const typeLabel = nodeTypeLabel(node);
232
+
233
+ const size = nodeSize(node);
183
234
 
184
235
  return (
185
236
  <div
186
237
  onClick={onClick}
187
238
  style={{
188
- width: NODE_W,
189
- minHeight: NODE_H,
239
+ width: size.width,
240
+ minHeight: size.height,
190
241
  boxSizing: 'border-box',
191
242
  display: 'flex',
192
243
  flexDirection: 'column',
@@ -204,48 +255,42 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
204
255
  fontFamily: theme.fonts.body,
205
256
  }}
206
257
  >
207
- {/* Level and state on one row, separately styled. The level is what the
208
- box IS; the state is whether anyone has vouched for it. Merging them
209
- into one label is what made containers and components unreadable. */}
258
+ {/* The kind is carried by the icon and the box shape; the one word here
259
+ is the container's kind. Confirmation state is carried by the border
260
+ (see `nodeStyle`), not by a word. */}
210
261
  <span
211
262
  style={{
212
263
  display: 'flex',
213
- alignItems: 'baseline',
264
+ alignItems: 'center',
214
265
  gap: 6,
215
266
  fontFamily: theme.fonts.monospace,
216
267
  fontSize: theme.fontSizes[0],
268
+ color: style.color,
217
269
  letterSpacing: 0.5,
218
270
  textTransform: 'uppercase',
271
+ minWidth: 0,
219
272
  }}
220
273
  >
221
274
  <span
222
- style={{
223
- color: style.color,
224
- fontWeight: 600,
225
- whiteSpace: 'nowrap',
226
- overflow: 'hidden',
227
- textOverflow: 'ellipsis',
228
- minWidth: 0,
229
- }}
230
- >
231
- {nodeTag(node)}
232
- </span>
233
- <span
234
- title={unconfirmed ? 'Nobody has reviewed this element yet' : undefined}
235
- style={{
236
- color: unconfirmed ? muted : style.color,
237
- // Unconfirmed is a quiet absence, not a loud claim.
238
- opacity: unconfirmed ? 0.75 : 1,
239
- letterSpacing: 0,
240
- textTransform: 'none',
241
- whiteSpace: 'nowrap',
242
- overflow: 'hidden',
243
- textOverflow: 'ellipsis',
244
- minWidth: 0,
245
- }}
275
+ title={nodeTag(node)}
276
+ aria-label={nodeTag(node)}
277
+ style={{ display: 'inline-flex', flexShrink: 0 }}
246
278
  >
247
- {nodeStateTag(node)}
279
+ <Icon size={13} strokeWidth={2.25} />
248
280
  </span>
281
+ {typeLabel && (
282
+ <span
283
+ style={{
284
+ fontWeight: 600,
285
+ whiteSpace: 'nowrap',
286
+ overflow: 'hidden',
287
+ textOverflow: 'ellipsis',
288
+ minWidth: 0,
289
+ }}
290
+ >
291
+ {typeLabel}
292
+ </span>
293
+ )}
249
294
  </span>
250
295
  {/* Two lines rather than an ellipsis: a truncated label hides the very
251
296
  thing that distinguishes two containers from each other. The fixed
@@ -276,7 +321,7 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
276
321
  overflow: 'hidden',
277
322
  textOverflow: 'ellipsis',
278
323
  }}
279
- title={node.decoration?.description ?? node.members.join(', ')}
324
+ title={node.description ?? node.members.join(', ')}
280
325
  >
281
326
  {nodeSubtitle(node)}
282
327
  </span>
@@ -117,21 +117,46 @@ export function FileDrawer({
117
117
  userSelect: "none",
118
118
  }}
119
119
  >
120
+ {showProgress && progress != null && (
121
+ <div
122
+ role="img"
123
+ aria-label={`Step ${progress.index + 1} of ${progress.total}`}
124
+ style={{
125
+ display: "flex",
126
+ gap: 2,
127
+ flexShrink: 0,
128
+ }}
129
+ >
130
+ {Array.from({ length: progress.total }, (_, i) => (
131
+ <span
132
+ key={i}
133
+ style={{
134
+ flex: 1,
135
+ height: 2,
136
+ borderRadius: 1,
137
+ background:
138
+ i <= progress.index
139
+ ? theme.colors.primary
140
+ : theme.colors.border,
141
+ }}
142
+ />
143
+ ))}
144
+ </div>
145
+ )}
120
146
  <div
121
147
  style={{
122
148
  display: "flex",
123
149
  alignItems: "center",
124
150
  gap: 8,
125
- padding: "6px 10px",
126
- // With segments present they form the header's bottom edge, so
127
- // only draw the hairline when there is nothing to stand in for it.
128
- borderBottom: showProgress
129
- ? "none"
130
- : `1px solid ${theme.colors.border}`,
151
+ // Top runs deeper than the bottom so the title/label sits
152
+ // optically centered inside the sticky top edge.
153
+ padding: "10px 10px 8px",
154
+ // The header row is the last chrome row now, so its hairline is
155
+ // the boundary with the code below.
156
+ borderBottom: `1px solid ${theme.colors.border}`,
131
157
  }}
132
158
  >
133
159
  <span
134
- title={title ?? undefined}
135
160
  style={{
136
161
  flex: 1,
137
162
  minWidth: 0,
@@ -197,33 +222,6 @@ export function FileDrawer({
197
222
  <X size={14} />
198
223
  </button>
199
224
  </div>
200
- {showProgress && progress != null && (
201
- <div
202
- role="img"
203
- aria-label={`Step ${progress.index + 1} of ${progress.total}`}
204
- style={{
205
- display: "flex",
206
- gap: 2,
207
- flexShrink: 0,
208
- padding: "0 10px 6px",
209
- }}
210
- >
211
- {Array.from({ length: progress.total }, (_, i) => (
212
- <span
213
- key={i}
214
- style={{
215
- flex: 1,
216
- height: 2,
217
- borderRadius: 1,
218
- background:
219
- i <= progress.index
220
- ? theme.colors.primary
221
- : theme.colors.border,
222
- }}
223
- />
224
- ))}
225
- </div>
226
- )}
227
225
  </div>
228
226
  <div style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
229
227
  {children}
@@ -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
  /**
@@ -233,7 +240,7 @@ export function humanizeIssueKind(kind: string): string {
233
240
  const KIND_LABEL: Record<string, string> = {
234
241
  missing_file: 'Construct declaration file missing',
235
242
  symbol_ambiguous: 'Multiple construct symbol matches',
236
- symbol_unmatched: 'Construct symbol unconfirmed',
243
+ symbol_unmatched: 'Construct symbol missing',
237
244
  stale_declaration: 'Construct declaration drift',
238
245
  construct_mismatch: 'Construct type mismatch',
239
246
  construct_unconfirmed: 'Construct type unconfirmed',
@@ -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;
@@ -869,16 +869,24 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
869
869
  // the selected step until it does.
870
870
  const [visibleStepIndex, setVisibleStepIndex] = useState<number | null>(null);
871
871
  const drawerStepIndex = drawerTarget?.kind === 'trail' ? drawerTarget.stepIndex : null;
872
+ // Reset when the drawer switches trail (or closes) — synchronously, so the bar
873
+ // never paints the previous trail's position. A step change *within* the same
874
+ // trail is deliberately not reset: the code view scrolls to the new step and
875
+ // the viewport tracker carries the fill across that scroll. Resetting on step
876
+ // change flashed — the post-paint effect snapped the fill to the target, then
877
+ // Pierre's smooth scroll re-reported the old viewport, bouncing target → old →
878
+ // target.
879
+ const activeTrailId = focusedTrail?.id ?? null;
880
+ const [visibleTrailId, setVisibleTrailId] = useState(activeTrailId);
881
+ if (activeTrailId !== visibleTrailId) {
882
+ setVisibleTrailId(activeTrailId);
883
+ setVisibleStepIndex(null);
884
+ }
872
885
  const drawerProgress = useMemo(() => {
873
886
  if (drawerStepIndex == null || !focusedTrail) return null;
874
887
  return { index: visibleStepIndex ?? drawerStepIndex, total: focusedTrail.steps.length };
875
888
  }, [drawerStepIndex, focusedTrail, visibleStepIndex]);
876
889
 
877
- // A new trail or step invalidates whatever the last scroll reported.
878
- useEffect(() => {
879
- setVisibleStepIndex(null);
880
- }, [focusedTrail?.id, drawerStepIndex]);
881
-
882
890
  // Refresh selected component when the components list updates (e.g. verify
883
891
  // writes back declarationRef).
884
892
  useEffect(() => {
@@ -1176,6 +1184,37 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1176
1184
  return map;
1177
1185
  }, [issues]);
1178
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
+ console.log('[stepIssueBadge] early return: issues=', issues?.length, 'trails=', trails?.length);
1194
+ return map;
1195
+ }
1196
+ for (const issue of issues) {
1197
+ if (issue.kind !== 'step_unconfirmed' && issue.kind !== 'step_stale') continue;
1198
+ const stepInfo = issueStep(issue);
1199
+ console.log('[stepIssueBadge] issue', issue.id, 'kind=', issue.kind, 'stepInfo=', stepInfo);
1200
+ if (!stepInfo) continue;
1201
+ const step = stepInfo.trail.steps[stepInfo.stepIndex];
1202
+ if (!step) continue;
1203
+ const edgeId = trailStepGraphEdgeId(step);
1204
+ console.log('[stepIssueBadge] -> edgeId=', edgeId);
1205
+ const prev = map.get(edgeId);
1206
+ // step_stale is worse than step_unconfirmed
1207
+ const isWorse = issue.kind === 'step_stale' && prev?.kind !== 'step_stale';
1208
+ map.set(edgeId, {
1209
+ kind: isWorse || !prev ? issue.kind as 'step_unconfirmed' | 'step_stale' : prev.kind,
1210
+ severity: prev?.severity === 'error' || issue.severity === 'error' ? 'error' : 'info',
1211
+ count: (prev?.count ?? 0) + 1,
1212
+ });
1213
+ }
1214
+ console.log('[stepIssueBadge] final map size=', map.size);
1215
+ return map;
1216
+ }, [issues, trails, issueStep]);
1217
+
1179
1218
  // Expanded verification layers in the diagnostics list. When any layer with
1180
1219
  // findings is expanded, the canvas dims everything that layer does not
1181
1220
  // implicate (see `issueFocus`). The list publishes this through
@@ -2444,7 +2483,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2444
2483
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
2445
2484
  // the actual edge path (not node-center approximations).
2446
2485
  const edgeLabels = useMemo(() => {
2447
- return dispEdges
2486
+ const labels = dispEdges
2448
2487
  .filter((e) => {
2449
2488
  if (e.hidden) return false;
2450
2489
  const d = e?.data as { dimmed?: boolean } | undefined;
@@ -2459,6 +2498,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2459
2498
  labelY?: number;
2460
2499
  pathLength?: number;
2461
2500
  } | undefined;
2501
+ const stepIssue = stepIssueBadgeByEdgeId.get(e.id);
2502
+ console.log('[edgeLabels] edge', e.id, 'stepIssue=', stepIssue);
2462
2503
  return {
2463
2504
  id: e.id,
2464
2505
  mechanism: d?.mechanism ?? 'uses',
@@ -2468,9 +2509,12 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2468
2509
  midY: d?.labelY ?? 0,
2469
2510
  pathLength: d?.pathLength ?? 0,
2470
2511
  stepNos: selectedFlowStepNos?.get(e.id),
2512
+ stepIssue,
2471
2513
  };
2472
2514
  });
2473
- }, [dispEdges, selectedFlowStepNos]);
2515
+ console.log('[edgeLabels] total labels=', labels.length, 'with stepIssue=', labels.filter(l => l.stepIssue).length);
2516
+ return labels;
2517
+ }, [dispEdges, selectedFlowStepNos, stepIssueBadgeByEdgeId]);
2474
2518
 
2475
2519
  // Unique source files across components → sidebar file trees.
2476
2520
  const treeFilePaths = treeFiles;
@@ -2950,6 +2994,53 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2950
2994
  >
2951
2995
  {text}
2952
2996
  </span>
2997
+ {lbl.stepIssue && (() => {
2998
+ const StepIssueIcon = ISSUE_KIND_ICON[lbl.stepIssue.kind];
2999
+ // Match IssueList: error severity = red, info severity = amber
3000
+ const issueColor = lbl.stepIssue.severity === 'error'
3001
+ ? (theme.colors.error ?? '#e5534b')
3002
+ : (theme.colors.warning ?? '#d4a017');
3003
+ const badgeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
3004
+ return (
3005
+ <span
3006
+ aria-hidden
3007
+ title={lbl.stepIssue.kind === 'step_stale'
3008
+ ? 'Call site changed since verification'
3009
+ : 'Call site not yet verified'}
3010
+ style={{
3011
+ position: 'absolute',
3012
+ right: -7,
3013
+ bottom: -6,
3014
+ zIndex: 2,
3015
+ display: 'inline-flex',
3016
+ alignItems: 'center',
3017
+ justifyContent: 'center',
3018
+ gap: 3,
3019
+ minWidth: 18,
3020
+ padding: '2px 4px',
3021
+ borderRadius: 4,
3022
+ border: `1.5px solid ${issueColor}`,
3023
+ background: badgeBg,
3024
+ pointerEvents: 'none',
3025
+ }}
3026
+ >
3027
+ {StepIssueIcon && (
3028
+ <StepIssueIcon size={12} color={issueColor} />
3029
+ )}
3030
+ {lbl.stepIssue.count > 1 && (
3031
+ <span style={{
3032
+ color: issueColor,
3033
+ fontFamily: theme.fonts.monospace,
3034
+ fontSize: 10,
3035
+ fontWeight: 700,
3036
+ lineHeight: 1,
3037
+ }}>
3038
+ {lbl.stepIssue.count}
3039
+ </span>
3040
+ )}
3041
+ </span>
3042
+ );
3043
+ })()}
2953
3044
  </div>
2954
3045
  );
2955
3046
  })}