How to make dense evaluation output readable fast — the 3-second rule, progressive disclosure architecture, scan-first layout, expandable evidence panels, and concrete before/after redesign for Bouts result pages.
Installation
Installer avec Codex ou Claude Copiez ce prompt, collez-le dans Codex, Claude ou un autre assistant, puis laissez-le vérifier la page du skill et l'installer pour vous.
How to make dense evaluation output readable fast — the 3-second rule, progressive disclosure architecture, scan-first layout, expandable evidence panels, and concrete before/after redesign for Bouts result pages.
Expert Information Hierarchy
Review Checklist
The verdict badge and top score number are visible without scrolling on every viewport — test at 375px, 768px, 1280px; if they're below the fold at 375px, the hierarchy is broken
The page communicates pass/fail or rank in under 3 seconds — have someone unfamiliar look for 3 seconds and ask what they learned
Evidence panels are collapsed by default — no evidence is surfaced inline unless it's the single most important signal
Lane ordering is intentional: worst-performing lane shown first, or highest-weight lane first — never alphabetical or insertion order
Progressive disclosure has exactly 3 tiers: summary → detail → evidence; a 4th tier means you've split detail wrong
Expandable panels are keyboard-navigable — Tab to reach, Enter/Space to expand, Escape to collapse; test with keyboard only
The page does not show "null" or empty boxes for pending data — pending lanes show "scoring" state, not blank
On mobile, the scan-first summary fills the screen above fold — the detail section starts at or below the fold on 375px
Lane priority logic is extractable — a function sortLanes(lanes, strategy) exists and is testable in isolation
No content repeats between tiers — if the score is in the summary, it's not restated identically in the detail section
Expand/collapse state is persisted during the session — if a user opens a panel and scrolls, it stays open on scroll back
Typography establishes hierarchy without color alone — size + weight carry hierarchy; color reinforces but doesn't define it
The 3-Second Rule: Designing for Instant Signal
The 3-second rule is not a guideline — it's a test. Sit a user in front of your result page, give them 3 seconds, then cover the screen and ask: "What was the outcome? What was the score? Did they win or lose?"
If they can't answer all three, your hierarchy is broken.
What must be visible in 3 seconds on every viewport:
Agent name (who is this result for?)
Overall verdict: pass/fail, win/lose, or numeric rank
Top-line score or primary metric
Whether scoring is final or still in progress
What must NOT be visible in 3 seconds:
Evidence quotes
Individual judge breakdowns
Lane-level scores (these belong in tier 2)
Methodology explanations
The scan-first layout puts #1–4 in the first 80px of vertical space.
Lane Priority Logic and Expandable Evidence Panels
The order lanes appear in the breakdown is a product decision, not a display accident. "Worst first" surfaces the most actionable feedback. "Highest weight first" surfaces what mattered most. "Custom order" lets the bout designer control the narrative.
// lib/lane-sort.tsimporttype { NormalizedSubmissionResult, NormalizedLaneScore } from'@/types/results';
exporttypeLaneSortStrategy = 'worst-first' | 'best-first' | 'weight-desc' | 'weight-asc' | 'alpha';
/**
* Get the aggregate score for a lane across all complete judges.
* Returns null if no judges have scored this lane yet.
*/functiongetLaneAggregateScore(laneId: string,
result: NormalizedSubmissionResult): number | null {
constscores: number[] = [];
for (const judge of result.judgeResults) {
if (!judge.isComplete) continue;
const laneScore = judge.laneScores.find(ls => ls.laneId === laneId);
if (laneScore?.isScored) {
scores.push(laneScore.score!);
}
}
if (scores.length === 0) returnnull;
return scores.reduce((sum, s) => sum + s, 0) / scores.length;
}
/**
* Get all unique lanes from judge results, with aggregate scores.
*/exportinterfaceLaneSummary {
laneId: string;
laneName: string;
aggregateScore: number | null;
maxScore: number;
weight: number;
percentScore: number | null;
}
exportfunctiongetLaneSummaries(result: NormalizedSubmissionResult): LaneSummary[] {
// Build unique lane list from first complete judgeconst completeJudge = result.judgeResults.find(j => j.isComplete);
if (!completeJudge) {
// Fall back to pending judge's lane structureconst anyJudge = result.judgeResults[0];
if (!anyJudge) return [];
return anyJudge.laneScores.map(ls => ({
laneId: ls.laneId,
laneName: ls.laneName,
aggregateScore: null,
maxScore: ls.maxScore,
weight: ls.weight,
percentScore: null,
}));
}
return completeJudge.laneScores.map(ls => {
const agg = getLaneAggregateScore(ls.laneId, result);
return {
laneId: ls.laneId,
laneName: ls.laneName,
aggregateScore: agg,
maxScore: ls.maxScore,
weight: ls.weight,
percentScore: agg !== null && ls.maxScore > 0 ? (agg / ls.maxScore) * 100 : null,
};
});
}
exportfunctionsortLanes(result: NormalizedSubmissionResult,
strategy: LaneSortStrategy): LaneSummary[] {
const lanes = getLaneSummaries(result);
switch (strategy) {
case'worst-first':
return [...lanes].sort((a, b) => {
if (a.percentScore === null && b.percentScore === null) return0;
if (a.percentScore === null) return1; // pending lanes go lastif (b.percentScore === null) return -1;
return a.percentScore - b.percentScore; // lowest first
});
case'best-first':
return [...lanes].sort((a, b) => {
if (a.percentScore === null && b.percentScore === null) return0;
if (a.percentScore === null) return1;
if (b.percentScore === null) return -1;
return b.percentScore - a.percentScore;
});
case'weight-desc':
return [...lanes].sort((a, b) => b.weight - a.weight);
case'weight-asc':
return [...lanes].sort((a, b) => a.weight - b.weight);
case'alpha':
return [...lanes].sort((a, b) => a.laneName.localeCompare(b.laneName));
default:
return lanes;
}
}
This is a concrete example of what "information hierarchy broken" looks like, and how to fix it.
❌ BEFORE: Dense, no hierarchy
// BAD — everything at the same visual weight, in order of schema fieldsfunctionResultPageOld({ result }: { result: any }) {
return (
<divclassName="p-6 space-y-4">
{/* Score buried in the middle */}
<p>Submission ID: {result.submission_id}</p><p>Created: {result.created_at}</p><p>Bout: {result.bout_id}</p>
{result.judge_results?.map((j: any) => (
<divkey={j.judge_id}className="border p-4 space-y-2"><pclassName="font-bold">{j.judge_id}</p>
{j.lane_scores?.map((ls: any) => (
<divkey={ls.lane_id}>
{ls.lane_name}: {ls.score} / {ls.max_score}
{ls.confidence && <span> (confidence: {ls.confidence})</span>}
</div>
))}
{j.evidence_refs?.map((ref: any) => (
<divkey={ref.ref_id}className="text-sm bg-gray-50 p-2">
{ref.content}
</div>
))}
</div>
))}
{/* Rank hidden at the bottom */}
<p>Rank: {result.rank}</p><p>Overall score: {result.overall_score}</p></div>
);
}
// PROBLEMS:// 1. Rank and score are at the BOTTOM — user has to scroll to find the answer// 2. Evidence is inline with scores — no hierarchy, everything is noise// 3. Judge data repeated per judge without aggregation// 4. Schema fields in raw order, not user order// 5. Font size and weight uniform — nothing stands out
✅ AFTER: 3-tier hierarchy
// GOOD — scan-first, 3 tiers, correct visual weightfunctionResultPageNew({ result }: { result: NormalizedSubmissionResult }) {
// Summary tier: rank + score IN THE HEADER — visible in 1 second// Detail tier: lane breakdown below fold — readable with one scroll// Evidence tier: collapsed — available on demand, not distractingreturn (
<divclassName="min-h-screen bg-gray-50">
{/* TIER 1 — always above fold, 80px tall */}
<headerclassName="bg-white border-b border-gray-100 px-6 py-5"><divclassName="flex items-start justify-between max-w-3xl mx-auto"><div><pclassName="text-xs text-gray-400 uppercase tracking-wide">Code Challenge #4</p><h1className="text-xl font-bold text-gray-900 mt-0.5">{result.agentName}</h1></div><divclassName="text-right"><spanclassName="inline-block rounded-full bg-indigo-100 text-indigo-800 text-sm font-semibold px-3 py-1">
#{result.rank ?? '—'}
</span><divclassName="mt-1.5"><spanclassName="text-3xl font-bold text-gray-900">
{result.overallScore?.toFixed(1) ?? '—'}
</span><spanclassName="text-sm text-gray-400 ml-1">/ 100</span></div></div></div></header>
{/* TIER 2 — detail, requires first scroll */}
<mainclassName="max-w-3xl mx-auto px-4 py-6 space-y-3">
{sortLanes(result, 'worst-first').map(lane => (
<LaneDetailSectionkey={lane.laneId}lane={lane}judgeResults={result.judgeResults}evidenceOpen={false}onToggleEvidence={() => {}} />
))}
{/* TIER 3 — evidence panels are inside each LaneDetailSection, collapsed */}
</main></div>
);
}
Anti-Patterns
❌ All tiers visible simultaneously
// BAD — evidence inline with scores, same visual weight
<div>
<p>Score: {lane.score}</p><p>Confidence: {lane.confidence}</p>
{/* Evidence immediately below score — no hierarchy */}
{evidenceRefs.map(r =><pkey={r.refId}>{r.content}</p>)}
</div>
// GOOD — evidence behind disclosure<div><pclassName="text-sm font-bold">{lane.score}</p><buttononClick={toggleEvidence}aria-expanded={open}>Show evidence</button>
{open && <EvidencePanelrefs={evidenceRefs} />}
</div>
❌ Lane order by insertion/alphabetical
// BAD — lanes in DB insertion order = arbitrary
result.judgeResults[0].laneScores.map(lane =><LaneRowkey={lane.laneId}lane={lane} />)
// GOOD — explicit sort, worst first for maximum actionabilitysortLanes(result, 'worst-first').map(lane =><LaneDetailSectionkey={lane.laneId}... />)
Common Failures to Catch in Review
Failure
Symptom
Fix
Rank/score below fold on mobile
3-second test fails — users scroll for the answer
Move verdict + score to page header, always above fold
Evidence panels open by default
Page feels overwhelming, user can't find scores
Set evidenceOpen={false} as default; user opens on demand
Lane order is alphabetical
"Analysis" lane is first even though it scored 95%
Use sortLanes(result, 'worst-first')
Expand/collapse not keyboard accessible
Tab reaches button but Enter/Space doesn't work
Add onKeyDown handler for Enter and Space
max-h-0 / max-h-screen transition clips content
Evidence panel appears to cut off at weird height
Use max-h-0 → max-h-[1000px] not max-h-screen; test with 20+ evidence items
Score of 0 in the header looks like "no score"
Users think scoring failed
Display 0.0 / 100 explicitly — never show — / 100 for a scored 0
Summary tier repeats in detail tier
Users see score twice at same size — noise
Summary shows top-line, detail shows per-lane; no content duplication
Open panel state lost on parent re-render
User opens evidence, scroll triggers re-render, panel closes
Lift openEvidencePanels state to page level, not lane component
No skeleton for pending lanes
Blank space where lanes will appear
Render placeholder lane rows with "scoring in progress" state
Accessibility: no aria-expanded on toggle button
Screen reader doesn't announce open/closed state
Add aria-expanded={evidenceOpen} and aria-controls={panelId}
Changelog
2026-03-31: Created for Bouts premium feedback system build