Premium Replay Breakdown UX
Review Checklist
- Information hierarchy is enforced in the component tree: The render order must be: verdict → lane breakdown → next steps → evidence panel → relative context. No component should render out of order. Test: disable CSS and verify the DOM order is correct.
- Partial results never render as broken: When some lanes are pending, the component shows "Evaluating…" skeleton cards with progress indicators — not empty cards, blank text, or layout shift when data arrives. Test with a real partial state.
- Loading state does not use spinner-only: A full-screen spinner is not acceptable. Each section has its own skeleton state so the user can see the page structure while data loads. Storybook story required for each skeleton variant.
- Score animation runs only once: The score counter animation fires once on mount, not on every re-render. Add
useReforuseStateto track whether animation has played. - Evidence panel is collapsed by default: Users are not forced to read evidence — it's available on demand. Verify: evidence panel renders with
isExpanded={false}on initial load. - Mobile layout stacks vertically, no horizontal scroll: Test on 375px viewport. No element should overflow. Lane breakdown cards must be full-width on mobile.
- Contradiction notice renders in the lane where it occurred: The contradiction badge must be scoped to the lane card, not shown as a global alert. Verify: a contradiction in "Efficiency" only affects the Efficiency lane card.
- State machine drives all loading/error/partial/complete states: No ad-hoc
if (loading && !error && data)chains. Use a proper state type union and render based on machine state. - Next steps are actionable, not generic: "Practice algorithm problems" is not acceptable. Next steps must reference the specific lane key and score range. Verify:
generateNextSteps()takes lane scores as input, not just overall score. - Relative context ("vs field") only shows when rankings are finalized: Do not show percentile during open window. Verify:
BreakdownRelativeContextrenders nothing ifrankStatus === 'provisional'. - Tab/keyboard navigation works through the breakdown: Evidence refs must be focusable. Lane cards must be expandable via Enter key. Run axe-core in CI.
- Score delta from previous attempt renders correctly: If user has a previous submission, show delta (+7, -3). Verify the delta calculation handles null (first attempt) gracefully without crashing.
Information Hierarchy: The Psychological Flow of Reading a Result
This is the most important design decision in Bouts. Users arrive at the breakdown page in one of two emotional states: (1) hopeful, wanting validation; (2) defensive, bracing for criticism. The layout must work for both.
Correct hierarchy:
- Overall verdict — answer the fundamental question first: how did I do?
- Lane breakdown — where specifically did I excel or struggle?
- Actionable next steps — what can I do about it?
- Evidence panel — show your work (on demand)
- Relative context — where do I stand vs others? (only when finalized)
Why this order: Users read top-to-bottom. If you put evidence first, users get lost in details before they understand the conclusion. If you put relative context first, losing feels worse. Verdict first anchors the emotional read.
Component Architecture
BreakdownPage (page.tsx — server component, fetches data)
├── BreakdownShell (layout wrapper, handles state machine)
│ ├── BreakdownHeader (score, verdict, basic metadata)
│ │ ├── ScoreRing (animated score circle)
│ │ ├── VerdictBadge (Exceptional / Strong / Adequate / Developing)
│ │ └── SubmissionMeta (submitted at, challenge name, attempt number)
│ ├── BreakdownLanes (lane-by-lane breakdown)
│ │ └── LaneCard[] (one per lane — expandable)
│ │ ├── LaneScoreBar
│ │ ├── LaneSignalPair (positive + weakness)
│ │ ├── ContradictionNotice (conditional)
│ │ └── LaneDimensionGrid (expanded state)
│ ├── BreakdownNextSteps (actionable recommendations)
│ │ └── NextStepItem[] (per-lane suggestions)
│ ├── BreakdownEvidencePanel (collapsed by default)
│ │ └── EvidenceRefList[]
│ └── BreakdownRelativeContext (only when rank finalized)
│ ├── PercentileBar
│ └── FieldDistributionChart
Complete TSX: Core Breakdown Components
// app/bouts/[boutId]/breakdown/page.tsx
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
import { BreakdownShell } from '@/components/breakdown/BreakdownShell';
import { BreakdownSkeleton } from '@/components/breakdown/BreakdownSkeleton';
import { loadBreakdownData } from '@/lib/breakdown/breakdown-loader';
interface Props {
params: { boutId: string };
searchParams: { submissionId?: string };
}
export default async function BreakdownPage({ params, searchParams }: Props) {
const supabase = createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) notFound();
const submissionId = searchParams.submissionId;
if (!submissionId) notFound();
// Verify ownership — users can only see their own breakdown
const { data: submission } = await supabase
.from('submissions')
.select('id, user_id, bout_id, created_at, attempt_number')
.eq('id', submissionId)
.eq('bout_id', params.boutId)
.single();
if (!submission || submission.user_id !== user.id) notFound();
const breakdown = await loadBreakdownData(submissionId);
return (
<Suspense fallback={<BreakdownSkeleton />}>
<BreakdownShell
submission={submission}
breakdown={breakdown}
/>
</Suspense>
);
}
// components/breakdown/BreakdownShell.tsx
'use client';
import { useState, useEffect, useReducer } from 'react';
import type { SynthesizedFeedback } from '@/lib/synthesis/feedback-synthesizer';
import { BreakdownHeader } from './BreakdownHeader';
import { BreakdownLanes } from './BreakdownLanes';
import { BreakdownNextSteps } from './BreakdownNextSteps';
import { BreakdownEvidencePanel } from './BreakdownEvidencePanel';
import { BreakdownRelativeContext } from './BreakdownRelativeContext';
// ---- State Machine ----
type BreakdownState =
| { type: 'loading' }
| { type: 'partial'; feedback: SynthesizedFeedback; pendingLaneKeys: string[] }
| { type: 'complete'; feedback: SynthesizedFeedback }
| { type: 'error'; message: string };
type BreakdownAction =
| { type: 'LANES_PARTIAL'; feedback: SynthesizedFeedback; pendingLaneKeys: string[] }
| { type: 'LANES_COMPLETE'; feedback: SynthesizedFeedback }
| { type: 'FETCH_ERROR'; message: string };
function breakdownReducer(state: BreakdownState, action: BreakdownAction): BreakdownState {
switch (action.type) {
case 'LANES_PARTIAL':
return { type: 'partial', feedback: action.feedback, pendingLaneKeys: action.pendingLaneKeys };
case 'LANES_COMPLETE':
return { type: 'complete', feedback: action.feedback };
case 'FETCH_ERROR':
return { type: 'error', message: action.message };
default:
return state;
}
}
interface BreakdownShellProps {
submission: { id: string; bout_id: string; created_at: string; attempt_number: number };
breakdown: SynthesizedFeedback | null;
}
export function BreakdownShell({ submission, breakdown }: BreakdownShellProps) {
const [state, dispatch] = useReducer(breakdownReducer, { type: 'loading' });
useEffect(() => {
if (!breakdown) {
dispatch({ type: 'FETCH_ERROR', message: 'Breakdown data unavailable' });
return;
}
const pendingLaneKeys = breakdown.lanes
.filter((l) => l.reconciledScore === 0 && !l.fallbackReason)
.map((l) => l.laneKey);
if (pendingLaneKeys.length > 0) {
dispatch({ type: 'LANES_PARTIAL', feedback: breakdown, pendingLaneKeys });
} else {
dispatch({ type: 'LANES_COMPLETE', feedback: breakdown });
}
}, [breakdown]);
if (state.type === 'loading') {
return <BreakdownLoadingState />;
}
if (state.type === 'error') {
return <BreakdownErrorState message={state.message} />;
}
const { feedback } = state;
const isPending = state.type === 'partial';
const pendingLaneKeys = state.type === 'partial' ? state.pendingLaneKeys : [];
return (
<div className="max-w-3xl mx-auto px-4 py-8 space-y-8">
{/* 1. Overall verdict — always first */}
<BreakdownHeader
overallScore={feedback.overallScore}
overallAdjustedScore={feedback.overallAdjustedScore}
overallSignal={feedback.overallSignal}
overallWeakness={feedback.overallWeakness}
submittedAt={submission.created_at}
attemptNumber={submission.attempt_number}
isPending={isPending}
/>
{/* 2. Lane breakdown */}
<BreakdownLanes
lanes={feedback.lanes}
pendingLaneKeys={pendingLaneKeys}
/>
{/* 3. Next steps */}
<BreakdownNextSteps lanes={feedback.lanes} />
{/* 4. Evidence panel (collapsed by default) */}
<BreakdownEvidencePanel lanes={feedback.lanes} />
{/* 5. Relative context — only when complete */}
{!isPending && (
<BreakdownRelativeContext
submissionId={submission.id}
boutId={submission.bout_id}
/>
)}
</div>
);
}
// components/breakdown/BreakdownHeader.tsx
'use client';
import { useEffect, useRef, useState } from 'react';
interface BreakdownHeaderProps {
overallScore: number;
overallAdjustedScore: number;
overallSignal: string | null;
overallWeakness: string | null;
submittedAt: string;
attemptNumber: number;
isPending: boolean;
}
function getVerdictLabel(score: number): { label: string; color: string; bg: string } {
if (score >= 85) return { label: 'Exceptional', color: 'text-emerald-700', bg: 'bg-emerald-100' };
if (score >= 70) return { label: 'Strong', color: 'text-indigo-700', bg: 'bg-indigo-100' };
if (score >= 50) return { label: 'Adequate', color: 'text-amber-700', bg: 'bg-amber-100' };
return { label: 'Developing', color: 'text-gray-600', bg: 'bg-gray-100' };
}
export function BreakdownHeader({
overallScore,
overallAdjustedScore,
overallSignal,
overallWeakness,
submittedAt,
attemptNumber,
isPending,
}: BreakdownHeaderProps) {
const [displayScore, setDisplayScore] = useState(0);
const hasAnimated = useRef(false);
const verdict = getVerdictLabel(overallScore);
const hasIntegrityDeduction = overallAdjustedScore < overallScore;
// Animate score counter — only once
useEffect(() => {
if (hasAnimated.current || isPending) return;
hasAnimated.current = true;
const duration = 800;
const start = performance.now();
const animate = (now: number) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
// Ease out cubic
const eased = 1 - Math.pow(1 - progress, 3);
setDisplayScore(Math.round(eased * overallScore));
if (progress < 1) requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [overallScore, isPending]);
return (
<div className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-xs text-gray-400 uppercase tracking-wider font-medium mb-1">
Attempt #{attemptNumber} · {new Date(submittedAt).toLocaleDateString()}
</p>
<div className="flex items-center gap-3 mb-4">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold ${verdict.bg} ${verdict.color}`}>
{isPending ? 'Evaluating…' : verdict.label}
</span>
{hasIntegrityDeduction && (
<span className="inline-flex items-center gap-1 text-xs text-rose-600 bg-rose-50 border border-rose-200 px-2 py-1 rounded-full">
⚠ Integrity deduction applied
</span>
)}
</div>
{overallSignal && !isPending && (
<p className="text-sm text-gray-700 leading-relaxed mb-2">
<span className="font-medium text-emerald-700">↑ </span>
{overallSignal}
</p>
)}
{overallWeakness && !isPending && (
<p className="text-sm text-gray-600 leading-relaxed">
<span className="font-medium text-amber-700">↓ </span>
{overallWeakness}
</p>
)}
{isPending && (
<div className="space-y-2">
<div className="h-4 bg-gray-100 rounded animate-pulse w-3/4" />
<div className="h-4 bg-gray-100 rounded animate-pulse w-1/2" />
</div>
)}
</div>
{/* Score ring */}
<div className="shrink-0 ml-6">
<ScoreRing
score={isPending ? null : displayScore}
adjustedScore={hasIntegrityDeduction ? overallAdjustedScore : null}
/>
</div>
</div>
</div>
);
}
function ScoreRing({ score, adjustedScore }: { score: number | null; adjustedScore: number | null }) {
const radius = 40;
const circumference = 2 * Math.PI * radius;
const progress = score !== null ? (score / 100) * circumference : 0;
return (
<div className="flex flex-col items-center">
<div className="relative w-24 h-24">
<svg className="w-24 h-24 -rotate-90" viewBox="0 0 100 100">
<circle cx="50" cy="50" r={radius} fill="none" stroke="#f3f4f6" strokeWidth="8" />
<circle
cx="50" cy="50" r={radius}
fill="none"
stroke={score !== null && score >= 70 ? '#6366f1' : score !== null && score >= 50 ? '#f59e0b' : '#9ca3af'}
strokeWidth="8"
strokeDasharray={circumference}
strokeDashoffset={circumference - progress}
strokeLinecap="round"
style={{ transition: 'stroke-dashoffset 0.8s cubic-bezier(0.22, 1, 0.36, 1)' }}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
{score !== null ? (
<span className="text-2xl font-bold text-gray-900">{score}</span>
) : (
<span className="text-sm text-gray-400">…</span>
)}
</div>
</div>
{adjustedScore !== null && score !== null && (
<span className="text-xs text-rose-600 mt-1">
Adj. {adjustedScore}
</span>
)}
</div>
);
}
// components/breakdown/BreakdownLanes.tsx
'use client';
import { useState } from 'react';
import type { SynthesizedLaneFeedback } from '@/lib/synthesis/feedback-synthesizer';
interface BreakdownLanesProps {
lanes: SynthesizedLaneFeedback[];
pendingLaneKeys: string[];
}
export function BreakdownLanes({ lanes, pendingLaneKeys }: BreakdownLanesProps) {
return (
<section>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
Lane Breakdown
</h2>
<div className="space-y-3">
{lanes.map((lane) => (
<LaneCard
key={lane.laneKey}
lane={lane}
isPending={pendingLaneKeys.includes(lane.laneKey)}
/>
))}
</div>
</section>
);
}
function LaneCard({ lane, isPending }: { lane: SynthesizedLaneFeedback; isPending: boolean }) {
const [isExpanded, setIsExpanded] = useState(false);
const scoreColor =
lane.reconciledScore >= 70 ? 'text-indigo-600' :
lane.reconciledScore >= 50 ? 'text-amber-600' :
'text-gray-500';
return (
<div className="border border-gray-200 rounded-xl bg-white overflow-hidden">
<button
=> !isPending && setIsExpanded(!isExpanded)}
disabled={isPending}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-gray-50 transition-colors disabled:cursor-not-allowed"
aria-expanded={isExpanded}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-gray-900">{lane.displayName}</span>
{lane.hasContradiction && (
<span className="inline-flex items-center text-xs text-amber-700 bg-amber-50 border border-amber-200 px-1.5 py-0.5 rounded-full">
Evaluators split
</span>
)}
{lane.isLowEvidence && (
<span className="inline-flex items-center text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded-full">
Limited data
</span>
)}
</div>
{/* Score bar */}
<div className="flex items-center gap-3">
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
{isPending ? (
<div className="h-full bg-gray-200 animate-pulse rounded-full" style={{ width: '60%' }} />
) : (
<div
className={`h-full rounded-full transition-all duration-700 ${
lane.reconciledScore >= 70 ? 'bg-indigo-500' :
lane.reconciledScore >= 50 ? 'bg-amber-400' : 'bg-gray-400'
}`}
style={{ width: `${lane.reconciledScore}%` }}
/>
)}
</div>
<span className={`text-sm font-bold tabular-nums ${scoreColor} w-8 text-right`}>
{isPending ? '…' : lane.reconciledScore}
</span>
</div>
</div>
{!isPending && (
<svg
className={`ml-4 w-4 h-4 text-gray-400 shrink-0 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</button>
{isExpanded && !isPending && (
<div className="px-5 pb-4 border-t border-gray-100 pt-3 space-y-3">
{lane.hasContradiction && lane.contradictionSummary && (
<div className="flex items-start gap-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
<span className="shrink-0">⚡</span>
<span>{lane.contradictionSummary}</span>
</div>
)}
{lane.positiveSignal && (
<div>
<p className="text-xs font-semibold text-emerald-700 uppercase tracking-wide mb-1">Strength</p>
<p className="text-sm text-gray-700 leading-relaxed">{lane.positiveSignal}</p>
</div>
)}
{lane.primaryWeakness && (
<div>
<p className="text-xs font-semibold text-amber-700 uppercase tracking-wide mb-1">To Improve</p>
<p className="text-sm text-gray-700 leading-relaxed">{lane.primaryWeakness}</p>
</div>
)}
{lane.fallbackReason && !lane.positiveSignal && !lane.primaryWeakness && (
<p className="text-sm text-gray-500 italic">{lane.fallbackReason}</p>
)}
</div>
)}
</div>
);
}
// components/breakdown/BreakdownNextSteps.tsx
'use client';
import type { SynthesizedLaneFeedback } from '@/lib/synthesis/feedback-synthesizer';
interface BreakdownNextStepsProps {
lanes: SynthesizedLaneFeedback[];
}
interface NextStep {
laneKey: string;
displayName: string;
action: string;
priority: 'high' | 'medium';
}
function generateNextSteps(lanes: SynthesizedLaneFeedback[]): NextStep[] {
// Sort by score ascending — lowest scores get highest priority
const sortedLanes = [...lanes]
.filter((l) => l.reconciledScore > 0 && !l.isLowEvidence)
.sort((a, b) => a.reconciledScore - b.reconciledScore);
return sortedLanes.slice(0, 3).map((lane, i) => {
const priority = i === 0 ? 'high' : 'medium';
let action = '';
if (lane.reconciledScore < 40) {
action = `Focus on fundamentals in ${lane.displayName} — your score of ${lane.reconciledScore} suggests the core approach needs revision. Review the scoring criteria for this lane to understand what "adequate" looks like.`;
} else if (lane.reconciledScore < 60) {
action = `In ${lane.displayName} (${lane.reconciledScore}), you're close to the adequate threshold. ${lane.primaryWeakness ?? 'Targeted practice in this area will close the gap quickly.'}`;
} else {
action = `${lane.displayName} scored ${lane.reconciledScore} — solid but there's room to reach the strong tier (70+). ${lane.primaryWeakness ?? 'Review where points were left on the table.'}`;
}
return { laneKey: lane.laneKey, displayName: lane.displayName, action, priority };
});
}
export function BreakdownNextSteps({ lanes }: BreakdownNextStepsProps) {
const steps = generateNextSteps(lanes);
if (steps.length === 0) return null;
return (
<section>
<h2 className="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
Next Steps
</h2>
<div className="bg-white border border-gray-200 rounded-xl divide-y divide-gray-100">
{steps.map((step, i) => (
<div key={step.laneKey} className="px-5 py-4 flex items-start gap-3">
<span className={`shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold mt-0.5 ${
step.priority === 'high'
? 'bg-rose-100 text-rose-700'
: 'bg-gray-100 text-gray-600'
}`}>
{i + 1}
</span>
<p className="text-sm text-gray-700 leading-relaxed">{step.action}</p>
</div>
))}
</div>
</section>
);
}
Anti-Patterns
Anti-Pattern 1: One monolithic component
// ❌ BAD: impossible to test, maintain, or progressively render
export function BreakdownPage({ submissionId }: { submissionId: string }) {
const [data, setData] = useState(null);
// ...1000 lines of JSX with score ring, lanes, next steps, evidence all inline
return <div>...everything in one tree...</div>;
}
// ✅ GOOD: each section is independently renderable with its own loading state
// BreakdownHeader — can load with just overallScore
// BreakdownLanes — can render individually as each lane completes
// BreakdownNextSteps — only renders when lanes array is complete
// Each component has its own Storybook story
Anti-Pattern 2: Spinner-only loading state
// ❌ BAD: user sees blank screen + spinner; feels broken
if (loading) return <div className="flex justify-center"><Spinner /></div>;
// ✅ GOOD: skeleton that mirrors the page structure
export function BreakdownSkeleton() {
return (
<div className="max-w-3xl mx-auto px-4 py-8 space-y-8">
{/* Header skeleton */}
<div className="bg-white border border-gray-200 rounded-2xl p-6">
<div className="flex justify-between">
<div className="space-y-2 flex-1">
<div className="h-3 bg-gray-100 rounded animate-pulse w-32" />
<div className="h-6 bg-gray-100 rounded animate-pulse w-24" />
<div className="h-4 bg-gray-100 rounded animate-pulse w-3/4 mt-3" />
<div className="h-4 bg-gray-100 rounded animate-pulse w-1/2" />
</div>
<div className="w-24 h-24 bg-gray-100 rounded-full animate-pulse ml-6" />
</div>
</div>
{/* Lane skeletons */}
{[1, 2, 3].map((i) => (
<div key={i} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="h-4 bg-gray-100 rounded animate-pulse w-1/3 mb-3" />
<div className="h-2 bg-gray-100 rounded-full animate-pulse" />
</div>
))}
</div>
);
}
Anti-Pattern 3: Showing percentile during open window
// ❌ BAD: "You're in the top 34%" during open window — number changes every hour
<BreakdownRelativeContext submissionId={submissionId} />
// ✅ GOOD: check rank status before showing
export function BreakdownRelativeContext({ submissionId, boutId }: Props) {
const { rankStatus, percentile } = useRankStatus(submissionId, boutId);
if (rankStatus === 'provisional' || rankStatus === null) {
return (
<div className="border border-dashed border-gray-200 rounded-xl px-5 py-4 text-sm text-gray-400">
Rankings finalize when the window closes. Check back then for your percentile.
</div>
);
}
return <FinalRankDisplay percentile={percentile} />;
}
Common Failures to Catch in Review
| Failure | Symptom | Fix |
|---|---|---|
| Score animation fires on every re-render | Score flickers back to 0 and re-animates when state updates | Use useRef(false) to track hasAnimated; only fire once |
| Evidence panel renders empty when all refs are suppressed | Collapsed panel opens to blank space; user feels misled | If allRefs.length === 0, don't render the evidence panel toggle at all |
| Contradiction notice appears as global banner | User thinks their entire submission was contested, not just one lane | Scope ContradictionNotice inside LaneCard; never at page level |
| Next steps are generic ("practice more") | Users ignore them immediately; UX value is wasted | generateNextSteps() must reference lane.displayName and lane.reconciledScore; test output programmatically |
| Partial state renders lane cards as empty divs | Layout shift when data arrives; looks broken | Use skeleton card for pending lanes; maintain card height during loading |
| Adjusted score shown when there are no integrity deductions | User sees "Adjusted: 72" same as raw score; confusing | Only render adjusted score row when overallAdjustedScore < overallScore |
| Mobile layout has horizontal overflow | Lane cards extend past viewport; requires horizontal scroll | All cards must be w-full on mobile; test at 375px in Chrome devtools |
| Relative context shows with provisional rank | Percentile changes in real-time; user refreshes constantly; creates anxiety | BreakdownRelativeContext must check rankStatus before rendering percentile |
isPending not passed to ScoreRing |
Ring animates to 0 for pending lanes; looks like zero score | Pass isPending to ScoreRing; render placeholder instead of animated ring when pending |
generateNextSteps crashes on empty lanes array |
Next steps section throws; breakdown page breaks | Guard: return [] when lanes.length === 0 |
Changelog
- 2026-03-31: Created for Bouts premium feedback system build