@@ -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+
1929interface 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
2591function 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
51140const 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 - z 0 - 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+
110322export 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