@principal-ai/subsystems-react 0.24.0 → 0.24.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 (46) hide show
  1. package/dist/index.d.ts +7 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/ComponentDeclaration.d.ts +3 -1
  6. package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
  7. package/dist/subsystem/ComponentDeclaration.js +2 -2
  8. package/dist/subsystem/ComponentDeclaration.js.map +1 -1
  9. package/dist/subsystem/ConstructsCatalog.d.ts +30 -0
  10. package/dist/subsystem/ConstructsCatalog.d.ts.map +1 -0
  11. package/dist/subsystem/ConstructsCatalog.js +329 -0
  12. package/dist/subsystem/ConstructsCatalog.js.map +1 -0
  13. package/dist/subsystem/DiagnosticToggle.d.ts +46 -0
  14. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -0
  15. package/dist/subsystem/DiagnosticToggle.js +95 -0
  16. package/dist/subsystem/DiagnosticToggle.js.map +1 -0
  17. package/dist/subsystem/IssueList.d.ts +103 -0
  18. package/dist/subsystem/IssueList.d.ts.map +1 -0
  19. package/dist/subsystem/IssueList.js +310 -0
  20. package/dist/subsystem/IssueList.js.map +1 -0
  21. package/dist/subsystem/SubsystemComponentGraph.d.ts +28 -0
  22. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  23. package/dist/subsystem/SubsystemComponentGraph.js +81 -58
  24. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  25. package/dist/subsystem/SubsystemFileTree.js +2 -2
  26. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  27. package/dist/subsystem/model.d.ts +13 -2
  28. package/dist/subsystem/model.d.ts.map +1 -1
  29. package/dist/subsystem/model.js +12 -0
  30. package/dist/subsystem/model.js.map +1 -1
  31. package/dist/subsystem/nodes.js +5 -5
  32. package/dist/subsystem/nodes.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/index.ts +37 -0
  35. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +287 -0
  36. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +138 -0
  37. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +130 -0
  38. package/src/subsystem/ComponentDeclaration.tsx +4 -1
  39. package/src/subsystem/ConstructsCatalog.tsx +604 -0
  40. package/src/subsystem/DiagnosticToggle.tsx +160 -0
  41. package/src/subsystem/IssueList.tsx +519 -0
  42. package/src/subsystem/SubsystemComponentGraph.tsx +119 -38
  43. package/src/subsystem/SubsystemFileTree.tsx +2 -2
  44. package/src/subsystem/model.test.ts +34 -0
  45. package/src/subsystem/model.ts +18 -2
  46. package/src/subsystem/nodes.tsx +5 -5
@@ -0,0 +1,160 @@
1
+ /**
2
+ * DiagnosticToggle — the subsystem graph's status/report chip.
3
+ *
4
+ * A compact button that lives in the sidebar title row (beside the description
5
+ * toggle). It carries the *status* of the graph's last verification pass in its
6
+ * icon color + count and, on click, opens the full report (the host owns that
7
+ * report; this component only surfaces state and calls `onOpen`).
8
+ *
9
+ * Status → color (theme):
10
+ * - `ok` → success (green)
11
+ * - `issues` → error (red)
12
+ * - `gaps` → warning (amber) — nothing failed, but some claims are unconfirmed
13
+ * - `unknown` → muted (never checked)
14
+ *
15
+ * `stale` means the inputs changed since the report was produced — the color
16
+ * stays, a hollow ring marks it. `busy` swaps the icon for a spinner.
17
+ */
18
+
19
+ import { useState } from 'react';
20
+ import { Activity, Loader2 } from 'lucide-react';
21
+ import { useTheme } from '@principal-ade/industry-theme';
22
+
23
+ export type SubsystemDiagnosticStatus = 'ok' | 'issues' | 'gaps' | 'unknown';
24
+
25
+ export interface SubsystemDiagnostic {
26
+ status: SubsystemDiagnosticStatus;
27
+ /** Count shown beside the icon (e.g. error + warn findings). Hidden when 0/undefined. */
28
+ issueCount?: number;
29
+ /** Inputs changed since the report was produced. */
30
+ stale?: boolean;
31
+ /** A verification pass is running right now. */
32
+ busy?: boolean;
33
+ /** Tooltip / a11y label override; otherwise derived from `status`. */
34
+ title?: string;
35
+ /** Click — open the report. */
36
+ onOpen?: () => void;
37
+ }
38
+
39
+ /** Resolve the status accent from the active theme. */
40
+ export function diagnosticStatusColor(
41
+ status: SubsystemDiagnosticStatus,
42
+ colors: { success: string; error: string; warning: string },
43
+ muted: string,
44
+ ): string {
45
+ if (status === 'ok') return colors.success;
46
+ if (status === 'issues') return colors.error;
47
+ if (status === 'gaps') return colors.warning;
48
+ return muted;
49
+ }
50
+
51
+ /** Human tooltip for the current diagnostic state. */
52
+ export function diagnosticTitle(diagnostic: SubsystemDiagnostic): string {
53
+ if (diagnostic.title) return diagnostic.title;
54
+ const { status, issueCount, stale, busy } = diagnostic;
55
+ let head: string;
56
+ if (status === 'ok') head = 'Diagnostics: no issues';
57
+ else if (status === 'issues')
58
+ head =
59
+ issueCount != null && issueCount > 0
60
+ ? `Diagnostics: ${issueCount} issue${issueCount === 1 ? '' : 's'}`
61
+ : 'Diagnostics: issues found';
62
+ else if (status === 'gaps')
63
+ head =
64
+ issueCount != null && issueCount > 0
65
+ ? `Diagnostics: ${issueCount} unconfirmed`
66
+ : 'Diagnostics: unconfirmed claims';
67
+ else head = 'Diagnostics: not checked yet';
68
+ if (busy) return `${head} — checking…`;
69
+ if (stale) return `${head} (stale) — open for details`;
70
+ return `${head} — open report`;
71
+ }
72
+
73
+ export interface SubsystemDiagnosticToggleProps extends SubsystemDiagnostic {
74
+ /** Icon size in px. @default 14 */
75
+ size?: number;
76
+ }
77
+
78
+ export function SubsystemDiagnosticToggle({
79
+ status,
80
+ issueCount,
81
+ stale = false,
82
+ busy = false,
83
+ size = 14,
84
+ onOpen,
85
+ ...rest
86
+ }: SubsystemDiagnosticToggleProps) {
87
+ const { theme } = useTheme();
88
+ const [hover, setHover] = useState(false);
89
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
90
+ const color = diagnosticStatusColor(status, theme.colors, muted);
91
+ const label = diagnosticTitle({ status, issueCount, stale, busy, ...rest });
92
+ const showCount = issueCount != null && issueCount > 0;
93
+
94
+ return (
95
+ <>
96
+ {busy && (
97
+ <style>{'@keyframes subsystem-diagnostic-spin{to{transform:rotate(360deg)}}'}</style>
98
+ )}
99
+ <button
100
+ type="button"
101
+ onClick={onOpen}
102
+ onMouseEnter={() => setHover(true)}
103
+ onMouseLeave={() => setHover(false)}
104
+ title={label}
105
+ aria-label={label}
106
+ aria-haspopup="dialog"
107
+ style={{
108
+ display: 'inline-flex',
109
+ alignItems: 'center',
110
+ gap: 4,
111
+ height: 22,
112
+ padding: showCount ? '0 7px 0 6px' : '0 5px',
113
+ border: 'none',
114
+ borderRadius: 4,
115
+ background: hover ? theme.colors.border : 'transparent',
116
+ color,
117
+ cursor: 'pointer',
118
+ flexShrink: 0,
119
+ transition: 'background 120ms ease, opacity 120ms ease',
120
+ opacity: stale && !busy ? 0.55 : 1,
121
+ }}
122
+ >
123
+ <span style={{ position: 'relative', display: 'inline-flex' }}>
124
+ {busy ? (
125
+ <Loader2 size={size} style={{ animation: 'subsystem-diagnostic-spin 900ms linear infinite' }} />
126
+ ) : (
127
+ <Activity size={size} />
128
+ )}
129
+ {stale && !busy && (
130
+ <span
131
+ aria-hidden
132
+ style={{
133
+ position: 'absolute',
134
+ top: -2,
135
+ right: -2,
136
+ width: 6,
137
+ height: 6,
138
+ borderRadius: '50%',
139
+ border: `1.5px solid ${color}`,
140
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
141
+ }}
142
+ />
143
+ )}
144
+ </span>
145
+ {showCount && (
146
+ <span
147
+ style={{
148
+ fontFamily: theme.fonts.monospace,
149
+ fontSize: theme.fontSizes[0],
150
+ fontWeight: 600,
151
+ lineHeight: 1,
152
+ }}
153
+ >
154
+ {issueCount}
155
+ </span>
156
+ )}
157
+ </button>
158
+ </>
159
+ );
160
+ }
@@ -0,0 +1,519 @@
1
+ /**
2
+ * SubsystemIssueList — the diagnostics sidebar list.
3
+ *
4
+ * A catalogue of verification issues (audit findings), grouped by *target* so
5
+ * every broken component / relation / module / flow clusters together. Each
6
+ * card is severity-colored, names its kind, states the message, and offers the
7
+ * deterministic fix inline when one exists.
8
+ *
9
+ * The list is presentation-only: it owns no verification logic. Hosts map their
10
+ * findings into `SubsystemIssue[]` and handle `onSelectIssue` / `onApplyFix`
11
+ * (e.g. focus the node on the graph, or apply the fix in the report).
12
+ */
13
+
14
+ import { useState } from 'react';
15
+ import {
16
+ ChevronDown,
17
+ ChevronRight,
18
+ CircleCheck,
19
+ Wrench,
20
+ } from 'lucide-react';
21
+ import { useTheme } from '@principal-ade/industry-theme';
22
+
23
+ export type SubsystemIssueSeverity = 'error' | 'warn' | 'info';
24
+
25
+ export type SubsystemIssueTargetKind =
26
+ | 'component'
27
+ | 'relation'
28
+ | 'module'
29
+ | 'walkthrough'
30
+ | 'graph';
31
+
32
+ /**
33
+ * The four verification layers, in order. Mirrors the docs' progression:
34
+ * constructs → static topology → runtime topology → walkthrough.
35
+ */
36
+ export type SubsystemIssueCategory =
37
+ | 'construct'
38
+ | 'static-topology'
39
+ | 'runtime-topology'
40
+ | 'walkthrough';
41
+
42
+ /** Layer order + display names, shared by the list and its headers. */
43
+ export const SUBSYSTEM_ISSUE_CATEGORIES: SubsystemIssueCategory[] = [
44
+ 'construct',
45
+ 'static-topology',
46
+ 'runtime-topology',
47
+ 'walkthrough',
48
+ ];
49
+
50
+ export const SUBSYSTEM_ISSUE_CATEGORY_LABEL: Record<
51
+ SubsystemIssueCategory,
52
+ string
53
+ > = {
54
+ construct: 'Constructs',
55
+ 'static-topology': 'Static topology',
56
+ 'runtime-topology': 'Runtime topology',
57
+ walkthrough: 'Walkthrough',
58
+ };
59
+
60
+ /** Default `kind` → layer. `boundary_*` splits by module (static) vs process (runtime). */
61
+ const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
62
+ missing_file: 'construct',
63
+ missing_symbol: 'construct',
64
+ stale_declaration: 'construct',
65
+ construct_mismatch: 'construct',
66
+ construct_unconfirmed: 'construct',
67
+ signature_mismatch: 'construct',
68
+ signature_unconfirmed: 'construct',
69
+ anchor: 'construct',
70
+ unresolved: 'construct',
71
+ topology_broken_endpoint: 'static-topology',
72
+ topology_import_unconfirmed: 'static-topology',
73
+ topology_relation_unconfirmed: 'static-topology',
74
+ boundary_module_without_file: 'static-topology',
75
+ boundary_module_file_mismatch: 'static-topology',
76
+ boundary_process_nest_disagree: 'runtime-topology',
77
+ walkthrough: 'walkthrough',
78
+ };
79
+
80
+ /**
81
+ * Verification layer for an issue. An explicit `category` wins; otherwise the
82
+ * audit `kind` is mapped, defaulting to `construct` (the base layer).
83
+ */
84
+ export function issueCategory(issue: SubsystemIssue): SubsystemIssueCategory {
85
+ return issue.category ?? KIND_CATEGORY[issue.kind] ?? 'construct';
86
+ }
87
+
88
+ export interface SubsystemIssueTarget {
89
+ kind: SubsystemIssueTargetKind;
90
+ /** Stable id used to group and to focus the target on the graph. */
91
+ id?: string;
92
+ /** Display label (component name, `from → to`, module path, flow title). */
93
+ label: string;
94
+ /** Optional sub-label (relation type, step number, …). */
95
+ detail?: string;
96
+ }
97
+
98
+ /** One issue to surface in the sidebar (and later, on the graph). */
99
+ export interface SubsystemIssue {
100
+ id: string;
101
+ severity: SubsystemIssueSeverity;
102
+ /** Machine kind, e.g. `missing_file`. Drives the label when `kindLabel` is absent. */
103
+ kind: string;
104
+ /** Human label override for the kind. */
105
+ kindLabel?: string;
106
+ message: string;
107
+ target?: SubsystemIssueTarget;
108
+ /** Verification layer override; defaults from `kind`. */
109
+ category?: SubsystemIssueCategory;
110
+ /** Deterministic fix the user can apply without an agent. */
111
+ fix?: { label: string };
112
+ }
113
+
114
+ const SEVERITY_RANK: Record<SubsystemIssueSeverity, number> = {
115
+ error: 0,
116
+ warn: 1,
117
+ info: 2,
118
+ };
119
+
120
+ /** `missing_file` → `Missing file`. */
121
+ export function humanizeIssueKind(kind: string): string {
122
+ const spaced = kind.replace(/[_-]+/g, ' ').trim();
123
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
124
+ }
125
+
126
+ /**
127
+ * Curated short labels, trimmed of prefixes the grouping already implies
128
+ * (`topology_`, `boundary_`) but kept unambiguous within a layer — so
129
+ * `construct_mismatch` reads "Wrong construct", not a bare "Mismatch" that
130
+ * would collide with `signature_mismatch`. Explicit `issue.kindLabel` wins.
131
+ */
132
+ const KIND_LABEL: Record<string, string> = {
133
+ missing_file: 'Missing file',
134
+ missing_symbol: 'Missing symbol',
135
+ stale_declaration: 'Stale declaration',
136
+ construct_mismatch: 'Wrong construct',
137
+ construct_unconfirmed: 'Unconfirmed construct',
138
+ signature_mismatch: 'Signature mismatch',
139
+ signature_unconfirmed: 'Signature unconfirmed',
140
+ anchor: 'Anchor',
141
+ unresolved: 'Unresolved',
142
+ topology_broken_endpoint: 'Broken endpoint',
143
+ topology_import_unconfirmed: 'Import unconfirmed',
144
+ topology_relation_unconfirmed: 'Relation unconfirmed',
145
+ boundary_module_without_file: 'Module without file',
146
+ boundary_module_file_mismatch: 'Module file mismatch',
147
+ boundary_process_nest_disagree: 'Process nest disagree',
148
+ walkthrough: 'Step issue',
149
+ };
150
+
151
+ /** Label for an issue's kind. Explicit `kindLabel` wins; unknown kinds humanize. */
152
+ export function issueKindLabel(issue: SubsystemIssue): string {
153
+ return issue.kindLabel ?? KIND_LABEL[issue.kind] ?? humanizeIssueKind(issue.kind);
154
+ }
155
+
156
+ export function severityRank(severity: SubsystemIssueSeverity): number {
157
+ return SEVERITY_RANK[severity] ?? SEVERITY_RANK.info;
158
+ }
159
+
160
+ /** Worst (most severe) severity in a list; `info` for an empty list. */
161
+ export function worstSeverity(
162
+ issues: SubsystemIssue[],
163
+ ): SubsystemIssueSeverity {
164
+ let worst: SubsystemIssueSeverity = 'info';
165
+ for (const issue of issues) {
166
+ if (severityRank(issue.severity) < severityRank(worst)) worst = issue.severity;
167
+ }
168
+ return worst;
169
+ }
170
+
171
+ export interface SubsystemIssueGroup {
172
+ key: string;
173
+ kind: SubsystemIssueTargetKind;
174
+ label: string;
175
+ detail?: string;
176
+ issues: SubsystemIssue[];
177
+ severity: SubsystemIssueSeverity;
178
+ }
179
+
180
+ function targetKey(issue: SubsystemIssue): string {
181
+ const t = issue.target;
182
+ if (!t) return 'graph';
183
+ return `${t.kind}:${t.id ?? t.label}`;
184
+ }
185
+
186
+ /**
187
+ * Group issues by their target, worst-severity first, then by label. Within a
188
+ * group, issues are ordered error → warn → info (stable for ties).
189
+ */
190
+ export function groupIssuesByTarget(
191
+ issues: SubsystemIssue[],
192
+ ): SubsystemIssueGroup[] {
193
+ const byKey = new Map<string, SubsystemIssueGroup>();
194
+ for (const issue of issues) {
195
+ const key = targetKey(issue);
196
+ let group = byKey.get(key);
197
+ if (!group) {
198
+ group = {
199
+ key,
200
+ kind: issue.target?.kind ?? 'graph',
201
+ label: issue.target?.label ?? 'Model',
202
+ detail: issue.target?.detail,
203
+ issues: [],
204
+ severity: 'info',
205
+ };
206
+ byKey.set(key, group);
207
+ }
208
+ group.issues.push(issue);
209
+ }
210
+ const groups = [...byKey.values()];
211
+ for (const group of groups) {
212
+ group.issues.sort(
213
+ (a, b) => severityRank(a.severity) - severityRank(b.severity),
214
+ );
215
+ group.severity = worstSeverity(group.issues);
216
+ }
217
+ groups.sort((a, b) => {
218
+ const bySeverity = severityRank(a.severity) - severityRank(b.severity);
219
+ if (bySeverity !== 0) return bySeverity;
220
+ return a.label.localeCompare(b.label);
221
+ });
222
+ return groups;
223
+ }
224
+
225
+ export interface SubsystemIssueCategoryGroup {
226
+ category: SubsystemIssueCategory;
227
+ label: string;
228
+ issues: SubsystemIssue[];
229
+ /** Worst severity present; `null` when the layer is clean. */
230
+ severity: SubsystemIssueSeverity | null;
231
+ count: number;
232
+ }
233
+
234
+ /**
235
+ * Group issues into the four verification layers — always all four, in order,
236
+ * so a clean layer can render its "happy" state. Issues within a layer are
237
+ * ordered error → warn → info (stable for ties).
238
+ */
239
+ export function groupIssuesByCategory(
240
+ issues: SubsystemIssue[],
241
+ ): SubsystemIssueCategoryGroup[] {
242
+ const byCategory = new Map<SubsystemIssueCategory, SubsystemIssue[]>();
243
+ for (const category of SUBSYSTEM_ISSUE_CATEGORIES) byCategory.set(category, []);
244
+ for (const issue of issues) {
245
+ byCategory.get(issueCategory(issue))?.push(issue);
246
+ }
247
+ return SUBSYSTEM_ISSUE_CATEGORIES.map((category) => {
248
+ const grouped = byCategory.get(category) ?? [];
249
+ grouped.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
250
+ return {
251
+ category,
252
+ label: SUBSYSTEM_ISSUE_CATEGORY_LABEL[category],
253
+ issues: grouped,
254
+ severity: grouped.length > 0 ? worstSeverity(grouped) : null,
255
+ count: grouped.length,
256
+ };
257
+ });
258
+ }
259
+
260
+ function severityColor(
261
+ severity: SubsystemIssueSeverity,
262
+ colors: { error: string; warning: string; info: string },
263
+ muted: string,
264
+ ): string {
265
+ if (severity === 'error') return colors.error ?? '#e5534b';
266
+ if (severity === 'warn') return colors.warning ?? '#d4a017';
267
+ return colors.info ?? muted;
268
+ }
269
+
270
+ const TARGET_KIND_LABEL: Record<SubsystemIssueTargetKind, string> = {
271
+ component: 'component',
272
+ relation: 'relation',
273
+ module: 'module',
274
+ walkthrough: 'flow',
275
+ graph: 'model',
276
+ };
277
+
278
+ export interface SubsystemIssueCardProps {
279
+ issue: SubsystemIssue;
280
+ onSelect?: (issue: SubsystemIssue) => void;
281
+ onApplyFix?: (issue: SubsystemIssue) => void;
282
+ onHover?: (issue: SubsystemIssue | null) => void;
283
+ }
284
+
285
+ export function SubsystemIssueCard({
286
+ issue,
287
+ onSelect,
288
+ onApplyFix,
289
+ onHover,
290
+ }: SubsystemIssueCardProps) {
291
+ const { theme } = useTheme();
292
+ const [hover, setHover] = useState(false);
293
+ // Every issue is collapsed to its kind by default so the list scans as a
294
+ // catalogue of problems; clicking a card reveals the message and any fix.
295
+ const [expanded, setExpanded] = useState(false);
296
+ const showDetail = expanded;
297
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
298
+ const color = severityColor(issue.severity, theme.colors, muted);
299
+ const activate = () => {
300
+ setExpanded((v) => !v);
301
+ onSelect?.(issue);
302
+ };
303
+
304
+ return (
305
+ <div
306
+ role="button"
307
+ tabIndex={0}
308
+ aria-expanded={expanded}
309
+ onClick={activate}
310
+ onKeyDown={(e) => {
311
+ if (e.key === 'Enter' || e.key === ' ') {
312
+ e.preventDefault();
313
+ activate();
314
+ }
315
+ }}
316
+ onMouseEnter={() => {
317
+ setHover(true);
318
+ onHover?.(issue);
319
+ }}
320
+ onMouseLeave={() => {
321
+ setHover(false);
322
+ onHover?.(null);
323
+ }}
324
+ style={{
325
+ display: 'flex',
326
+ flexDirection: 'column',
327
+ gap: 5,
328
+ padding: '9px 12px 10px',
329
+ borderBottom: `1px solid ${theme.colors.border}`,
330
+ background: hover ? theme.colors.background : 'transparent',
331
+ cursor: 'pointer',
332
+ transition: 'background 120ms ease',
333
+ fontFamily: theme.fonts.body,
334
+ }}
335
+ >
336
+ <div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, minWidth: 0 }}>
337
+ <div
338
+ style={{
339
+ flex: 1,
340
+ minWidth: 0,
341
+ display: 'flex',
342
+ flexDirection: 'column',
343
+ gap: 1,
344
+ }}
345
+ >
346
+ <span
347
+ style={{
348
+ fontFamily: theme.fonts.monospace,
349
+ fontSize: theme.fontSizes[0],
350
+ fontWeight: 600,
351
+ letterSpacing: 0.3,
352
+ textTransform: 'uppercase',
353
+ color,
354
+ whiteSpace: 'nowrap',
355
+ }}
356
+ >
357
+ {issueKindLabel(issue)}
358
+ </span>
359
+ {issue.target && (
360
+ <span
361
+ style={{
362
+ minWidth: 0,
363
+ fontFamily: theme.fonts.monospace,
364
+ fontSize: theme.fontSizes[0],
365
+ color: theme.colors.text,
366
+ whiteSpace: 'nowrap',
367
+ overflow: 'hidden',
368
+ textOverflow: 'ellipsis',
369
+ }}
370
+ title={`${TARGET_KIND_LABEL[issue.target.kind]} · ${issue.target.label}`}
371
+ >
372
+ {issue.target.label}
373
+ </span>
374
+ )}
375
+ </div>
376
+ <span
377
+ style={{
378
+ display: 'inline-flex',
379
+ color: muted,
380
+ flexShrink: 0,
381
+ marginTop: 1,
382
+ }}
383
+ >
384
+ {expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
385
+ </span>
386
+ </div>
387
+ {showDetail && (
388
+ <div
389
+ style={{
390
+ fontSize: theme.fontSizes[0],
391
+ color: theme.colors.text,
392
+ lineHeight: 1.45,
393
+ overflowWrap: 'anywhere',
394
+ }}
395
+ >
396
+ {issue.message}
397
+ </div>
398
+ )}
399
+ {showDetail && issue.fix && (
400
+ <div style={{ display: 'flex', marginTop: 1 }}>
401
+ <button
402
+ type="button"
403
+ onClick={(e) => {
404
+ e.stopPropagation();
405
+ onApplyFix?.(issue);
406
+ }}
407
+ disabled={onApplyFix == null}
408
+ style={{
409
+ display: 'inline-flex',
410
+ alignItems: 'center',
411
+ gap: 5,
412
+ padding: '3px 8px',
413
+ border: `1px solid ${theme.colors.primary}`,
414
+ borderRadius: 4,
415
+ background: 'transparent',
416
+ color: theme.colors.primary,
417
+ fontFamily: theme.fonts.monospace,
418
+ fontSize: theme.fontSizes[0],
419
+ cursor: onApplyFix == null ? 'default' : 'pointer',
420
+ opacity: onApplyFix == null ? 0.5 : 1,
421
+ }}
422
+ >
423
+ <Wrench size={12} />
424
+ {issue.fix.label}
425
+ </button>
426
+ </div>
427
+ )}
428
+ </div>
429
+ );
430
+ }
431
+
432
+ export interface SubsystemIssueListProps {
433
+ issues: SubsystemIssue[];
434
+ onSelectIssue?: (issue: SubsystemIssue) => void;
435
+ onApplyFix?: (issue: SubsystemIssue) => void;
436
+ onHoverIssue?: (issue: SubsystemIssue | null) => void;
437
+ }
438
+
439
+ export function SubsystemIssueList({
440
+ issues,
441
+ onSelectIssue,
442
+ onApplyFix,
443
+ onHoverIssue,
444
+ }: SubsystemIssueListProps) {
445
+ const { theme } = useTheme();
446
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
447
+ const categories = groupIssuesByCategory(issues);
448
+
449
+ return (
450
+ <div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
451
+ {categories.map((category) => {
452
+ // A layer with no findings renders its happy state: a green check in
453
+ // the header, no count, no cards.
454
+ const clean = category.count === 0;
455
+ const accent = category.severity
456
+ ? severityColor(category.severity, theme.colors, muted)
457
+ : theme.colors.success;
458
+ return (
459
+ <div key={category.category}>
460
+ <div
461
+ style={{
462
+ position: 'sticky',
463
+ top: 0,
464
+ zIndex: 1,
465
+ display: 'flex',
466
+ alignItems: 'center',
467
+ gap: 7,
468
+ padding: '7px 12px',
469
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
470
+ borderBottom: `1px solid ${theme.colors.border}`,
471
+ }}
472
+ >
473
+ <span
474
+ style={{
475
+ flex: 1,
476
+ minWidth: 0,
477
+ fontSize: theme.fontSizes[1],
478
+ fontWeight: 600,
479
+ color: clean ? muted : theme.colors.text,
480
+ fontFamily: theme.fonts.body,
481
+ whiteSpace: 'nowrap',
482
+ overflow: 'hidden',
483
+ textOverflow: 'ellipsis',
484
+ }}
485
+ >
486
+ {category.label}
487
+ </span>
488
+ {clean ? (
489
+ <CircleCheck size={13} color={accent} style={{ flexShrink: 0 }} />
490
+ ) : (
491
+ <span
492
+ style={{
493
+ fontFamily: theme.fonts.monospace,
494
+ fontSize: theme.fontSizes[0],
495
+ fontWeight: 600,
496
+ color: accent,
497
+ flexShrink: 0,
498
+ }}
499
+ >
500
+ {category.count}
501
+ </span>
502
+ )}
503
+ </div>
504
+ {!clean &&
505
+ category.issues.map((issue) => (
506
+ <SubsystemIssueCard
507
+ key={issue.id}
508
+ issue={issue}
509
+ onSelect={onSelectIssue}
510
+ onApplyFix={onApplyFix}
511
+ onHover={onHoverIssue}
512
+ />
513
+ ))}
514
+ </div>
515
+ );
516
+ })}
517
+ </div>
518
+ );
519
+ }