@principal-ai/subsystems-react 0.38.0 → 0.40.0

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 (72) hide show
  1. package/dist/components/maintain-events/MaintainEventLog.d.ts +35 -0
  2. package/dist/components/maintain-events/MaintainEventLog.d.ts.map +1 -0
  3. package/dist/components/maintain-events/MaintainEventLog.js +66 -0
  4. package/dist/components/maintain-events/MaintainEventLog.js.map +1 -0
  5. package/dist/components/maintain-events/MaintainLivePanel.d.ts +15 -0
  6. package/dist/components/maintain-events/MaintainLivePanel.d.ts.map +1 -0
  7. package/dist/components/maintain-events/MaintainLivePanel.js +49 -0
  8. package/dist/components/maintain-events/MaintainLivePanel.js.map +1 -0
  9. package/dist/components/maintain-events/index.d.ts +8 -0
  10. package/dist/components/maintain-events/index.d.ts.map +1 -0
  11. package/dist/components/maintain-events/index.js +6 -0
  12. package/dist/components/maintain-events/index.js.map +1 -0
  13. package/dist/graphify/consolidated.d.ts +8 -0
  14. package/dist/graphify/consolidated.d.ts.map +1 -1
  15. package/dist/graphify/construct.d.ts +18 -0
  16. package/dist/graphify/construct.d.ts.map +1 -1
  17. package/dist/graphify/construct.js +23 -0
  18. package/dist/graphify/construct.js.map +1 -1
  19. package/dist/graphify/index.d.ts +2 -2
  20. package/dist/graphify/index.d.ts.map +1 -1
  21. package/dist/graphify/index.js +1 -1
  22. package/dist/graphify/index.js.map +1 -1
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/subsystem/IssueList.d.ts +49 -3
  28. package/dist/subsystem/IssueList.d.ts.map +1 -1
  29. package/dist/subsystem/IssueList.js +180 -79
  30. package/dist/subsystem/IssueList.js.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.d.ts +15 -1
  32. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  33. package/dist/subsystem/SubsystemComponentGraph.js +464 -14
  34. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  35. package/dist/subsystem/formatDeclaration.js +19 -5
  36. package/dist/subsystem/formatDeclaration.js.map +1 -1
  37. package/dist/subsystem/model.d.ts +38 -0
  38. package/dist/subsystem/model.d.ts.map +1 -1
  39. package/dist/subsystem/model.js.map +1 -1
  40. package/dist/subsystem/nodes.d.ts.map +1 -1
  41. package/dist/subsystem/nodes.js +124 -67
  42. package/dist/subsystem/nodes.js.map +1 -1
  43. package/dist/subsystem/symbolRefs.d.ts.map +1 -1
  44. package/dist/subsystem/symbolRefs.js +2 -0
  45. package/dist/subsystem/symbolRefs.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/components/maintain-events/MaintainEventLog.tsx +152 -0
  48. package/src/components/maintain-events/MaintainLivePanel.tsx +99 -0
  49. package/src/components/maintain-events/index.ts +10 -0
  50. package/src/graphify/consolidated.ts +8 -0
  51. package/src/graphify/construct.test.ts +61 -1
  52. package/src/graphify/construct.ts +33 -0
  53. package/src/graphify/index.ts +6 -1
  54. package/src/index.ts +9 -0
  55. package/src/stories/MaintainEvents/MaintainEventLog.stories.tsx +66 -0
  56. package/src/stories/MaintainEvents/MaintainLivePanel.stories.tsx +77 -0
  57. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +625 -0
  58. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +7 -1
  59. package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +40 -73
  60. package/src/stories/Subsystem/ComponentGraph/StoreValueType.stories.tsx +261 -0
  61. package/src/stories/Subsystem/StoreValueTypeDeclaration.stories.tsx +182 -0
  62. package/src/stories/data/maintain-live-events.json +554 -0
  63. package/src/subsystem/IssueList.focus.test.tsx +113 -0
  64. package/src/subsystem/IssueList.icon.test.tsx +95 -0
  65. package/src/subsystem/IssueList.tsx +267 -111
  66. package/src/subsystem/SubsystemComponentGraph.tsx +541 -13
  67. package/src/subsystem/formatDeclaration.test.ts +64 -0
  68. package/src/subsystem/formatDeclaration.ts +20 -5
  69. package/src/subsystem/model.ts +46 -0
  70. package/src/subsystem/nodes.group.test.tsx +52 -0
  71. package/src/subsystem/nodes.tsx +102 -2
  72. package/src/subsystem/symbolRefs.ts +2 -0
@@ -0,0 +1,95 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import { createElement } from 'react';
3
+ import { Window } from 'happy-dom';
4
+ import { cleanup, render } from '@testing-library/react/pure';
5
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
6
+ import { SubsystemIssueCard, type SubsystemIssue } from './IssueList';
7
+
8
+ // Bun's happy-dom test environment isn't registered (no @happy-dom/global-
9
+ // registrator), so wire the DOM primitives React DOM needs before rendering.
10
+ const dom = new Window();
11
+ const globals = globalThis as unknown as Record<string, unknown>;
12
+ globals.window = dom;
13
+ globals.document = dom.document;
14
+ globals.navigator = dom.navigator;
15
+ globals.HTMLElement = dom.HTMLElement;
16
+ globals.Node = dom.Node;
17
+ globals.Element = dom.Element;
18
+ globals.Event = dom.Event;
19
+ globals.getComputedStyle = dom.getComputedStyle.bind(dom);
20
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
21
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
22
+ globals.localStorage = dom.localStorage;
23
+ globals.matchMedia = dom.matchMedia.bind(dom);
24
+
25
+ afterEach(cleanup);
26
+
27
+ const issue: SubsystemIssue = {
28
+ id: 'i1',
29
+ severity: 'info',
30
+ kind: 'walkthrough',
31
+ message: 'Step 2 references a line that moved.',
32
+ target: {
33
+ kind: 'step',
34
+ id: 'flow-1',
35
+ label: 'Checkout',
36
+ detail: 'step 2',
37
+ stepIndex: 1,
38
+ },
39
+ };
40
+
41
+ function renderCard(overrides: Partial<SubsystemIssue> = {}) {
42
+ return render(
43
+ createElement(
44
+ ThemeProvider,
45
+ { theme: defaultEditorTheme },
46
+ createElement(SubsystemIssueCard as never, {
47
+ issue: { ...issue, ...overrides },
48
+ } as never),
49
+ ),
50
+ );
51
+ }
52
+
53
+ // lucide emits "lucide lucide-<name>", so match on substring rather than the
54
+ // whole class string.
55
+ const hasIcon = (container: HTMLElement, name: string) =>
56
+ [...container.querySelectorAll('svg')].some((s) =>
57
+ (s.getAttribute('class') ?? '').includes(`lucide-${name}`),
58
+ );
59
+
60
+ describe('issue card icon', () => {
61
+ test('a step finding wears the step icon, not the flow lane icon', () => {
62
+ // The audit kind is `walkthrough` (the whole flow), so without a
63
+ // target-shape icon this card would read as a comment on the flow rather
64
+ // than on the one step that is wrong.
65
+ const { container } = renderCard();
66
+ expect(hasIcon(container, 'footprints')).toBe(true);
67
+ expect(hasIcon(container, 'route')).toBe(false);
68
+ });
69
+
70
+ test('a finding about the flow as a whole keeps the lane icon', () => {
71
+ const { container } = renderCard({
72
+ target: { kind: 'walkthrough', id: 'flow-1', label: 'Checkout' },
73
+ });
74
+ expect(hasIcon(container, 'route')).toBe(true);
75
+ expect(hasIcon(container, 'footprints')).toBe(false);
76
+ });
77
+
78
+ test('an explicit kind icon outranks the target-shape icon', () => {
79
+ // A boundary finding about a module is about the region, and says so.
80
+ const { container } = renderCard({
81
+ kind: 'boundary_process_nest_disagree',
82
+ target: { kind: 'step', id: 'flow-1', label: 'Checkout', stepIndex: 0 },
83
+ });
84
+ expect(hasIcon(container, 'split')).toBe(true);
85
+ expect(hasIcon(container, 'footprints')).toBe(false);
86
+ });
87
+
88
+ test('a construct rung still outranks both', () => {
89
+ const { container } = renderCard({
90
+ kind: 'missing_file',
91
+ target: { kind: 'step', id: 'flow-1', label: 'Checkout', stepIndex: 0 },
92
+ });
93
+ expect(hasIcon(container, 'file-x')).toBe(true);
94
+ });
95
+ });
@@ -11,20 +11,28 @@
11
11
  * (e.g. focus the node on the graph, or apply the fix in the report).
12
12
  */
13
13
 
14
- import { useEffect, useState } from 'react';
14
+ import { useEffect, useRef, useState } from 'react';
15
15
  import type { LucideIcon } from 'lucide-react';
16
16
  import {
17
17
  ChevronDown,
18
18
  ChevronRight,
19
19
  CircleCheck,
20
20
  Component,
21
+ FileX,
21
22
  FolderGit2,
23
+ Footprints,
24
+ MapPin,
22
25
  Network,
23
26
  Route,
27
+ Search,
24
28
  Server,
29
+ Shapes,
30
+ Sigma,
31
+ Split,
25
32
  Wrench,
26
33
  } from 'lucide-react';
27
34
  import { useTheme } from '@principal-ade/industry-theme';
35
+ import type { SubsystemIssueRung } from './model';
28
36
 
29
37
  export type SubsystemIssueSeverity = 'error' | 'info';
30
38
 
@@ -33,6 +41,7 @@ export type SubsystemIssueTargetKind =
33
41
  | 'relation'
34
42
  | 'module'
35
43
  | 'walkthrough'
44
+ | 'step'
36
45
  | 'repo'
37
46
  | 'graph';
38
47
 
@@ -109,6 +118,71 @@ export function issueCategory(issue: SubsystemIssue): SubsystemIssueCategory {
109
118
  return issue.category ?? KIND_CATEGORY[issue.kind] ?? 'construct';
110
119
  }
111
120
 
121
+ /**
122
+ * Construct-layer verification rung for a finding kind (file → symbol →
123
+ * declaration → type → signature), or null for findings outside that ladder
124
+ * (topology / walkthrough / repo). Drives the node overlay's earliest-rung chip.
125
+ */
126
+ const KIND_RUNG: Record<string, SubsystemIssueRung> = {
127
+ missing_file: 'file',
128
+ symbol_unmatched: 'symbol',
129
+ symbol_ambiguous: 'symbol',
130
+ stale_declaration: 'declaration',
131
+ construct_unconfirmed: 'type',
132
+ construct_mismatch: 'type',
133
+ signature_unconfirmed: 'signature',
134
+ signature_mismatch: 'signature',
135
+ };
136
+
137
+ /** The rung a finding fails at, or null when it is not a construct finding. */
138
+ export function issueRung(kind: string): SubsystemIssueRung | null {
139
+ return KIND_RUNG[kind] ?? null;
140
+ }
141
+
142
+ /** Order of the verification rungs; smaller = earlier (shallower) failure. */
143
+ export const ISSUE_RUNG_ORDER: Record<SubsystemIssueRung, number> = {
144
+ file: 0,
145
+ symbol: 1,
146
+ declaration: 2,
147
+ type: 3,
148
+ signature: 4,
149
+ };
150
+
151
+ /** Rung icon shared by the node overlay chip and the issue list. */
152
+ export const ISSUE_RUNG_ICON: Record<SubsystemIssueRung, LucideIcon> = {
153
+ file: FileX,
154
+ symbol: Search,
155
+ declaration: MapPin,
156
+ type: Shapes,
157
+ signature: Sigma,
158
+ };
159
+
160
+ /**
161
+ * Icons for findings that are about a BOUNDARY rather than a construct, so they
162
+ * have no rung to key off. These are what earns a frame badge: a kind listed
163
+ * here is badged on the region node it names (see `ISSUE_KIND_ICON` consumers),
164
+ * because the finding is a property of the region's shape — no single member
165
+ * construct is at fault.
166
+ */
167
+ export const ISSUE_KIND_ICON: Record<string, LucideIcon> = {
168
+ // One module, more than one claimed process parent — a containment that can't
169
+ // be drawn, which is why the region sits at the root instead of nested.
170
+ boundary_process_nest_disagree: Split,
171
+ };
172
+
173
+ /**
174
+ * Icons for target SHAPES that are more specific than their verification lane.
175
+ * An audit `kind` of `walkthrough` covers the whole flow, so a finding about
176
+ * one step of it would otherwise wear the lane's `Route` icon and read as a
177
+ * comment on the flow rather than on the step that is actually wrong.
178
+ */
179
+ export const ISSUE_TARGET_KIND_ICON: Partial<
180
+ Record<SubsystemIssueTargetKind, LucideIcon>
181
+ > = {
182
+ // A step in a flow: a footprint along the path, distinct from the path itself.
183
+ step: Footprints,
184
+ };
185
+
112
186
  export interface SubsystemIssueTarget {
113
187
  kind: SubsystemIssueTargetKind;
114
188
  /** Stable id used to group and to focus the target on the graph. */
@@ -117,6 +191,13 @@ export interface SubsystemIssueTarget {
117
191
  label: string;
118
192
  /** Optional sub-label (relation type, step number, …). */
119
193
  detail?: string;
194
+ /**
195
+ * For a `step` target: the walkthrough id, plus a 0-based index into that
196
+ * walkthrough's steps. Carried structurally rather than parsed back out of
197
+ * `detail` ("step 7") so focusing a step is exact rather than best-effort —
198
+ * `detail` is a display string and is free to change its phrasing.
199
+ */
200
+ stepIndex?: number;
120
201
  }
121
202
 
122
203
  /** One issue to surface in the sidebar (and later, on the graph). */
@@ -324,6 +405,14 @@ export function groupIssuesByCategory(
324
405
  });
325
406
  }
326
407
 
408
+ /** `#rrggbb` → `rgba(...)`; used for hover washes on translucent surfaces. */
409
+ function withAlpha(hex: string, alpha: number): string {
410
+ const h = hex.replace('#', '');
411
+ const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
412
+ const n = parseInt(full.slice(0, 6), 16);
413
+ return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
414
+ }
415
+
327
416
  function severityColor(
328
417
  severity: SubsystemIssueSeverity,
329
418
  colors: { error: string; info: string; warning: string },
@@ -333,39 +422,58 @@ function severityColor(
333
422
  return colors.warning ?? '#d4a017';
334
423
  }
335
424
 
336
- const TARGET_KIND_LABEL: Record<SubsystemIssueTargetKind, string> = {
337
- component: 'component',
338
- relation: 'relationship',
339
- module: 'module',
340
- walkthrough: 'flow',
341
- repo: 'repo',
342
- graph: 'model',
343
- };
344
-
345
425
  export interface SubsystemIssueCardProps {
346
426
  issue: SubsystemIssue;
347
427
  onSelect?: (issue: SubsystemIssue) => void;
428
+ /** The card was collapsed again — undo whatever `onSelect` focused. */
429
+ onDeselect?: (issue: SubsystemIssue) => void;
348
430
  onApplyFix?: (issue: SubsystemIssue) => void;
349
431
  onHover?: (issue: SubsystemIssue | null) => void;
432
+ /**
433
+ * This card expanded / collapsed, for any reason. The list needs it because a
434
+ * collapsed CATEGORY unmounts its cards outright, so a card can stop being
435
+ * expanded without ever toggling itself — and a host that focused something
436
+ * on expand has to hear about it.
437
+ */
438
+ onExpandedChange?: (issue: SubsystemIssue, expanded: boolean) => void;
350
439
  }
351
440
 
352
441
  export function SubsystemIssueCard({
353
442
  issue,
354
443
  onSelect,
444
+ onDeselect,
355
445
  onApplyFix,
356
446
  onHover,
447
+ onExpandedChange,
357
448
  }: SubsystemIssueCardProps) {
358
449
  const { theme } = useTheme();
359
450
  const [hover, setHover] = useState(false);
451
+ const [fixHover, setFixHover] = useState(false);
360
452
  // Every issue is collapsed to its kind by default so the list scans as a
361
453
  // catalogue of problems; clicking a card reveals the message and any fix.
362
454
  const [expanded, setExpanded] = useState(false);
363
455
  const showDetail = expanded;
364
456
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
365
457
  const color = severityColor(issue.severity, theme.colors);
458
+ // Leading icon: the verification rung when the finding maps to one, else the
459
+ // layer's icon — every row reads with an icon.
460
+ const rung = issueRung(issue.kind);
461
+ // Rung icons are the construct ladder; a boundary finding names its own icon
462
+ // (it has no rung); everything else falls back to its layer's icon.
463
+ const targetIcon = issue.target?.kind
464
+ ? ISSUE_TARGET_KIND_ICON[issue.target.kind]
465
+ : undefined;
466
+ const RowIcon: LucideIcon = rung
467
+ ? ISSUE_RUNG_ICON[rung]
468
+ : (ISSUE_KIND_ICON[issue.kind] ??
469
+ targetIcon ??
470
+ SUBSYSTEM_ISSUE_CATEGORY_ICON[issueCategory(issue)]);
366
471
  const activate = () => {
367
- setExpanded((v) => !v);
368
- onSelect?.(issue);
472
+ const next = !expanded;
473
+ setExpanded(next);
474
+ onExpandedChange?.(issue, next);
475
+ if (next) onSelect?.(issue);
476
+ else onDeselect?.(issue);
369
477
  };
370
478
 
371
479
  return (
@@ -400,96 +508,99 @@ export function SubsystemIssueCard({
400
508
  fontFamily: theme.fonts.body,
401
509
  }}
402
510
  >
403
- <div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, minWidth: 0 }}>
404
- <div
511
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
512
+ <RowIcon size={15} color={color} style={{ flexShrink: 0 }} aria-hidden />
513
+ <span
405
514
  style={{
406
515
  flex: 1,
407
516
  minWidth: 0,
408
- display: 'flex',
409
- flexDirection: 'column',
410
- gap: 1,
517
+ fontFamily: theme.fonts.monospace,
518
+ fontSize: theme.fontSizes[1],
519
+ color: theme.colors.text,
520
+ whiteSpace: 'nowrap',
521
+ overflow: 'hidden',
522
+ textOverflow: 'ellipsis',
411
523
  }}
412
524
  >
413
- <span
414
- style={{
415
- fontFamily: theme.fonts.monospace,
416
- fontSize: theme.fontSizes[0],
417
- fontWeight: 600,
418
- letterSpacing: 0.3,
419
- textTransform: 'uppercase',
420
- color,
421
- whiteSpace: 'nowrap',
422
- }}
423
- >
424
- {issueKindLabel(issue)}
425
- </span>
426
- {issue.target && (
427
- <span
428
- style={{
429
- minWidth: 0,
430
- fontFamily: theme.fonts.monospace,
431
- fontSize: theme.fontSizes[0],
432
- color: theme.colors.text,
433
- whiteSpace: 'nowrap',
434
- overflow: 'hidden',
435
- textOverflow: 'ellipsis',
436
- }}
437
- title={`${TARGET_KIND_LABEL[issue.target.kind]} · ${issue.target.label}`}
438
- >
439
- {issue.target.label}
440
- </span>
441
- )}
442
- </div>
525
+ {issue.target?.label ?? issueKindLabel(issue)}
526
+ </span>
443
527
  <span
444
528
  style={{
445
529
  display: 'inline-flex',
446
530
  color: muted,
447
531
  flexShrink: 0,
448
- marginTop: 1,
449
532
  }}
450
533
  >
451
- {expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
534
+ {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
452
535
  </span>
453
536
  </div>
454
537
  {showDetail && (
455
- <div
456
- style={{
457
- fontSize: theme.fontSizes[0],
458
- color: theme.colors.text,
459
- lineHeight: 1.45,
460
- overflowWrap: 'anywhere',
461
- }}
462
- >
463
- {issue.message}
464
- </div>
465
- )}
466
- {showDetail && issue.fix && (
467
- <div style={{ display: 'flex', marginTop: 1 }}>
468
- <button
469
- type="button"
470
- onClick={(e) => {
471
- e.stopPropagation();
472
- onApplyFix?.(issue);
473
- }}
474
- disabled={onApplyFix == null}
538
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 5, paddingTop: 4 }}>
539
+ {issue.target && (
540
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
541
+ <span
542
+ style={{
543
+ minWidth: 0,
544
+ fontFamily: theme.fonts.monospace,
545
+ fontSize: theme.fontSizes[1],
546
+ fontWeight: 600,
547
+ letterSpacing: 0.3,
548
+ textTransform: 'uppercase',
549
+ color,
550
+ whiteSpace: 'nowrap',
551
+ overflow: 'hidden',
552
+ textOverflow: 'ellipsis',
553
+ }}
554
+ >
555
+ {issueKindLabel(issue)}
556
+ </span>
557
+ </div>
558
+ )}
559
+ <div
475
560
  style={{
476
- display: 'inline-flex',
477
- alignItems: 'center',
478
- gap: 5,
479
- padding: '3px 8px',
480
- border: `1px solid ${theme.colors.primary}`,
481
- borderRadius: 4,
482
- background: 'transparent',
483
- color: theme.colors.primary,
484
- fontFamily: theme.fonts.monospace,
485
- fontSize: theme.fontSizes[0],
486
- cursor: onApplyFix == null ? 'default' : 'pointer',
487
- opacity: onApplyFix == null ? 0.5 : 1,
561
+ fontSize: theme.fontSizes[1],
562
+ color: theme.colors.text,
563
+ lineHeight: 1.45,
564
+ overflowWrap: 'anywhere',
488
565
  }}
489
566
  >
490
- <Wrench size={12} />
491
- {issue.fix.label}
492
- </button>
567
+ {issue.message}
568
+ </div>
569
+ {issue.fix && (
570
+ <div style={{ display: 'flex' }}>
571
+ <button
572
+ type="button"
573
+ onClick={(e) => {
574
+ e.stopPropagation();
575
+ onApplyFix?.(issue);
576
+ }}
577
+ disabled={onApplyFix == null}
578
+ onMouseEnter={() => setFixHover(true)}
579
+ onMouseLeave={() => setFixHover(false)}
580
+ style={{
581
+ display: 'inline-flex',
582
+ alignItems: 'center',
583
+ gap: 5,
584
+ padding: '3px 8px',
585
+ border: `1px solid ${theme.colors.success ?? '#2da44e'}`,
586
+ borderRadius: 4,
587
+ background:
588
+ onApplyFix != null && fixHover
589
+ ? withAlpha(theme.colors.success ?? '#2da44e', 0.16)
590
+ : 'transparent',
591
+ color: theme.colors.success ?? '#2da44e',
592
+ fontFamily: theme.fonts.monospace,
593
+ fontSize: theme.fontSizes[1],
594
+ cursor: onApplyFix == null ? 'default' : 'pointer',
595
+ opacity: onApplyFix == null ? 0.5 : 1,
596
+ transition: 'background 120ms ease',
597
+ }}
598
+ >
599
+ <Wrench size={12} />
600
+ {issue.fix.label}
601
+ </button>
602
+ </div>
603
+ )}
493
604
  </div>
494
605
  )}
495
606
  </div>
@@ -499,6 +610,8 @@ export function SubsystemIssueCard({
499
610
  export interface SubsystemIssueListProps {
500
611
  issues: SubsystemIssue[];
501
612
  onSelectIssue?: (issue: SubsystemIssue) => void;
613
+ /** A card was collapsed again — the counterpart to `onSelectIssue`. */
614
+ onDeselectIssue?: (issue: SubsystemIssue) => void;
502
615
  onApplyFix?: (issue: SubsystemIssue) => void;
503
616
  onHoverIssue?: (issue: SubsystemIssue | null) => void;
504
617
  /**
@@ -509,28 +622,38 @@ export interface SubsystemIssueListProps {
509
622
  * honoured without an extra click.
510
623
  */
511
624
  focusCategory?: SubsystemIssueCategory;
625
+ /**
626
+ * Fires whenever the expanded-layer set changes. Publishers use it to focus
627
+ * the canvas on the layer's findings — dimming everything the expanded
628
+ * layers do not implicate. Emits [] when every layer is collapsed.
629
+ */
630
+ onExpandedCategoriesChange?: (categories: SubsystemIssueCategory[]) => void;
512
631
  }
513
632
 
514
633
  export function SubsystemIssueList({
515
634
  issues,
516
635
  onSelectIssue,
636
+ onDeselectIssue,
517
637
  onApplyFix,
518
638
  onHoverIssue,
519
639
  focusCategory,
640
+ onExpandedCategoriesChange,
520
641
  }: SubsystemIssueListProps) {
521
642
  const { theme } = useTheme();
522
643
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
523
644
  const categories = groupIssuesByCategory(issues);
524
- // Collapsed categories (by id). Everything starts expanded, unless the list
525
- // was opened focused on a single layer — then every other layer starts
526
- // collapsed so the focused category's findings are what you land on.
645
+ // Collapsed categories (by id). Every layer starts collapsed, so the list
646
+ // opens as a scannable summary of layers + counts; a layer opens on click.
647
+ // When the list was opened focused on a single layer, that one starts
648
+ // expanded and all others stay collapsed, so the caller's intent — "show me
649
+ // this layer's findings" — is honoured without an extra click.
527
650
  const [collapsed, setCollapsed] = useState<Set<SubsystemIssueCategory>>(
528
651
  () =>
529
652
  focusCategory
530
653
  ? new Set(
531
654
  SUBSYSTEM_ISSUE_CATEGORIES.filter((c) => c !== focusCategory),
532
655
  )
533
- : new Set(),
656
+ : new Set(SUBSYSTEM_ISSUE_CATEGORIES),
534
657
  );
535
658
  const [hovered, setHovered] = useState<SubsystemIssueCategory | null>(null);
536
659
  // Re-apply the focus when it changes on an already-mounted list (e.g. the
@@ -539,16 +662,43 @@ export function SubsystemIssueList({
539
662
  setCollapsed(
540
663
  focusCategory
541
664
  ? new Set(SUBSYSTEM_ISSUE_CATEGORIES.filter((c) => c !== focusCategory))
542
- : new Set(),
665
+ : new Set(SUBSYSTEM_ISSUE_CATEGORIES),
543
666
  );
544
667
  }, [focusCategory]);
668
+ // Publish the expanded-layer set so a host can focus the canvas on it. Read
669
+ // the callback through a ref so an unstable host closure can't re-trigger the
670
+ // effect (and loop) on every render.
671
+ const onExpandedRef = useRef(onExpandedCategoriesChange);
672
+ onExpandedRef.current = onExpandedCategoriesChange;
673
+ useEffect(() => {
674
+ onExpandedRef.current?.(
675
+ SUBSYSTEM_ISSUE_CATEGORIES.filter((c) => !collapsed.has(c)),
676
+ );
677
+ }, [collapsed]);
678
+ // The card currently expanded, if any. Tracked here (not just inside the card)
679
+ // because closing a CATEGORY unmounts its cards, so a card can stop being
680
+ // expanded without toggling itself — and the host that focused a target on
681
+ // expand must be told to let it go.
682
+ const expandedIssueRef = useRef<SubsystemIssue | null>(null);
683
+ const trackExpanded = (issue: SubsystemIssue, expanded: boolean) => {
684
+ expandedIssueRef.current = expanded ? issue : null;
685
+ };
545
686
  const toggleCollapsed = (category: SubsystemIssueCategory) => {
546
- setCollapsed((prev) => {
547
- const next = new Set(prev);
548
- if (next.has(category)) next.delete(category);
549
- else next.add(category);
550
- return next;
551
- });
687
+ // Accordion: at most one layer open. Opening one collapses the rest;
688
+ // clicking the open one collapses all. That keeps the canvas dim driven by
689
+ // exactly one layer at a time.
690
+ const next = collapsed.has(category)
691
+ ? new Set(SUBSYSTEM_ISSUE_CATEGORIES.filter((c) => c !== category))
692
+ : new Set(SUBSYSTEM_ISSUE_CATEGORIES);
693
+ // Retract the focus before this layer's cards unmount. Test the NEXT set,
694
+ // not the current one: opening a layer closes every other, so a focus
695
+ // established in a sibling layer dies on this click too.
696
+ const open = expandedIssueRef.current;
697
+ if (open && next.has(issueCategory(open))) {
698
+ expandedIssueRef.current = null;
699
+ onDeselectIssue?.(open);
700
+ }
701
+ setCollapsed(next);
552
702
  };
553
703
 
554
704
  return (
@@ -581,11 +731,11 @@ export function SubsystemIssueList({
581
731
  gap: 8,
582
732
  padding: '9px 12px',
583
733
  background: isHovered
584
- ? (theme.colors.backgroundHover ??
734
+ ? (theme.colors.backgroundTertiary ??
735
+ theme.colors.backgroundHover ??
585
736
  theme.colors.backgroundSecondary ??
586
737
  theme.colors.background)
587
- : (theme.colors.backgroundTertiary ??
588
- theme.colors.backgroundSecondary ??
738
+ : (theme.colors.backgroundSecondary ??
589
739
  theme.colors.background),
590
740
  border: 'none',
591
741
  borderTop: `1px solid ${theme.colors.border}`,
@@ -613,7 +763,7 @@ export function SubsystemIssueList({
613
763
  style={{
614
764
  flex: 1,
615
765
  minWidth: 0,
616
- fontSize: theme.fontSizes[1],
766
+ fontSize: theme.fontSizes[2],
617
767
  fontWeight: 700,
618
768
  letterSpacing: 0.2,
619
769
  color: clean ? muted : theme.colors.text,
@@ -630,7 +780,7 @@ export function SubsystemIssueList({
630
780
  <span
631
781
  style={{
632
782
  fontFamily: theme.fonts.monospace,
633
- fontSize: theme.fontSizes[0],
783
+ fontSize: theme.fontSizes[1],
634
784
  fontWeight: 600,
635
785
  color: muted,
636
786
  flexShrink: 0,
@@ -643,17 +793,23 @@ export function SubsystemIssueList({
643
793
  {isCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
644
794
  </span>
645
795
  </button>
646
- {!clean &&
647
- !isCollapsed &&
648
- category.issues.map((issue) => (
649
- <SubsystemIssueCard
650
- key={issue.id}
651
- issue={issue}
652
- onSelect={onSelectIssue}
653
- onApplyFix={onApplyFix}
654
- onHover={onHoverIssue}
655
- />
656
- ))}
796
+ {!clean && !isCollapsed && (
797
+ // Hang the cards off the header: an inset indent so the body
798
+ // reads as belonging to the layer above.
799
+ <div style={{ marginLeft: 11 }}>
800
+ {category.issues.map((issue) => (
801
+ <SubsystemIssueCard
802
+ key={issue.id}
803
+ issue={issue}
804
+ onSelect={onSelectIssue}
805
+ onDeselect={onDeselectIssue}
806
+ onExpandedChange={trackExpanded}
807
+ onApplyFix={onApplyFix}
808
+ onHover={onHoverIssue}
809
+ />
810
+ ))}
811
+ </div>
812
+ )}
657
813
  </div>
658
814
  );
659
815
  })}