Skip to content

Commit 41b34ce

Browse files
fix(site/VerdictExplanation): findings list now reads as cards, not a wall
On a malicious skill the Reasons section was 8 category headings and 14 rule blocks stacked vertically, every header rendered in the same text-sm font-medium with a hairline divider, every rule body the same color. With nothing to grab onto visually the eye slid past it. Restructured the findings as a grid of category cards: - Each category is a card with a colored left strip whose tone is the maximum severity present in the category (red for high or critical, amber for medium, neutral for low or info). The previous uniform border told you nothing; now you can spot the worst categories at a glance. - Category header carries a SeverityBadges row on the right showing the count per severity present in that category, for example '5 high 2 medium'. Lets you prioritize without expanding each rule. - Every rule gets a filled severity dot, a bold mono ID, the severity word in the severity tone, and the hit count. Same data the rule_severity field carried all along, just rendered. - Rules within a category sort by severity-desc then count-desc so the worst rule of each cluster is at the top. Categories sort by max severity, then by total count. - On md+ widths the cards flow into two CSS columns with break-inside-avoid, cutting the section to roughly half its previous height without losing any text. On narrow viewports the layout collapses to one column. - When there are four or more categories, a JumpBar of compact pills appears above the cards. Each pill is the category name, the max-severity dot, and the total count, and links to the category's anchor id. Anchors are stable slugs so deep links work even after re-scans. Rule item layout is now two rows: a header row with '• PE3 HIGH · 5 hits' (severity is colored), and a body row with the description as its own paragraph. The previous single-line 'PE3 · 5 hits Code accesses credential files...' smashed the machine-readable bits up against the prose. Tested with the live DOM dumped through an HTML parser to verify the copy-paste output is clean, plus pnpm lint, lint-types, test:ci, and build. Existing Storybook fixtures already carry the severity field so the stories did not need updating.
1 parent 39e9af5 commit 41b34ce

1 file changed

Lines changed: 238 additions & 47 deletions

File tree

site/src/components/VerdictExplanation/VerdictExplanation.tsx

Lines changed: 238 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,76 @@ interface VerdictExplanationProps {
1616
className?: string;
1717
}
1818

19+
const SEVERITY_ORDER = ["critical", "high", "medium", "low", "info"] as const;
20+
type SeverityKey = (typeof SEVERITY_ORDER)[number];
21+
22+
interface RuleItem {
23+
id: string;
24+
severity: SeverityKey;
25+
count: number;
26+
description: string;
27+
}
28+
1929
interface CategoryGroup {
2030
category: string;
31+
/** Total `count` across rules in the category. */
2132
totalCount: number;
22-
rules: { id: string; severity: string; count: number; description: string }[];
33+
/** Highest severity present; drives the card's accent color. */
34+
maxSeverity: SeverityKey;
35+
/** Tally per severity, used for the right-side summary on the card. */
36+
bySeverity: Record<SeverityKey, number>;
37+
rules: RuleItem[];
38+
}
39+
40+
const SEVERITY_TONE: Record<
41+
SeverityKey,
42+
{
43+
/** Used for the colored left-side accent strip on the card. */
44+
accent: string;
45+
/** Severity word color in the rule header. */
46+
text: string;
47+
/** Filled dot before the rule ID. */
48+
dot: string;
49+
}
50+
> = {
51+
critical: {
52+
accent: "bg-verdict-malicious",
53+
text: "text-verdict-malicious",
54+
dot: "bg-verdict-malicious",
55+
},
56+
high: {
57+
accent: "bg-verdict-malicious",
58+
text: "text-verdict-malicious",
59+
dot: "bg-verdict-malicious",
60+
},
61+
medium: {
62+
accent: "bg-verdict-suspicious",
63+
text: "text-verdict-suspicious",
64+
dot: "bg-verdict-suspicious",
65+
},
66+
low: {
67+
accent: "bg-coder-neutral-500",
68+
text: "text-coder-neutral-300",
69+
dot: "bg-coder-neutral-500",
70+
},
71+
info: {
72+
accent: "bg-coder-neutral-600",
73+
text: "text-coder-neutral-400",
74+
dot: "bg-coder-neutral-600",
75+
},
76+
};
77+
78+
function severityKey(sev?: string): SeverityKey {
79+
const s = (sev ?? "info").toLowerCase();
80+
return (SEVERITY_ORDER as readonly string[]).includes(s)
81+
? (s as SeverityKey)
82+
: "info";
83+
}
84+
85+
function severityRank(s: SeverityKey): number {
86+
// Lower index = more severe. Mirror SEVERITY_ORDER so sort key is
87+
// monotonically ascending for severity-desc ordering.
88+
return SEVERITY_ORDER.indexOf(s);
2389
}
2490

2591
function groupByCategory(findings: FindingByRule[]): CategoryGroup[] {
@@ -29,23 +95,46 @@ function groupByCategory(findings: FindingByRule[]): CategoryGroup[] {
2995
const category = rule?.category || "Other";
3096
let slot = map.get(category);
3197
if (!slot) {
32-
slot = { category, totalCount: 0, rules: [] };
98+
slot = {
99+
category,
100+
totalCount: 0,
101+
maxSeverity: "info",
102+
bySeverity: { critical: 0, high: 0, medium: 0, low: 0, info: 0 },
103+
rules: [],
104+
};
33105
map.set(category, slot);
34106
}
107+
const sev = severityKey(f.severity);
35108
slot.totalCount += f.count;
109+
slot.bySeverity[sev] += f.count;
110+
if (severityRank(sev) < severityRank(slot.maxSeverity)) {
111+
slot.maxSeverity = sev;
112+
}
36113
slot.rules.push({
37114
id: f.id,
38-
severity: f.severity,
115+
severity: sev,
39116
count: f.count,
40117
description: rule?.description ?? "",
41118
});
42119
}
43-
return Array.from(map.values())
44-
.map((group) => ({
45-
...group,
46-
rules: group.rules.sort((a, b) => b.count - a.count),
47-
}))
48-
.sort((a, b) => b.totalCount - a.totalCount);
120+
for (const group of map.values()) {
121+
// Within a category, lead with the most severe rules; break ties by
122+
// hit count so a louder rule edges out a quieter one at the same
123+
// severity. Falls back to alphabetical ID for full determinism.
124+
group.rules.sort((a, b) => {
125+
const sev = severityRank(a.severity) - severityRank(b.severity);
126+
if (sev !== 0) return sev;
127+
if (a.count !== b.count) return b.count - a.count;
128+
return a.id.localeCompare(b.id);
129+
});
130+
}
131+
return Array.from(map.values()).sort((a, b) => {
132+
// Categories ordered by max severity, then by total count desc so
133+
// the worst clusters lead.
134+
const sev = severityRank(a.maxSeverity) - severityRank(b.maxSeverity);
135+
if (sev !== 0) return sev;
136+
return b.totalCount - a.totalCount;
137+
});
49138
}
50139

51140
const VERDICT_TONE: Record<
@@ -54,10 +143,6 @@ const VERDICT_TONE: Record<
54143
icon: typeof ShieldAlertIcon;
55144
chipClass: string;
56145
label: string;
57-
/** One-sentence narrative, no data restated. The Risk score panel
58-
* directly above the Reasons section already shows the score,
59-
* severity, and recommendation; this chip should interpret, not
60-
* restate. */
61146
summary: string;
62147
}
63148
> = {
@@ -107,6 +192,133 @@ function thresholdSentence(
107192
return null;
108193
}
109194

195+
function slugifyCategory(name: string): string {
196+
return `cat-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "")}`;
197+
}
198+
199+
interface SeverityBadgesProps {
200+
counts: Record<SeverityKey, number>;
201+
}
202+
203+
const SeverityBadges: FC<SeverityBadgesProps> = ({ counts }) => {
204+
const present = SEVERITY_ORDER.filter((s) => counts[s] > 0);
205+
if (present.length === 0) return null;
206+
return (
207+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[10px] uppercase tracking-wider">
208+
{present.map((sev) => (
209+
<span
210+
key={sev}
211+
className={cn("inline-flex items-center gap-1", SEVERITY_TONE[sev].text)}
212+
>
213+
<span
214+
aria-hidden
215+
className={cn("size-1.5 rounded-full", SEVERITY_TONE[sev].dot)}
216+
/>
217+
{counts[sev]} {sev}
218+
</span>
219+
))}
220+
</div>
221+
);
222+
};
223+
224+
interface JumpBarProps {
225+
categories: CategoryGroup[];
226+
}
227+
228+
const JumpBar: FC<JumpBarProps> = ({ categories }) => (
229+
<nav
230+
aria-label="Findings by category"
231+
className="-mx-1 mb-3 flex flex-wrap gap-1 text-xs"
232+
>
233+
{categories.map((cat) => (
234+
<a
235+
key={cat.category}
236+
href={`#${slugifyCategory(cat.category)}`}
237+
className="inline-flex items-center gap-1.5 rounded-full border border-coder-smoke bg-coder-cinder px-2.5 py-1 text-coder-neutral-300 transition-colors hover:border-coder-smoke-lighter hover:bg-coder-smoke/40 hover:text-coder-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coder-sky focus-visible:ring-offset-1 focus-visible:ring-offset-coder-cinder"
238+
>
239+
<span
240+
aria-hidden
241+
className={cn(
242+
"size-1.5 rounded-full",
243+
SEVERITY_TONE[cat.maxSeverity].dot,
244+
)}
245+
/>
246+
<span>{cat.category}</span>
247+
<span className="font-mono text-coder-neutral-500">
248+
{cat.totalCount}
249+
</span>
250+
</a>
251+
))}
252+
</nav>
253+
);
254+
255+
interface CategoryCardProps {
256+
group: CategoryGroup;
257+
}
258+
259+
const CategoryCard: FC<CategoryCardProps> = ({ group }) => {
260+
const accent = SEVERITY_TONE[group.maxSeverity];
261+
return (
262+
<article
263+
id={slugifyCategory(group.category)}
264+
className="mb-3 break-inside-avoid overflow-hidden rounded-md border border-coder-smoke bg-coder-cinder/60"
265+
>
266+
<header className="flex items-stretch">
267+
<span
268+
aria-hidden
269+
className={cn("w-1 shrink-0", accent.accent)}
270+
/>
271+
<div className="flex flex-1 flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-3 py-2.5">
272+
<h4 className="text-sm font-semibold text-coder-neutral-100">
273+
{group.category}
274+
</h4>
275+
<SeverityBadges counts={group.bySeverity} />
276+
</div>
277+
</header>
278+
<ul className="divide-y divide-coder-smoke/40">
279+
{group.rules.map((rule) => {
280+
const tone = SEVERITY_TONE[rule.severity];
281+
return (
282+
<li key={rule.id} className="space-y-1 px-3 py-2.5">
283+
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-xs">
284+
<span
285+
aria-hidden
286+
className={cn(
287+
"mb-px size-1.5 rounded-full self-center",
288+
tone.dot,
289+
)}
290+
/>
291+
<span className="font-mono font-medium text-coder-neutral-100">
292+
{rule.id}
293+
</span>
294+
<span
295+
className={cn(
296+
"font-mono uppercase tracking-wide",
297+
tone.text,
298+
)}
299+
>
300+
{rule.severity}
301+
</span>
302+
<span className="font-mono text-coder-neutral-500">
303+
{"\u00b7"} {rule.count}{" "}
304+
{rule.count === 1 ? "hit" : "hits"}
305+
</span>
306+
</div>
307+
<p className="text-xs leading-relaxed text-coder-neutral-300">
308+
{rule.description || (
309+
<span className="text-coder-neutral-500">
310+
(no description in the bundled rule catalogue)
311+
</span>
312+
)}
313+
</p>
314+
</li>
315+
);
316+
})}
317+
</ul>
318+
</article>
319+
);
320+
};
321+
110322
export const VerdictExplanation: FC<VerdictExplanationProps> = ({
111323
skill,
112324
// Defaults match config.yaml and scanner/verdict.py. They are also
@@ -178,42 +390,21 @@ export const VerdictExplanation: FC<VerdictExplanationProps> = ({
178390
{categories.length === 1 ? "category" : "categories"}
179391
</span>
180392
</div>
181-
<ul className="space-y-4">
393+
394+
{/* Show the jump bar once the list runs long enough that a
395+
user might want to navigate to a specific area instead of
396+
scrolling top-to-bottom. */}
397+
{categories.length >= 4 && <JumpBar categories={categories} />}
398+
399+
{/* CSS multi-column layout: at md+ widths the cards flow into
400+
two columns and `break-inside-avoid` keeps each category
401+
card together. This collapses the previous tall list to
402+
roughly half its height without losing any information. */}
403+
<div className="md:columns-2 md:gap-4">
182404
{categories.map((cat) => (
183-
<li key={cat.category}>
184-
<div className="flex items-baseline justify-between gap-3 border-b border-coder-smoke/60 pb-1">
185-
<h4 className="text-sm font-medium text-coder-neutral-100">
186-
{cat.category}
187-
</h4>
188-
<span className="font-mono text-xs tabular-nums text-coder-neutral-500">
189-
{cat.totalCount}
190-
</span>
191-
</div>
192-
<ul className="mt-2 space-y-2.5">
193-
{cat.rules.map((rule) => (
194-
<li key={rule.id} className="space-y-0.5">
195-
<div className="flex items-baseline gap-2 text-xs">
196-
<span className="font-mono font-medium text-coder-neutral-100">
197-
{rule.id}
198-
</span>
199-
<span className="font-mono text-coder-neutral-500">
200-
{"\u00b7"} {rule.count}{" "}
201-
{rule.count === 1 ? "hit" : "hits"}
202-
</span>
203-
</div>
204-
<p className="text-xs leading-relaxed text-coder-neutral-300">
205-
{rule.description || (
206-
<span className="text-coder-neutral-500">
207-
(no description in the bundled rule catalogue)
208-
</span>
209-
)}
210-
</p>
211-
</li>
212-
))}
213-
</ul>
214-
</li>
405+
<CategoryCard key={cat.category} group={cat} />
215406
))}
216-
</ul>
407+
</div>
217408
</div>
218409
)}
219410

0 commit comments

Comments
 (0)