@principal-ai/subsystems-react 0.51.1 → 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.
@@ -6,6 +6,7 @@ import {
6
6
  nodeStyle,
7
7
  nodeSubtitle,
8
8
  nodeTag,
9
+ nodeTypeLabel,
9
10
  nodeShape,
10
11
  nodeSize,
11
12
  nodeMissing,
@@ -147,13 +148,13 @@ const VARIANTS: Array<{ group: string; note: string; node: C4Node }> = [
147
148
  },
148
149
  {
149
150
  group: '',
150
- note: '',
151
+ note: 'A queue or topic is a data store, not its own kind. C4 says an individual queue is a bucket of messages — a data store — and that the message bus itself is not a container at all. The queue-ness lives in the label and technology.',
151
152
  node: node({
152
153
  id: 'b2',
153
154
  label: 'Audit correction queue',
154
155
  members: ['s2'],
155
156
  constructs: ['store'],
156
- containerKind: 'queue',
157
+ containerKind: 'data-store',
157
158
  technology: 'agent session transport',
158
159
  description: 'Proposals awaiting a human decision.',
159
160
  state: 'accepted',
@@ -333,7 +334,7 @@ function Gallery() {
333
334
 
334
335
  <div style={{ fontFamily: 'monospace', fontSize: 11, marginBottom: 26, lineHeight: 1.9 }}>
335
336
  <div style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
336
- <span style={{ color: '#e8e8ea' }}>KIND — shape and border weight</span>
337
+ <span style={{ color: '#e8e8ea' }}>KIND — shape, icon, and border weight</span>
337
338
  </div>
338
339
  <div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', marginTop: 4 }}>
339
340
  {KINDS.map((kind) => {
@@ -384,7 +385,9 @@ function Gallery() {
384
385
  </div>
385
386
  )}
386
387
  <Case
387
- caption={`${v.node.id} · "${nodeTag(v.node)}" · ${v.node.state} · ${nodeShape(v.node).kind} ${
388
+ caption={`${v.node.id} · "${nodeTag(v.node)}"${
389
+ nodeTypeLabel(v.node) ? ` / ${nodeTypeLabel(v.node)}` : ''
390
+ } · ${v.node.state} · ${nodeShape(v.node).kind} ${
388
391
  nodeSize(v.node).width
389
392
  }×${nodeSize(v.node).height} · ${nodeSubtitle(v.node)}${
390
393
  nodeMissing(v.node).length ? ` · missing ${nodeMissing(v.node).join('+')}` : ''
@@ -61,8 +61,34 @@ const ISSUES: SubsystemIssue[] = [
61
61
  message: 'Step 2 references a line that moved (was :42).',
62
62
  target: {
63
63
  kind: 'step',
64
- id: 'flow-1',
65
- label: 'Checkout',
64
+ id: 'story-steps',
65
+ label: 'Story steps',
66
+ detail: 'step 2',
67
+ stepIndex: 1,
68
+ },
69
+ },
70
+ {
71
+ id: 'i20',
72
+ severity: 'info',
73
+ kind: 'step_unconfirmed',
74
+ message: 'Trail "Story steps" step 1: call site not verified (entry → store via writes)',
75
+ target: {
76
+ kind: 'step',
77
+ id: 'story-steps',
78
+ label: 'Story steps',
79
+ detail: 'step 1',
80
+ stepIndex: 0,
81
+ },
82
+ },
83
+ {
84
+ id: 'i21',
85
+ severity: 'info',
86
+ kind: 'step_stale',
87
+ message: 'Trail "Story steps" step 2: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
88
+ target: {
89
+ kind: 'step',
90
+ id: 'story-steps',
91
+ label: 'Story steps',
66
92
  detail: 'step 2',
67
93
  stepIndex: 1,
68
94
  },
@@ -181,3 +207,143 @@ export const GraphWithIssues: Story = {
181
207
  </div>
182
208
  ),
183
209
  };
210
+
211
+ /** Trail step verification findings — unconfirmed and stale call sites. */
212
+ const TRAIL_STEP_ISSUES: SubsystemIssue[] = [
213
+ {
214
+ id: 'ts1',
215
+ severity: 'info',
216
+ kind: 'step_unconfirmed',
217
+ message: 'Trail "Add to Cart" step 1: call site not verified (addToCart → cartStore via writes)',
218
+ target: {
219
+ kind: 'step',
220
+ id: 'add-to-cart',
221
+ label: 'Add to Cart',
222
+ detail: 'step 1',
223
+ stepIndex: 0,
224
+ },
225
+ },
226
+ {
227
+ id: 'ts2',
228
+ severity: 'info',
229
+ kind: 'step_unconfirmed',
230
+ message: 'Trail "Add to Cart" step 2: call site not verified (cartStore → inventoryService via calls)',
231
+ target: {
232
+ kind: 'step',
233
+ id: 'add-to-cart',
234
+ label: 'Add to Cart',
235
+ detail: 'step 2',
236
+ stepIndex: 1,
237
+ },
238
+ },
239
+ {
240
+ id: 'ts3',
241
+ severity: 'info',
242
+ kind: 'step_stale',
243
+ message: 'Trail "Checkout" step 1: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
244
+ target: {
245
+ kind: 'step',
246
+ id: 'checkout',
247
+ label: 'Checkout',
248
+ detail: 'step 1',
249
+ stepIndex: 0,
250
+ },
251
+ },
252
+ {
253
+ id: 'ts4',
254
+ severity: 'info',
255
+ kind: 'step_stale',
256
+ message: 'Trail "Checkout" step 3: call site changed since verification (lines 78-82 in src/checkout/api.ts)',
257
+ target: {
258
+ kind: 'step',
259
+ id: 'checkout',
260
+ label: 'Checkout',
261
+ detail: 'step 3',
262
+ stepIndex: 2,
263
+ },
264
+ },
265
+ ];
266
+
267
+ /** Trail step verification: shows unconfirmed and stale call site findings. */
268
+ export const TrailStepVerification: Story = {
269
+ render: () => (
270
+ <div style={{ display: 'flex', flexDirection: 'column', width: 380, height: '100vh', borderRight: '1px solid #333' }}>
271
+ <SubsystemIssueList
272
+ issues={TRAIL_STEP_ISSUES}
273
+ focusCategory="trail"
274
+ onSelectIssue={() => {}}
275
+ />
276
+ </div>
277
+ ),
278
+ };
279
+
280
+ /** Trail step verification badges on graph edges. */
281
+ const stepVerificationComponents = components([
282
+ ['api', 'checkoutApi', 'function', 'src/checkout/api.ts', 'pkg:github/you/your-app', 'Handles cart requests.', 'checkoutApi'],
283
+ ['store', 'cartStore', 'store', 'src/checkout/cartStore.ts', 'pkg:github/you/your-app', 'Retained cart state.', 'cartStore'],
284
+ ['payment', 'PaymentService', 'class', 'src/payment/service.ts', 'pkg:github/you/your-app', 'Processes payments.', 'PaymentService'],
285
+ ]);
286
+
287
+ const stepVerificationTrails = [
288
+ {
289
+ id: 'checkout-flow',
290
+ title: 'Checkout',
291
+ steps: [
292
+ { from: 'api', to: 'store', mechanism: 'writes' as const },
293
+ { from: 'store', to: 'payment', mechanism: 'calls' as const },
294
+ ],
295
+ },
296
+ ];
297
+
298
+ const stepVerificationIssues: SubsystemIssue[] = [
299
+ {
300
+ id: 'sv1',
301
+ severity: 'info',
302
+ kind: 'step_unconfirmed',
303
+ message: 'Trail "Checkout" step 1: call site not verified (checkoutApi → cartStore via writes)',
304
+ target: {
305
+ kind: 'step',
306
+ id: 'checkout-flow',
307
+ label: 'Checkout',
308
+ detail: 'step 1',
309
+ stepIndex: 0,
310
+ },
311
+ },
312
+ {
313
+ id: 'sv2',
314
+ severity: 'info',
315
+ kind: 'step_stale',
316
+ message: 'Trail "Checkout" step 2: call site changed since verification (lines 42-44 in src/checkout/api.ts)',
317
+ target: {
318
+ kind: 'step',
319
+ id: 'checkout-flow',
320
+ label: 'Checkout',
321
+ detail: 'step 2',
322
+ stepIndex: 1,
323
+ },
324
+ },
325
+ ];
326
+
327
+ /** Graph with trail step verification badges on edges — ? for unconfirmed, clock for stale. */
328
+ export const GraphWithStepVerificationBadges: Story = {
329
+ render: () => (
330
+ <div style={{ width: '100%', height: '100vh' }}>
331
+ <SubsystemComponentGraph
332
+ components={stepVerificationComponents}
333
+ trails={stepVerificationTrails}
334
+ initialTrailId="checkout-flow"
335
+ title="Checkout with Step Verification"
336
+ description="Shows edge badges for step verification status: question mark (?) for unconfirmed call sites, clock for stale call sites."
337
+ diagnostic={{
338
+ status: 'issues',
339
+ issueCount: stepVerificationIssues.length,
340
+ onToggle: () => {},
341
+ }}
342
+ issues={stepVerificationIssues}
343
+ showIssues
344
+ onSelectIssue={() => {}}
345
+ onApplyIssueFix={() => {}}
346
+ />
347
+ </div>
348
+ ),
349
+ };
@@ -3,6 +3,8 @@ import {
3
3
  nodeStyle,
4
4
  nodeSubtitle,
5
5
  nodeTag,
6
+ nodeKindIcon,
7
+ nodeTypeLabel,
6
8
  nodeShape,
7
9
  nodeMissing,
8
10
  NODE_W,
@@ -111,6 +113,36 @@ describe('nodeTag — what the box IS', () => {
111
113
  });
112
114
  });
113
115
 
116
+ describe('nodeKindIcon — the kind as a glyph, not a word', () => {
117
+ test('every kind has its own icon', () => {
118
+ const icons = (['container', 'component', 'external-system', 'person'] as const).map((kind) =>
119
+ nodeKindIcon(node({ kind })),
120
+ );
121
+ for (const icon of icons) expect(icon).toBeDefined();
122
+ // Different kinds must not share a glyph, or the icon says nothing.
123
+ expect(new Set(icons).size).toBe(4);
124
+ });
125
+
126
+ test('the icon does not depend on confirmation state', () => {
127
+ expect(nodeKindIcon(node({ state: 'proposed' }))).toBe(
128
+ nodeKindIcon(node({ state: 'accepted' })),
129
+ );
130
+ });
131
+ });
132
+
133
+ describe('nodeTypeLabel — the one word in the icon row', () => {
134
+ test('a container names its kind', () => {
135
+ expect(nodeTypeLabel(node({ containerKind: 'application' }))).toBe('application');
136
+ expect(nodeTypeLabel(node({ containerKind: 'data-store' }))).toBe('data-store');
137
+ });
138
+
139
+ test('the other three kinds have no subtype, so no word', () => {
140
+ for (const kind of ['component', 'external-system', 'person'] as const) {
141
+ expect(nodeTypeLabel(node({ kind }))).toBeUndefined();
142
+ }
143
+ });
144
+ });
145
+
114
146
  describe('nodeShape — kind without relying on colour or dash', () => {
115
147
  test('a container is a sharp rectangle', () => {
116
148
  const s = nodeShape(node());
@@ -142,14 +174,19 @@ describe('nodeShape — kind without relying on colour or dash', () => {
142
174
  });
143
175
 
144
176
  describe('nodeSubtitle', () => {
145
- test('shows the container kind and technology', () => {
146
- expect(
147
- nodeSubtitle(node({ containerKind: 'application', technology: 'Bun + Electrobun' })),
148
- ).toBe('application · Bun + Electrobun');
177
+ test('shows the technology, and does not repeat the container kind', () => {
178
+ const s = nodeSubtitle(
179
+ node({ containerKind: 'application', technology: 'Bun + Electrobun' }),
180
+ );
181
+ expect(s).toBe('Bun + Electrobun');
182
+ expect(s).not.toContain('application');
149
183
  });
150
184
 
151
- test('shows a data store with no technology as just the kind', () => {
152
- expect(nodeSubtitle(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
185
+ test('a container with no technology falls back to its construct breakdown', () => {
186
+ const s = nodeSubtitle(
187
+ node({ containerKind: 'data-store', technology: '', constructs: ['store'], members: ['a'] }),
188
+ );
189
+ expect(s).toBe('1 store · 1 component');
153
190
  });
154
191
 
155
192
  test('falls back to the construct breakdown when neither is stated', () => {
@@ -286,16 +323,16 @@ describe('card metrics', () => {
286
323
  });
287
324
 
288
325
  describe('container kinds', () => {
289
- test('every container kind renders as a subtitle without special-casing', () => {
290
- const kinds = ['application', 'data-store', 'queue'] as const;
326
+ test('every container kind renders as the icon-row label without special-casing', () => {
327
+ const kinds = ['application', 'data-store'] as const;
291
328
  for (const containerKind of kinds) {
292
- expect(nodeSubtitle(node({ containerKind, technology: '' }))).toBe(containerKind);
329
+ expect(nodeTypeLabel(node({ containerKind }))).toBe(containerKind);
293
330
  }
294
331
  });
295
332
 
296
333
  test('a library is not one of them', () => {
297
334
  // C4 says a module "typically" is not an element at all, so there is no
298
335
  // kind to render. A library-shaped group is a review comment.
299
- expect(['application', 'data-store', 'queue']).not.toContain('library');
336
+ expect(['application', 'data-store']).not.toContain('library');
300
337
  });
301
338
  });
@@ -13,6 +13,8 @@
13
13
  */
14
14
 
15
15
  import { useTheme } from '@principal-ade/industry-theme';
16
+ import { Cloud, Component, Container, UserRound } from 'lucide-react';
17
+ import type { LucideIcon } from 'lucide-react';
16
18
  import { describeConstructBreakdown } from './model';
17
19
  import type { C4Node } from './c4';
18
20
 
@@ -102,14 +104,14 @@ function baseName(path: string): string {
102
104
  /**
103
105
  * The kind + technology line under the label.
104
106
  *
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.
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.
107
110
  */
108
111
  export function nodeSubtitle(node: C4Node): string {
109
- const parts = [node.containerKind, node.technology].filter(Boolean);
110
- if (parts.length > 0) return parts.join(' · ');
112
+ if (node.technology) return node.technology;
111
113
 
112
- // No confirmed attributes: fall back to what the document told us.
114
+ // No stated technology: fall back to what the document told us.
113
115
  if (node.kind === 'component') {
114
116
  return `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`;
115
117
  }
@@ -127,6 +129,34 @@ export function nodeTag(node: C4Node): string {
127
129
  return node.kind;
128
130
  }
129
131
 
132
+ /**
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".
137
+ */
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;
148
+ }
149
+ }
150
+
151
+ /**
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
+
130
160
  /** How a box is drawn. Shape carries the C4 kind, independent of colour. */
131
161
  export type C4ShapeKind = 'rect' | 'square' | 'pill';
132
162
 
@@ -150,8 +180,8 @@ export interface NodeShape {
150
180
  * An external system is separated from a container by radius alone, which is
151
181
  * a weak cue. That is deliberate rather than an oversight: the border dash is
152
182
  * 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.
183
+ * already spent on proposed-vs-accepted. The kind icon says it, and the frame
184
+ * position says it structurally.
155
185
  */
156
186
  export function nodeShape(node: C4Node): NodeShape {
157
187
  switch (node.kind) {
@@ -197,6 +227,8 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
197
227
  const style = nodeStyle(node, theme, selected);
198
228
  const shape = nodeShape(node);
199
229
  const missing = nodeMissing(node);
230
+ const Icon = nodeKindIcon(node);
231
+ const typeLabel = nodeTypeLabel(node);
200
232
 
201
233
  const size = nodeSize(node);
202
234
 
@@ -223,23 +255,42 @@ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeC
223
255
  fontFamily: theme.fonts.body,
224
256
  }}
225
257
  >
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. */}
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. */}
229
261
  <span
230
262
  style={{
263
+ display: 'flex',
264
+ alignItems: 'center',
265
+ gap: 6,
231
266
  fontFamily: theme.fonts.monospace,
232
267
  fontSize: theme.fontSizes[0],
233
268
  color: style.color,
234
- fontWeight: 600,
235
269
  letterSpacing: 0.5,
236
270
  textTransform: 'uppercase',
237
- whiteSpace: 'nowrap',
238
- overflow: 'hidden',
239
- textOverflow: 'ellipsis',
271
+ minWidth: 0,
240
272
  }}
241
273
  >
242
- {nodeTag(node)}
274
+ <span
275
+ title={nodeTag(node)}
276
+ aria-label={nodeTag(node)}
277
+ style={{ display: 'inline-flex', flexShrink: 0 }}
278
+ >
279
+ <Icon size={13} strokeWidth={2.25} />
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
+ )}
243
294
  </span>
244
295
  {/* Two lines rather than an ellipsis: a truncated label hides the very
245
296
  thing that distinguishes two containers from each other. The fixed
@@ -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,37 @@ 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
+ 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
+
1187
1218
  // Expanded verification layers in the diagnostics list. When any layer with
1188
1219
  // findings is expanded, the canvas dims everything that layer does not
1189
1220
  // implicate (see `issueFocus`). The list publishes this through
@@ -2452,7 +2483,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2452
2483
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
2453
2484
  // the actual edge path (not node-center approximations).
2454
2485
  const edgeLabels = useMemo(() => {
2455
- return dispEdges
2486
+ const labels = dispEdges
2456
2487
  .filter((e) => {
2457
2488
  if (e.hidden) return false;
2458
2489
  const d = e?.data as { dimmed?: boolean } | undefined;
@@ -2467,6 +2498,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2467
2498
  labelY?: number;
2468
2499
  pathLength?: number;
2469
2500
  } | undefined;
2501
+ const stepIssue = stepIssueBadgeByEdgeId.get(e.id);
2502
+ console.log('[edgeLabels] edge', e.id, 'stepIssue=', stepIssue);
2470
2503
  return {
2471
2504
  id: e.id,
2472
2505
  mechanism: d?.mechanism ?? 'uses',
@@ -2476,9 +2509,12 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2476
2509
  midY: d?.labelY ?? 0,
2477
2510
  pathLength: d?.pathLength ?? 0,
2478
2511
  stepNos: selectedFlowStepNos?.get(e.id),
2512
+ stepIssue,
2479
2513
  };
2480
2514
  });
2481
- }, [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]);
2482
2518
 
2483
2519
  // Unique source files across components → sidebar file trees.
2484
2520
  const treeFilePaths = treeFiles;
@@ -2958,6 +2994,53 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2958
2994
  >
2959
2995
  {text}
2960
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
+ })()}
2961
3044
  </div>
2962
3045
  );
2963
3046
  })}
@@ -83,12 +83,13 @@ interface C4ElementBase {
83
83
  /**
84
84
  * What kind of thing a container is.
85
85
  *
86
- * `application` is a deployable program; the other two are storage that the
87
- * system reads or writes. Note c4model is explicit that an individual *queue*
88
- * is a data store, not the bus: "model the message bus as a container" is
89
- * listed as incorrect.
86
+ * C4 recognises exactly two: an `application` (a deployable program) or a
87
+ * `data-store` (something the system reads or writes). There is deliberately
88
+ * no `queue` kind — c4model is explicit that an individual queue or topic *is*
89
+ * a data store, and that the message bus itself is not a container at all, so
90
+ * a queue is a `data-store` whose technology/label say "queue".
90
91
  */
91
- export type C4ContainerKind = 'application' | 'data-store' | 'queue';
92
+ export type C4ContainerKind = 'application' | 'data-store';
92
93
 
93
94
  /** A deployable unit or a store — the C4 level 2 box. */
94
95
  export interface C4Container extends C4ElementBase {
@@ -161,7 +162,7 @@ export interface C4Node {
161
162
  technology?: string;
162
163
  description?: string;
163
164
  state: C4ElementState;
164
- /** `application` / `data-store` / `queue`. Set on containers only. */
165
+ /** `application` / `data-store`. Set on containers only. */
165
166
  containerKind?: C4ContainerKind;
166
167
  /** Provenance, when the element consolidated several derived keys. */
167
168
  derivedFrom?: string[];
@@ -196,10 +196,10 @@ describe('suggestContainerKind', () => {
196
196
  });
197
197
 
198
198
  test('there is no library answer, even for a library-shaped group', () => {
199
- // The kinds are application | data-store | queue. A package of interfaces
200
- // still comes back `application`, with the question left to a person in
201
- // the `library_shaped` concern. Returning `library` here would put a
202
- // non-C4 concept into the vocabulary.
199
+ // The kinds are application | data-store. A package of interfaces still
200
+ // comes back `application`, with the question left to a person in the
201
+ // `library_shaped` concern. Returning `library` here would put a non-C4
202
+ // concept into the vocabulary.
203
203
  const members = [member({ alias: 'a', construct: 'interface' })];
204
204
  expect(deriveConcerns('x', members).some((c) => c.kind === 'library_shaped')).toBe(true);
205
205
  expect(suggestContainerKind(members)).toBe('application');