Skip to content

Commit 10b2473

Browse files
fix(site/VerdictExplanation): balance the findings columns and tighten chrome
Three things were off in the previous layout: 1. CSS multi-column auto-balancing can't cope with break-inside-avoid cards of wildly different heights. Left column ran deep with four tall cards while the right column ended early, leaving an asymmetric block of whitespace under the right column and the Verdict trigger orphaned under the left. 2. Severity badges in the card header (5 HIGH 2 MEDIUM) read as trim, not as data, because they were just colored mono text with no shape. The eye slid past them. 3. The jump bar count numerals floated next to the category name with no separator, looking like dangling integers. Plus the rule severity dots at size-1.5 (6px) were barely perceptible. Changes: - Manual height-balanced bin-pack into two columns. estimateCardHeight sums a per-card header cost plus a per-rule cost (header row plus one estimated line per ~60 description characters). The greedy pack walks the categories in severity-first order and drops each into whichever column is currently shorter. Mobile keeps the single-column flow so the order matches the sort. On the test skill, the two columns previously diverged by hundreds of pixels; now they finish within a card-height of each other. - Severity badges in the card header are now rounded chips with a 1px coder-smoke border and a coder-cinder background. The shape makes them read as labels. Spacing inside the chip uses tabular numerals so '5 HIGH' and '12 MEDIUM' align. - Jump bar pill: name and count are separated by a 12px vertical divider (h-3 w-px bg-coder-smoke) so the count reads as a distinct field. Count uses tabular-nums for alignment. - Rule severity dot is now size-2 (8px) with shrink-0 so it doesn't squash when wrapping. Removed the negative margin and self-center hack now that the parent uses items-center. - Card header gap-y bumped to 1.5 so the badges drop cleanly under the title when the card is narrow. - Card accent strip widened from w-1 (4px) to w-1.5 (6px) for a more confident severity band. The single-column mobile path duplicates the cards DOM-side intentionally; CategoryCard is a leaf component and the duplication is cheap. The benefit is that mobile keeps the original severity-first ordering without inheriting the bin-pack split, which would interleave categories incorrectly on a narrow viewport.
1 parent 41b34ce commit 10b2473

1 file changed

Lines changed: 87 additions & 17 deletions

File tree

site/src/components/VerdictExplanation/VerdictExplanation.tsx

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,49 @@ function slugifyCategory(name: string): string {
196196
return `cat-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "")}`;
197197
}
198198

199+
/**
200+
* Estimate the rendered height of a CategoryCard so we can bin-pack
201+
* categories into balanced columns. Values are in arbitrary units; only
202+
* the relative size matters for the bin-packing. Header cost covers the
203+
* card title and severity badges row; each rule adds a header row plus
204+
* an estimated number of wrapped description lines.
205+
*/
206+
function estimateCardHeight(c: CategoryGroup): number {
207+
let h = 50;
208+
for (const r of c.rules) {
209+
h += 28; // rule header (dot, ID, severity word, count)
210+
const lines = Math.max(1, Math.ceil((r.description?.length ?? 0) / 60));
211+
h += lines * 18 + 12; // description body + bottom padding
212+
}
213+
return h;
214+
}
215+
216+
/**
217+
* Greedy bin-pack: walk categories in their sorted order and place each
218+
* into whichever column is currently shorter. The result preserves the
219+
* severity-first ordering inside each column while keeping column
220+
* heights close enough that the section does not look lopsided.
221+
*/
222+
function balanceIntoColumns(
223+
categories: CategoryGroup[],
224+
): [CategoryGroup[], CategoryGroup[]] {
225+
const left: CategoryGroup[] = [];
226+
const right: CategoryGroup[] = [];
227+
let lW = 0;
228+
let rW = 0;
229+
for (const cat of categories) {
230+
const w = estimateCardHeight(cat);
231+
if (lW <= rW) {
232+
left.push(cat);
233+
lW += w;
234+
} else {
235+
right.push(cat);
236+
rW += w;
237+
}
238+
}
239+
return [left, right];
240+
}
241+
199242
interface SeverityBadgesProps {
200243
counts: Record<SeverityKey, number>;
201244
}
@@ -204,17 +247,21 @@ const SeverityBadges: FC<SeverityBadgesProps> = ({ counts }) => {
204247
const present = SEVERITY_ORDER.filter((s) => counts[s] > 0);
205248
if (present.length === 0) return null;
206249
return (
207-
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[10px] uppercase tracking-wider">
250+
<div className="flex flex-wrap items-center gap-1.5 font-mono text-[10px] uppercase tracking-wider">
208251
{present.map((sev) => (
209252
<span
210253
key={sev}
211-
className={cn("inline-flex items-center gap-1", SEVERITY_TONE[sev].text)}
254+
className={cn(
255+
"inline-flex items-center gap-1 rounded-full border border-coder-smoke bg-coder-cinder px-1.5 py-0.5",
256+
SEVERITY_TONE[sev].text,
257+
)}
212258
>
213259
<span
214260
aria-hidden
215261
className={cn("size-1.5 rounded-full", SEVERITY_TONE[sev].dot)}
216262
/>
217-
{counts[sev]} {sev}
263+
<span className="tabular-nums">{counts[sev]}</span>
264+
<span>{sev}</span>
218265
</span>
219266
))}
220267
</div>
@@ -244,7 +291,11 @@ const JumpBar: FC<JumpBarProps> = ({ categories }) => (
244291
)}
245292
/>
246293
<span>{cat.category}</span>
247-
<span className="font-mono text-coder-neutral-500">
294+
<span
295+
aria-hidden
296+
className="h-3 w-px bg-coder-smoke"
297+
/>
298+
<span className="font-mono tabular-nums text-coder-neutral-500">
248299
{cat.totalCount}
249300
</span>
250301
</a>
@@ -261,14 +312,14 @@ const CategoryCard: FC<CategoryCardProps> = ({ group }) => {
261312
return (
262313
<article
263314
id={slugifyCategory(group.category)}
264-
className="mb-3 break-inside-avoid overflow-hidden rounded-md border border-coder-smoke bg-coder-cinder/60"
315+
className="break-inside-avoid overflow-hidden rounded-md border border-coder-smoke bg-coder-cinder/60"
265316
>
266317
<header className="flex items-stretch">
267318
<span
268319
aria-hidden
269-
className={cn("w-1 shrink-0", accent.accent)}
320+
className={cn("w-1.5 shrink-0", accent.accent)}
270321
/>
271-
<div className="flex flex-1 flex-wrap items-baseline justify-between gap-x-4 gap-y-1 px-3 py-2.5">
322+
<div className="flex flex-1 flex-wrap items-center justify-between gap-x-4 gap-y-1.5 px-3 py-2.5">
272323
<h4 className="text-sm font-semibold text-coder-neutral-100">
273324
{group.category}
274325
</h4>
@@ -280,13 +331,10 @@ const CategoryCard: FC<CategoryCardProps> = ({ group }) => {
280331
const tone = SEVERITY_TONE[rule.severity];
281332
return (
282333
<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">
334+
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
284335
<span
285336
aria-hidden
286-
className={cn(
287-
"mb-px size-1.5 rounded-full self-center",
288-
tone.dot,
289-
)}
337+
className={cn("size-2 shrink-0 rounded-full", tone.dot)}
290338
/>
291339
<span className="font-mono font-medium text-coder-neutral-100">
292340
{rule.id}
@@ -340,6 +388,13 @@ export const VerdictExplanation: FC<VerdictExplanationProps> = ({
340388
);
341389
const totalFindings = categories.reduce((sum, c) => sum + c.totalCount, 0);
342390

391+
// Bin-pack into two height-balanced columns once per category set so
392+
// the layout does not jump around as React re-renders.
393+
const [leftColumn, rightColumn] = useMemo(
394+
() => balanceIntoColumns(categories),
395+
[categories],
396+
);
397+
343398
const threshold = thresholdSentence(
344399
skill.verdict,
345400
risk,
@@ -396,15 +451,30 @@ export const VerdictExplanation: FC<VerdictExplanationProps> = ({
396451
scrolling top-to-bottom. */}
397452
{categories.length >= 4 && <JumpBar categories={categories} />}
398453

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">
454+
{/* Mobile: single column, preserves severity-first sort order. */}
455+
<div className="space-y-3 md:hidden">
404456
{categories.map((cat) => (
405457
<CategoryCard key={cat.category} group={cat} />
406458
))}
407459
</div>
460+
461+
{/* Desktop: two columns, height-balanced via a greedy bin-pack
462+
so the section does not look lopsided when categories have
463+
wildly different rule counts and description lengths. CSS
464+
multi-column auto-balancing chokes on `break-inside-avoid`
465+
cards of varying size. */}
466+
<div className="hidden gap-4 md:grid md:grid-cols-2">
467+
<div className="space-y-3">
468+
{leftColumn.map((cat) => (
469+
<CategoryCard key={cat.category} group={cat} />
470+
))}
471+
</div>
472+
<div className="space-y-3">
473+
{rightColumn.map((cat) => (
474+
<CategoryCard key={cat.category} group={cat} />
475+
))}
476+
</div>
477+
</div>
408478
</div>
409479
)}
410480

0 commit comments

Comments
 (0)