@principal-ai/subsystems-react 0.50.0 → 0.51.1

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 (83) hide show
  1. package/dist/index.d.ts +8 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +7 -0
  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 +38 -73
  16. package/dist/subsystem/C4Graph.js.map +1 -1
  17. package/dist/subsystem/C4NodeCard.d.ts +119 -0
  18. package/dist/subsystem/C4NodeCard.d.ts.map +1 -0
  19. package/dist/subsystem/C4NodeCard.js +223 -0
  20. package/dist/subsystem/C4NodeCard.js.map +1 -0
  21. package/dist/subsystem/DiagnosticToggle.d.ts +8 -1
  22. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
  23. package/dist/subsystem/DiagnosticToggle.js +40 -6
  24. package/dist/subsystem/DiagnosticToggle.js.map +1 -1
  25. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  26. package/dist/subsystem/FileDrawer.js +20 -21
  27. package/dist/subsystem/FileDrawer.js.map +1 -1
  28. package/dist/subsystem/IssueList.js +1 -1
  29. package/dist/subsystem/IssueList.js.map +1 -1
  30. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.js +13 -4
  32. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  33. package/dist/subsystem/c4.d.ts +272 -0
  34. package/dist/subsystem/c4.d.ts.map +1 -0
  35. package/dist/subsystem/c4.js +322 -0
  36. package/dist/subsystem/c4.js.map +1 -0
  37. package/dist/subsystem/c4Evidence.d.ts +143 -0
  38. package/dist/subsystem/c4Evidence.d.ts.map +1 -0
  39. package/dist/subsystem/c4Evidence.js +240 -0
  40. package/dist/subsystem/c4Evidence.js.map +1 -0
  41. package/dist/subsystem/model.d.ts +27 -7
  42. package/dist/subsystem/model.d.ts.map +1 -1
  43. package/dist/subsystem/model.js +15 -0
  44. package/dist/subsystem/model.js.map +1 -1
  45. package/dist/subsystem/nodes.d.ts.map +1 -1
  46. package/dist/subsystem/nodes.js +78 -7
  47. package/dist/subsystem/nodes.js.map +1 -1
  48. package/package.json +3 -2
  49. package/src/index.ts +37 -0
  50. package/src/pierre/PierreTrailCodeView.tsx +49 -21
  51. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +413 -0
  52. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
  53. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
  54. package/src/stories/Subsystem/ComponentGraph/Deprecated.stories.tsx +358 -0
  55. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +27 -2
  56. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
  57. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +1 -1
  58. package/src/stories/Subsystem/ComponentGraph/{Flows.stories.tsx → Trails.stories.tsx} +4 -4
  59. package/src/subsystem/C4Graph.tsx +81 -107
  60. package/src/subsystem/C4NodeCard.test.ts +301 -0
  61. package/src/subsystem/C4NodeCard.tsx +301 -0
  62. package/src/subsystem/DiagnosticToggle.tsx +58 -4
  63. package/src/subsystem/FileDrawer.tsx +32 -34
  64. package/src/subsystem/IssueList.tsx +1 -1
  65. package/src/subsystem/SubsystemComponentGraph.tsx +13 -5
  66. package/src/subsystem/c4.test.ts +573 -0
  67. package/src/subsystem/c4.ts +558 -0
  68. package/src/subsystem/c4Evidence.test.ts +386 -0
  69. package/src/subsystem/c4Evidence.ts +329 -0
  70. package/src/subsystem/model.ts +36 -7
  71. package/src/subsystem/nodes.tsx +124 -5
  72. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -3
  73. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
  74. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -2
  75. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
  76. package/dist/subsystem/toC4.d.ts +0 -95
  77. package/dist/subsystem/toC4.d.ts.map +0 -1
  78. package/dist/subsystem/toC4.js +0 -192
  79. package/dist/subsystem/toC4.js.map +0 -1
  80. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
  81. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -6
  82. package/src/subsystem/toC4.test.ts +0 -114
  83. package/src/subsystem/toC4.ts +0 -273
@@ -0,0 +1,301 @@
1
+ /**
2
+ * C4NodeCard — the box an element draws as, with nothing else.
3
+ *
4
+ * Extracted from `C4Graph.tsx` so the visual encoding can be inspected,
5
+ * reviewed, and unit-tested without standing up a graph. React Flow supplies
6
+ * edges and hit-testing; this supplies the reading surface.
7
+ *
8
+ * The encoding has one job: make confirmation state legible at a glance. A box
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.
13
+ */
14
+
15
+ import { useTheme } from '@principal-ade/industry-theme';
16
+ import { describeConstructBreakdown } from './model';
17
+ import type { C4Node } from './c4';
18
+
19
+ /**
20
+ * 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
+ */
24
+ export const NODE_W = 210;
25
+ export const NODE_H = 138;
26
+
27
+ /**
28
+ * A component is a proper square — equal sides, and visibly smaller than the
29
+ * container it sits inside. Square-vs-rectangle is the clearest signal that
30
+ * these are different C4 levels, and it survives grayscale and colour-blind
31
+ * reading where a border-weight difference alone does not.
32
+ */
33
+ export const COMPONENT_SIZE = 132;
34
+
35
+ /**
36
+ * Per-level box size. The graph lays out against these, so a square component
37
+ * and a rectangular container do not collide at a shared nominal width.
38
+ */
39
+ export function nodeSize(node: C4Node): { width: number; height: number } {
40
+ return node.kind === 'component'
41
+ ? { width: COMPONENT_SIZE, height: COMPONENT_SIZE }
42
+ : { width: NODE_W, height: NODE_H };
43
+ }
44
+
45
+ /**
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.
49
+ */
50
+ const LABEL_SLOT_H = 40;
51
+ const GAP_SLOT_H = 18;
52
+
53
+ type Theme = ReturnType<typeof useTheme>['theme'];
54
+
55
+ export interface NodeStyle {
56
+ color: string;
57
+ dash: 'solid' | 'dashed' | 'dotted';
58
+ width: number;
59
+ }
60
+
61
+ /**
62
+ * Border style for one node: colour, dash and weight, from confirmation state
63
+ * and level.
64
+ *
65
+ * One axis per channel, so nothing is said twice and nothing is contradicted:
66
+ *
67
+ * colour + dash → confirmation state (the primary axis)
68
+ * weight → C4 level, as a grayscale redundancy for the square
69
+ * shape + size → C4 kind (`nodeShape`, `nodeSize`)
70
+ *
71
+ * State owning the dash is deliberate. An earlier build gave the dash to kind
72
+ * and drew proposed boxes solid, quietly contradicting the one thing the
73
+ * confirm UI needs a reader to see. Kind now says itself through shape, and
74
+ * the border says who vouched for it.
75
+ */
76
+ export function nodeStyle(node: C4Node, theme: Theme, selected = false): NodeStyle {
77
+ const muted = theme.colors.border ?? '#555';
78
+ if (selected) return { color: theme.colors.primary ?? '#5aa', dash: 'solid', width: 3 };
79
+
80
+ // Border weight carries the level, so a component stays a component even
81
+ // in grayscale or for a reader who cannot separate the hues.
82
+ const weight = node.kind === 'component' ? 1 : 2;
83
+
84
+ switch (node.state) {
85
+ case 'accepted':
86
+ // Solid and saturated — part of the architecture now.
87
+ return { color: theme.colors.primary ?? '#4ec9b0', dash: 'solid', width: weight };
88
+ case 'proposed':
89
+ // Dashed — an agent asked; a human has not answered.
90
+ return { color: theme.colors.warning ?? '#e8a33a', dash: 'dashed', width: weight };
91
+ case 'rejected':
92
+ // Should never be drawn (buildC4Model drops it); belt-and-braces.
93
+ return { color: muted, dash: 'dotted', width: 1 };
94
+ }
95
+ }
96
+
97
+ function baseName(path: string): string {
98
+ const parts = path.split('/').filter(Boolean);
99
+ return parts[parts.length - 1] ?? path;
100
+ }
101
+
102
+ /**
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.
107
+ */
108
+ 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'}` : ''}`;
118
+ }
119
+
120
+ /**
121
+ * What the box IS — its C4 kind. An accepted container is still a container;
122
+ * collapsing the two is what once made containers and components unreadable.
123
+ * Confirmation state is a separate axis carried by the border (see
124
+ * `nodeStyle`), not by a word on the card.
125
+ */
126
+ export function nodeTag(node: C4Node): string {
127
+ return node.kind;
128
+ }
129
+
130
+ /** How a box is drawn. Shape carries the C4 kind, independent of colour. */
131
+ export type C4ShapeKind = 'rect' | 'square' | 'pill';
132
+
133
+ export interface NodeShape {
134
+ kind: C4ShapeKind;
135
+ /** Corner radius, for the shapes that use one. */
136
+ radius: number;
137
+ }
138
+
139
+ /**
140
+ * Shape cue for the C4 kind, independent of colour and dash — both of which
141
+ * belong to confirmation state.
142
+ *
143
+ * The convention:
144
+ *
145
+ * container wide rectangle, sharp corners — a runtime boundary
146
+ * component square — a part inside one (see `nodeSize`)
147
+ * external system wide rectangle, rounded — someone else's system
148
+ * person pill — not a box at all
149
+ *
150
+ * An external system is separated from a container by radius alone, which is
151
+ * a weak cue. That is deliberate rather than an oversight: the border dash is
152
+ * 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.
155
+ */
156
+ export function nodeShape(node: C4Node): NodeShape {
157
+ switch (node.kind) {
158
+ case 'person':
159
+ return { kind: 'pill', radius: NODE_H / 2 };
160
+ case 'external-system':
161
+ return { kind: 'rect', radius: 14 };
162
+ case 'component':
163
+ return { kind: 'square', radius: 6 };
164
+ default:
165
+ return { kind: 'rect', radius: 4 };
166
+ }
167
+ }
168
+
169
+ /**
170
+ * The gaps the C4 notation still wants on this element, named rather than left
171
+ * to be noticed.
172
+ *
173
+ * Containers are where the notation is strictest: it requires a technology and
174
+ * a description on every one. A component needs a technology too, but a
175
+ * component drawn without a parent already reports `unknown_container`, and
176
+ * that is the question worth asking first.
177
+ */
178
+ export function nodeMissing(node: C4Node): string[] {
179
+ if (node.kind !== 'container') return [];
180
+ const missing: string[] = [];
181
+ if (!node.technology) missing.push('technology');
182
+ if (!node.description) missing.push('description');
183
+ return missing;
184
+ }
185
+
186
+ export interface C4NodeCardProps {
187
+ node: C4Node;
188
+ selected?: boolean;
189
+ /** Draw React Flow's edge handles. Off when rendering a bare gallery. */
190
+ handles?: React.ReactNode;
191
+ onClick?: () => void;
192
+ }
193
+
194
+ export function C4NodeCard({ node, selected = false, handles, onClick }: C4NodeCardProps) {
195
+ const { theme } = useTheme();
196
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
197
+ const style = nodeStyle(node, theme, selected);
198
+ const shape = nodeShape(node);
199
+ const missing = nodeMissing(node);
200
+
201
+ const size = nodeSize(node);
202
+
203
+ return (
204
+ <div
205
+ onClick={onClick}
206
+ style={{
207
+ width: size.width,
208
+ minHeight: size.height,
209
+ boxSizing: 'border-box',
210
+ display: 'flex',
211
+ flexDirection: 'column',
212
+ // Top-aligned, not centred: the number of lines varies (the notation-gap
213
+ // marker is conditional), and centring pushes the tag to a different
214
+ // height on every card. In a graph grid that makes the row unscannable.
215
+ justifyContent: 'flex-start',
216
+ gap: 3,
217
+ padding: '10px 12px',
218
+ borderRadius: shape.radius,
219
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
220
+ border: `${style.width} ${style.dash} ${style.color}`,
221
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
222
+ cursor: onClick ? 'pointer' : 'default',
223
+ fontFamily: theme.fonts.body,
224
+ }}
225
+ >
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. */}
229
+ <span
230
+ style={{
231
+ fontFamily: theme.fonts.monospace,
232
+ fontSize: theme.fontSizes[0],
233
+ color: style.color,
234
+ fontWeight: 600,
235
+ letterSpacing: 0.5,
236
+ textTransform: 'uppercase',
237
+ whiteSpace: 'nowrap',
238
+ overflow: 'hidden',
239
+ textOverflow: 'ellipsis',
240
+ }}
241
+ >
242
+ {nodeTag(node)}
243
+ </span>
244
+ {/* Two lines rather than an ellipsis: a truncated label hides the very
245
+ thing that distinguishes two containers from each other. The fixed
246
+ height reserves both lines even for a one-line label, so the subtitle
247
+ below sits at the same y on every card. */}
248
+ <span
249
+ style={{
250
+ fontWeight: 600,
251
+ fontSize: theme.fontSizes[2],
252
+ color: theme.colors.text,
253
+ lineHeight: 1.25,
254
+ height: LABEL_SLOT_H,
255
+ display: '-webkit-box',
256
+ WebkitLineClamp: 2,
257
+ WebkitBoxOrient: 'vertical',
258
+ overflow: 'hidden',
259
+ }}
260
+ title={node.label}
261
+ >
262
+ {node.label}
263
+ </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 && (
281
+ <span
282
+ title={`C4 requires a ${missing.join(' and a ')} on every container`}
283
+ style={{
284
+ fontFamily: theme.fonts.monospace,
285
+ 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',
293
+ }}
294
+ >
295
+ needs {missing.join(' + ')}
296
+ </span>
297
+ )}
298
+ {handles}
299
+ </div>
300
+ );
301
+ }
@@ -28,6 +28,13 @@ export interface SubsystemDiagnostic {
28
28
  status: SubsystemDiagnosticStatus;
29
29
  /** Count shown beside the icon (e.g. error + warn findings). Hidden when 0/undefined. */
30
30
  issueCount?: number;
31
+ /**
32
+ * How many of the open findings carry a one-click deterministic fix (a
33
+ * re-pin, an adoptable signature fill, a unique Graphify file relocate).
34
+ * Badged on the icon so it's visible without expanding the list — the fixes
35
+ * live inside collapsed categories, so the count was previously invisible.
36
+ */
37
+ fixableCount?: number;
31
38
  /** Inputs changed since the report was produced. */
32
39
  stale?: boolean;
33
40
  /** A verification pass is running right now. */
@@ -55,7 +62,7 @@ export function diagnosticStatusColor(
55
62
  /** Human tooltip for the current diagnostic state. */
56
63
  export function diagnosticTitle(diagnostic: SubsystemDiagnostic): string {
57
64
  if (diagnostic.title) return diagnostic.title;
58
- const { status, issueCount, stale, busy } = diagnostic;
65
+ const { status, issueCount, fixableCount, stale, busy } = diagnostic;
59
66
  let head: string;
60
67
  if (status === 'ok') head = 'Diagnostics: no issues';
61
68
  else if (status === 'issues')
@@ -69,9 +76,15 @@ export function diagnosticTitle(diagnostic: SubsystemDiagnostic): string {
69
76
  ? `Diagnostics: ${issueCount} unconfirmed`
70
77
  : 'Diagnostics: unconfirmed claims';
71
78
  else head = 'Diagnostics: not checked yet';
79
+ // Name the one-click count first when there is work a click can close —
80
+ // that's the actionable part of the list.
81
+ const fixable =
82
+ fixableCount != null && fixableCount > 0
83
+ ? `${fixableCount} fixable with one click`
84
+ : '';
72
85
  if (busy) return `${head} — checking…`;
73
- if (stale) return `${head} (stale) — toggle the list`;
74
- return `${head} — toggle the list`;
86
+ if (stale) return `${head} (stale) — toggle the list${fixable ? `, ${fixable}` : ''}`;
87
+ return `${head} — toggle the list${fixable ? `, ${fixable}` : ''}`;
75
88
  }
76
89
 
77
90
  export interface SubsystemDiagnosticToggleProps extends SubsystemDiagnostic {
@@ -82,6 +95,7 @@ export interface SubsystemDiagnosticToggleProps extends SubsystemDiagnostic {
82
95
  export function SubsystemDiagnosticToggle({
83
96
  status,
84
97
  issueCount,
98
+ fixableCount = 0,
85
99
  stale = false,
86
100
  busy = false,
87
101
  active = false,
@@ -93,8 +107,20 @@ export function SubsystemDiagnosticToggle({
93
107
  const [hover, setHover] = useState(false);
94
108
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
95
109
  const color = diagnosticStatusColor(status, theme.colors, muted);
96
- const label = diagnosticTitle({ status, issueCount, stale, busy, active, ...rest });
110
+ const label = diagnosticTitle({
111
+ status,
112
+ issueCount,
113
+ fixableCount,
114
+ stale,
115
+ busy,
116
+ active,
117
+ ...rest,
118
+ });
97
119
  const showCount = issueCount != null && issueCount > 0;
120
+ // Green regardless of status: this marks "a click fixes this", not severity,
121
+ // and green is the same accent the per-finding Apply button uses.
122
+ const showFixable = fixableCount > 0 && !busy;
123
+ const fixableColor = theme.colors.success ?? '#2da44e';
98
124
  const background = active
99
125
  ? hexWithAlpha(color, hover ? 0.24 : 0.16)
100
126
  : hover
@@ -151,6 +177,34 @@ export function SubsystemDiagnosticToggle({
151
177
  }}
152
178
  />
153
179
  )}
180
+ {/* Fixable badge — superscript count on the icon's trailing edge.
181
+ Offset up-and-out from the `stale` dot so both read at once;
182
+ the badge is the call to action, the dot only a freshness note. */}
183
+ {showFixable && (
184
+ <span
185
+ aria-hidden
186
+ style={{
187
+ position: 'absolute',
188
+ top: -5,
189
+ right: -8,
190
+ minWidth: 12,
191
+ height: 12,
192
+ padding: '0 3px',
193
+ borderRadius: 6,
194
+ display: 'inline-flex',
195
+ alignItems: 'center',
196
+ justifyContent: 'center',
197
+ background: fixableColor,
198
+ color: theme.colors.background,
199
+ fontFamily: theme.fonts.monospace,
200
+ fontSize: 9,
201
+ fontWeight: 700,
202
+ lineHeight: 1,
203
+ }}
204
+ >
205
+ {fixableCount > 99 ? '99+' : fixableCount}
206
+ </span>
207
+ )}
154
208
  </span>
155
209
  {showCount && (
156
210
  <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}
@@ -233,7 +233,7 @@ export function humanizeIssueKind(kind: string): string {
233
233
  const KIND_LABEL: Record<string, string> = {
234
234
  missing_file: 'Construct declaration file missing',
235
235
  symbol_ambiguous: 'Multiple construct symbol matches',
236
- symbol_unmatched: 'Construct symbol unconfirmed',
236
+ symbol_unmatched: 'Construct symbol missing',
237
237
  stale_declaration: 'Construct declaration drift',
238
238
  construct_mismatch: 'Construct type mismatch',
239
239
  construct_unconfirmed: 'Construct type unconfirmed',
@@ -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(() => {