@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.
- package/dist/components/maintain-events/MaintainEventLog.d.ts +35 -0
- package/dist/components/maintain-events/MaintainEventLog.d.ts.map +1 -0
- package/dist/components/maintain-events/MaintainEventLog.js +66 -0
- package/dist/components/maintain-events/MaintainEventLog.js.map +1 -0
- package/dist/components/maintain-events/MaintainLivePanel.d.ts +15 -0
- package/dist/components/maintain-events/MaintainLivePanel.d.ts.map +1 -0
- package/dist/components/maintain-events/MaintainLivePanel.js +49 -0
- package/dist/components/maintain-events/MaintainLivePanel.js.map +1 -0
- package/dist/components/maintain-events/index.d.ts +8 -0
- package/dist/components/maintain-events/index.d.ts.map +1 -0
- package/dist/components/maintain-events/index.js +6 -0
- package/dist/components/maintain-events/index.js.map +1 -0
- package/dist/graphify/consolidated.d.ts +8 -0
- package/dist/graphify/consolidated.d.ts.map +1 -1
- package/dist/graphify/construct.d.ts +18 -0
- package/dist/graphify/construct.d.ts.map +1 -1
- package/dist/graphify/construct.js +23 -0
- package/dist/graphify/construct.js.map +1 -1
- package/dist/graphify/index.d.ts +2 -2
- package/dist/graphify/index.d.ts.map +1 -1
- package/dist/graphify/index.js +1 -1
- package/dist/graphify/index.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +49 -3
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +180 -79
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +15 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +464 -14
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/formatDeclaration.js +19 -5
- package/dist/subsystem/formatDeclaration.js.map +1 -1
- package/dist/subsystem/model.d.ts +38 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +124 -67
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/subsystem/symbolRefs.d.ts.map +1 -1
- package/dist/subsystem/symbolRefs.js +2 -0
- package/dist/subsystem/symbolRefs.js.map +1 -1
- package/package.json +3 -3
- package/src/components/maintain-events/MaintainEventLog.tsx +152 -0
- package/src/components/maintain-events/MaintainLivePanel.tsx +99 -0
- package/src/components/maintain-events/index.ts +10 -0
- package/src/graphify/consolidated.ts +8 -0
- package/src/graphify/construct.test.ts +61 -1
- package/src/graphify/construct.ts +33 -0
- package/src/graphify/index.ts +6 -1
- package/src/index.ts +9 -0
- package/src/stories/MaintainEvents/MaintainEventLog.stories.tsx +66 -0
- package/src/stories/MaintainEvents/MaintainLivePanel.stories.tsx +77 -0
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +625 -0
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +7 -1
- package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +40 -73
- package/src/stories/Subsystem/ComponentGraph/StoreValueType.stories.tsx +261 -0
- package/src/stories/Subsystem/StoreValueTypeDeclaration.stories.tsx +182 -0
- package/src/stories/data/maintain-live-events.json +554 -0
- package/src/subsystem/IssueList.focus.test.tsx +113 -0
- package/src/subsystem/IssueList.icon.test.tsx +95 -0
- package/src/subsystem/IssueList.tsx +267 -111
- package/src/subsystem/SubsystemComponentGraph.tsx +541 -13
- package/src/subsystem/formatDeclaration.test.ts +64 -0
- package/src/subsystem/formatDeclaration.ts +20 -5
- package/src/subsystem/model.ts +46 -0
- package/src/subsystem/nodes.group.test.tsx +52 -0
- package/src/subsystem/nodes.tsx +102 -2
- 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
|
-
|
|
368
|
-
|
|
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: '
|
|
404
|
-
<
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
414
|
-
|
|
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={
|
|
534
|
+
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
452
535
|
</span>
|
|
453
536
|
</div>
|
|
454
537
|
{showDetail && (
|
|
455
|
-
<div
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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).
|
|
525
|
-
//
|
|
526
|
-
//
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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.
|
|
734
|
+
? (theme.colors.backgroundTertiary ??
|
|
735
|
+
theme.colors.backgroundHover ??
|
|
585
736
|
theme.colors.backgroundSecondary ??
|
|
586
737
|
theme.colors.background)
|
|
587
|
-
: (theme.colors.
|
|
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[
|
|
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[
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
})}
|