AE-Shree's picture
Update src/App.js
6c69368 verified
import { useState, useRef, useCallback } from "react";
// ─────────────────────────────────────────────────────────
// THEME DEFINITIONS
// ─────────────────────────────────────────────────────────
const THEMES = {
dark: {
name: "dark",
bg: "#070d14",
surface: "#0f1923",
surfaceAlt: "#0a1520",
border: "#1e2d3d",
borderFocus: "#3b82f6",
text: "#e2e8f0",
textMuted: "#4a6080",
textSub: "#94a3b8",
textBody: "#cbd5e1",
inputBg: "#0a1520",
uploadBg: "#0a1520",
uploadHover: "#1e2d3d",
scrollTrack: "#0f1923",
scrollThumb: "#1e3a5f",
infoBg: "#0a1520",
emptyColor: "#1e3a5f",
cardBg: "#0f1923",
barTrack: "#1e2d3d",
btnDisabled: "#1e2d3d",
btnDisabledTxt: "#4a6080",
headerShadow: "none",
cardShadow: "none",
},
light: {
name: "light",
bg: "#f0f4f8",
surface: "#ffffff",
surfaceAlt: "#f8fafc",
border: "#d1dde9",
borderFocus: "#2563eb",
text: "#0f172a",
textMuted: "#64748b",
textSub: "#475569",
textBody: "#334155",
inputBg: "#ffffff",
uploadBg: "#f8fafc",
uploadHover: "#e2eaf4",
scrollTrack: "#e2e8f0",
scrollThumb: "#94a3b8",
infoBg: "#f1f5f9",
emptyColor: "#94a3b8",
cardBg: "#ffffff",
barTrack: "#e2e8f0",
btnDisabled: "#e2e8f0",
btnDisabledTxt: "#94a3b8",
headerShadow: "0 1px 6px rgba(0,0,0,0.07)",
cardShadow: "0 1px 4px rgba(0,0,0,0.06)",
},
};
// ─────────────────────────────────────────────────────────
// THEME TOGGLE
// ─────────────────────────────────────────────────────────
const ThemeToggle = ({ theme, onToggle }) => {
const isDark = theme.name === "dark";
return (
<button onClick={onToggle} title={`Switch to ${isDark ? "light" : "dark"} mode`}
style={{
display: "flex", alignItems: "center", gap: 9,
background: isDark ? "#1a2d42" : "#e8f0fa",
border: `1px solid ${isDark ? "#2d4a6a" : "#c4d4e8"}`,
borderRadius: 24, padding: "6px 14px 6px 8px",
cursor: "pointer", color: theme.text,
fontSize: 12, fontWeight: 600, letterSpacing: 0.4,
transition: "background .3s, border-color .3s",
}}>
<div style={{
width: 42, height: 23, borderRadius: 12,
background: isDark ? "#1d4ed8" : "#f59e0b",
position: "relative", flexShrink: 0,
transition: "background .35s ease",
}}>
<div style={{
position: "absolute", top: "50%", transform: "translateY(-50%)",
left: isDark ? 22 : 3, width: 17, height: 17, borderRadius: "50%",
background: "#fff", boxShadow: "0 1px 5px rgba(0,0,0,0.25)",
display: "grid", placeItems: "center", fontSize: 9,
transition: "left .28s ease",
}}>{isDark ? "πŸŒ™" : "β˜€οΈ"}</div>
</div>
<span style={{ color: theme.textMuted, userSelect: "none" }}>
{isDark ? "Dark" : "Light"}
</span>
</button>
);
};
// ─────────────────────────────────────────────────────────
// HF JUDGE SCORE BADGE (replaces ROUGE-L badge)
// ─────────────────────────────────────────────────────────
const HFJudgeBadge = ({ score }) => {
const pct = parseFloat(score) * 100;
const color = pct >= 60 ? "#22c55e" : pct >= 35 ? "#f59e0b" : "#ef4444";
return (
<span style={{
background: color + "22", color,
border: `1px solid ${color}55`,
borderRadius: 6, padding: "2px 10px",
fontFamily: "monospace", fontWeight: 700, fontSize: 13,
}}>HF Judge: {pct.toFixed(1)}%</span>
);
};
// ─────────────────────────────────────────────────────────
// OUTPUT CARD
// ─────────────────────────────────────────────────────────
const OutputCard = ({ title, icon, content, badge, accent, loading, theme }) => (
<div style={{
background: theme.cardBg, border: `1px solid ${accent}33`,
borderRadius: 14, padding: 20, position: "relative", overflow: "hidden",
boxShadow: theme.cardShadow, transition: "background .3s, box-shadow .3s",
}}>
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 3, background: accent }} />
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<span style={{ fontSize: 18 }}>{icon}</span>
<span style={{
fontFamily: "'Courier New', monospace",
fontSize: 11, fontWeight: 700, color: accent,
textTransform: "uppercase", letterSpacing: 2,
}}>{title}</span>
{badge && <div style={{ marginLeft: "auto" }}>{badge}</div>}
</div>
{loading ? (
<div style={{ display: "flex", gap: 6, alignItems: "center", color: theme.textMuted, fontSize: 13 }}>
{[0, 0.2, 0.4].map((d, i) => (
<span key={i} style={{ animation: `pulse 1s infinite ${d}s` }}>●</span>
))}
<span style={{ marginLeft: 8 }}>Generating…</span>
</div>
) : content ? (
<p style={{ margin: 0, fontSize: 14, lineHeight: 1.78, color: theme.textBody, fontFamily: "Georgia, serif" }}>
{content}
</p>
) : (
<p style={{ margin: 0, fontSize: 13, color: theme.textMuted, fontStyle: "italic" }}>
Awaiting input…
</p>
)}
</div>
);
// ─────────────────────────────────────────────────────────
// REWARD BREAKDOWN CARD β€” mirrors inference.py output exactly
// ─────────────────────────────────────────────────────────
const RewardBar = ({ label, score, color, desc, theme }) => {
const pct = Math.round(parseFloat(score) * 100);
return (
<div style={{ marginBottom: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 3 }}>
<span style={{ fontSize: 12, color: theme.textSub, fontWeight: 600 }}>{label}</span>
<span style={{ fontSize: 12, fontFamily: "monospace", color, fontWeight: 700 }}>
{parseFloat(score).toFixed(4)}
</span>
</div>
<div style={{ height: 8, background: theme.barTrack, borderRadius: 4 }}>
<div style={{
width: `${pct}%`, height: 8, borderRadius: 4,
background: color, transition: "width 1s ease",
}} />
</div>
<div style={{ fontSize: 11, color: theme.textMuted, marginTop: 2 }}>{desc}</div>
</div>
);
};
const RewardBreakdownCard = ({ breakdown, label, accent, theme }) => {
if (!breakdown) return null;
const totalPct = Math.round(parseFloat(breakdown.total) * 100);
const totalColor = totalPct >= 60 ? "#22c55e" : totalPct >= 40 ? "#f59e0b" : "#ef4444";
return (
<div style={{
background: theme.cardBg, border: `1px solid ${accent}33`,
borderRadius: 14, padding: 20, position: "relative", overflow: "hidden",
boxShadow: theme.cardShadow, transition: "background .3s",
animation: "fadeIn .4s ease",
}}>
<div style={{ position: "absolute", top: 0, left: 0, right: 0, height: 3, background: accent }} />
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 18 }}>πŸ“Š</span>
<span style={{
fontFamily: "'Courier New', monospace", fontSize: 11,
fontWeight: 700, color: accent, textTransform: "uppercase", letterSpacing: 2,
}}>
{label} Reward Breakdown
</span>
</div>
{/* Total score pill */}
<span style={{
background: totalColor + "22", color: totalColor,
border: `1px solid ${totalColor}55`,
borderRadius: 6, padding: "3px 12px",
fontFamily: "monospace", fontWeight: 700, fontSize: 14,
}}>
TOTAL: {parseFloat(breakdown.total).toFixed(4)}
</span>
</div>
{/* Component bars */}
<RewardBar label="Contrastive" score={breakdown.contrastive} color="#3498db" desc="Beats BM25 retrieval baseline" theme={theme} />
<RewardBar label="ROUGE-L" score={breakdown.rouge_l} color="#9b59b6" desc="Surface overlap with reference" theme={theme} />
<RewardBar label="Negation Safety" score={breakdown.negation_safety} color="#e67e22" desc="No hallucinated negations" theme={theme} />
<RewardBar label="HF Judge" score={breakdown.hf_judge} color="#1abc9c" desc={`BiomedBERT sim (${parseFloat(breakdown.biomed_sim).toFixed(3)}) + NLI quality (${parseFloat(breakdown.nli_quality).toFixed(3)})`} theme={theme} />
{/* Total bar */}
<div style={{ borderTop: `1px solid ${theme.border}`, paddingTop: 10, marginTop: 6 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 3 }}>
<span style={{ fontSize: 12, color: theme.text, fontWeight: 700 }}>TOTAL</span>
<span style={{ fontSize: 13, fontFamily: "monospace", color: totalColor, fontWeight: 700 }}>
{parseFloat(breakdown.total).toFixed(4)} / 1.0
</span>
</div>
<div style={{ height: 10, background: theme.barTrack, borderRadius: 5 }}>
<div style={{
width: `${totalPct}%`, height: 10, borderRadius: 5,
background: totalColor, transition: "width 1.2s ease",
}} />
</div>
<div style={{ fontSize: 11, color: theme.textMuted, marginTop: 2 }}>
Weighted average (0.25 Γ— each component)
</div>
</div>
</div>
);
};
// ─────────────────────────────────────────────────────────
// MAIN APP
// ─────────────────────────────────────────────────────────
export default function App() {
const [themeName, setThemeName] = useState("dark");
const theme = THEMES[themeName];
const toggleTheme = () => setThemeName(t => t === "dark" ? "light" : "dark");
const [image, setImage] = useState(null);
const [imageFile, setImageFile] = useState(null);
const [groundTruth, setGroundTruth] = useState("");
const [sftOutput, setSftOutput] = useState("");
const [rewardOutput, setRewardOutput] = useState("");
const [grpoOutput, setGrpoOutput] = useState("");
const [rewardScore, setRewardScore] = useState(null);
// ── NEW: HF Judge scores from server (replaces client-side ROUGE-L) ──
const [sftHFJudge, setSftHFJudge] = useState(null);
const [grpoHFJudge, setGrpoHFJudge] = useState(null);
const [loading, setLoading] = useState(false);
const [dragging, setDragging] = useState(false);
const fileRef = useRef();
// ── Pipeline ─────────────────────────────────────────
const runInference = async () => {
if (!image || !imageFile) return;
setLoading(true);
setSftOutput(""); setRewardOutput(""); setGrpoOutput("");
setRewardScore(null);
// ── Reset HF Judge scores ──
setSftHFJudge(null);
setGrpoHFJudge(null);
const BASE = "";
try {
// 1. SFT
const sftForm = new FormData();
sftForm.append("file", imageFile);
const sftRes = await fetch(`${BASE}/sft`, { method: "POST", body: sftForm });
const sftData = await sftRes.json();
setSftOutput(sftData.report);
// 2. Reward β€” send ground truth for full breakdown + hf_judge
const rmForm = new FormData();
rmForm.append("file", imageFile);
rmForm.append("ground_truth", groundTruth);
const rmRes = await fetch(`${BASE}/reward`, { method: "POST", body: rmForm });
const rmData = await rmRes.json();
setRewardOutput(rmData.feedback);
setRewardScore(parseFloat(rmData.score).toFixed(2));
// ── Capture SFT HF Judge score from server ──
if (rmData.hf_judge !== null && rmData.hf_judge !== undefined) {
setSftHFJudge(rmData.hf_judge);
}
// 3. GRPO + its reward breakdown + hf_judge
const grpoForm = new FormData();
grpoForm.append("file", imageFile);
grpoForm.append("ground_truth", groundTruth);
const grpoRes = await fetch(`${BASE}/grpo_reward`, { method: "POST", body: grpoForm });
const grpoData = await grpoRes.json();
setGrpoOutput(grpoData.report);
// ── Capture GRPO HF Judge score from server ──
if (grpoData.hf_judge !== null && grpoData.hf_judge !== undefined) {
setGrpoHFJudge(grpoData.hf_judge);
}
} catch (err) {
console.error("Inference error:", err);
setSftOutput("⚠️ Could not connect to server. Please check your connection.");
}
setLoading(false);
};
// ── File handling ─────────────────────────────────────
const handleFile = (file) => {
if (!file || !file.type.startsWith("image/")) return;
setImageFile(file);
const reader = new FileReader();
reader.onload = e => setImage(e.target.result);
reader.readAsDataURL(file);
};
const onDrop = useCallback((e) => {
e.preventDefault(); setDragging(false);
handleFile(e.dataTransfer.files[0]);
}, []); // eslint-disable-line
const clearAll = () => {
setImage(null); setImageFile(null);
setSftOutput(""); setRewardOutput(""); setGrpoOutput("");
setRewardScore(null);
setSftHFJudge(null);
setGrpoHFJudge(null);
if (fileRef.current) fileRef.current.value = "";
};
return (
<div style={{
minHeight: "100vh", background: theme.bg, color: theme.text,
fontFamily: "system-ui, -apple-system, sans-serif",
transition: "background .3s ease, color .3s ease",
}}>
<style>{`
@keyframes pulse { 0%,100%{opacity:.3} 50%{opacity:1} }
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:translateY(0)} }
* { box-sizing: border-box; margin: 0; padding: 0; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: ${theme.scrollTrack}; }
::-webkit-scrollbar-thumb { background: ${theme.scrollThumb}; border-radius: 3px; }
textarea { font-family: system-ui, sans-serif; }
textarea:focus {
outline: none;
border-color: ${theme.borderFocus} !important;
box-shadow: 0 0 0 3px ${theme.borderFocus}22;
}
button { transition: all .22s ease !important; }
button:not(:disabled):hover { filter: brightness(1.1); transform: translateY(-1px); }
button:not(:disabled):active { transform: translateY(0px); filter: brightness(0.97); }
`}</style>
{/* ═══ HEADER ═══ */}
<header style={{
borderBottom: `2px solid ${theme.border}`, padding: "20px 32px",
display: "flex", alignItems: "center", gap: 18,
background: `linear-gradient(135deg, ${theme.surface} 0%, ${theme.surfaceAlt} 100%)`,
boxShadow: "0 4px 20px rgba(0,0,0,0.1)",
transition: "background .3s, border-color .3s, box-shadow .3s",
position: "sticky", top: 0, zIndex: 100,
backdropFilter: "blur(10px)", WebkitBackdropFilter: "blur(10px)",
}}>
<div style={{
width: 52, height: 52, flexShrink: 0,
background: "linear-gradient(135deg,#3b82f6,#06b6d4)",
borderRadius: 14, display: "grid", placeItems: "center",
fontSize: 24, boxShadow: "0 4px 16px rgba(59,130,246,0.4)",
border: "2px solid rgba(255,255,255,0.2)",
}}>🫁</div>
<div>
<div style={{
fontWeight: 900, fontSize: 22, letterSpacing: -0.8, color: theme.text,
textShadow: theme.name === "dark" ? "0 1px 2px rgba(0,0,0,0.3)" : "none",
marginBottom: 4,
}}>BioStack</div>
<div style={{
fontSize: 12, color: theme.textMuted, letterSpacing: 1.2,
textTransform: "uppercase", fontWeight: 600, opacity: 0.9,
}}>NeMo Gym Based Medical Report Generation</div>
</div>
<div style={{ flex: 1 }} />
<ThemeToggle theme={theme} onToggle={toggleTheme} />
</header>
{/* ═══ MAIN GRID ═══ */}
<div style={{
display: "grid", gridTemplateColumns: "370px 1fr",
height: "calc(100vh - 155px)", overflow: "hidden",
}}>
{/* ══ LEFT PANEL ══ */}
<aside style={{
borderRight: `1px solid ${theme.border}`, padding: "15px 18px",
display: "flex", flexDirection: "column", gap: 14,
background: theme.surface, overflowY: "auto",
transition: "background .3s, border-color .3s", height: "100%",
}}>
<div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700 }}>
πŸ“€ Input
</div>
{/* Upload zone */}
<div>
<label style={{
fontSize: 11, color: theme.textMuted, textTransform: "uppercase",
letterSpacing: 1.5, fontWeight: 700, display: "block", marginBottom: 8,
}}>Chest X-Ray Image</label>
<div
onClick={() => fileRef.current.click()}
onDragOver={e => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={onDrop}
style={{
border: `2px dashed ${dragging ? "#3b82f6" : image ? "#22c55e66" : theme.border}`,
borderRadius: 14, minHeight: 190,
display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
cursor: "pointer",
background: dragging ? theme.uploadHover : theme.uploadBg,
transition: "all .22s", overflow: "hidden",
}}
>
{image ? (
<img src={image} alt="X-Ray" style={{
width: "100%", height: "100%",
objectFit: "contain", maxHeight: 240, borderRadius: 12,
}} />
) : (
<>
<div style={{ fontSize: 40, marginBottom: 10, opacity: 0.65 }}>🩻</div>
<div style={{ fontSize: 13, color: theme.textMuted, textAlign: "center", lineHeight: 1.65 }}>
Click or drag &amp; drop<br />
<span style={{ fontSize: 11, color: theme.emptyColor }}>PNG, JPG, DICOM supported</span>
</div>
</>
)}
</div>
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }}
onChange={e => handleFile(e.target.files[0])} />
</div>
{image && (
<button onClick={clearAll} style={{
background: "transparent", border: `1px solid ${theme.border}`,
color: theme.textMuted, borderRadius: 8, padding: "5px 14px",
cursor: "pointer", fontSize: 12, alignSelf: "flex-start",
}}>βœ• Clear Image</button>
)}
{/* Ground truth */}
<div>
<label style={{
fontSize: 11, color: theme.textMuted, textTransform: "uppercase",
letterSpacing: 1.5, fontWeight: 700, display: "block", marginBottom: 8,
}}>
πŸ“‹ Ground Truth{" "}
<span style={{ color: theme.emptyColor, textTransform: "none", letterSpacing: 0, fontWeight: 400, fontSize: 11 }}>
</span>
</label>
<textarea
value={groundTruth}
onChange={e => setGroundTruth(e.target.value)}
placeholder="Paste the radiologist ground truth report here"
rows={5}
style={{
width: "100%", background: theme.inputBg,
border: `1px solid ${theme.border}`, borderRadius: 10,
color: theme.text, padding: "10px 12px",
fontSize: 13, resize: "vertical", lineHeight: 1.65,
transition: "border .2s, box-shadow .2s, background .3s, color .3s",
}}
/>
{groundTruth && (
<div style={{
marginTop: 6, fontSize: 11, color: "#22c55e",
display: "flex", alignItems: "center", gap: 4,
}}>
βœ“ Ground Truth Provided β€” HF Judge scores will be computed
</div>
)}
</div>
{/* Run button */}
<button
onClick={runInference}
disabled={!image || loading}
style={{
background: image && !loading
? "linear-gradient(135deg,#1d4ed8,#0891b2)"
: theme.btnDisabled,
color: image && !loading ? "#fff" : theme.btnDisabledTxt,
border: "none", borderRadius: 12, padding: "13px 20px",
fontSize: 14, fontWeight: 700,
cursor: image && !loading ? "pointer" : "not-allowed",
letterSpacing: 0.5, width: "100%",
boxShadow: image && !loading ? "0 4px 16px rgba(29,78,216,0.38)" : "none",
}}>
{loading ? "⏳ Running Pipeline…" : "β–Ά Run NeMo Gym Pipeline"}
</button>
</aside>
{/* ══ RIGHT PANEL ══ */}
<main style={{
padding: "18px 22px", display: "flex", flexDirection: "column", gap: 16,
overflowY: "auto", background: theme.bg,
transition: "background .3s", height: "100%",
}}>
<div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700 }}>
πŸ“Š Pipeline Outputs
</div>
{/* SFT output β€” badge shows HF Judge score from server */}
<div style={{ animation: sftOutput ? "fadeIn .4s ease" : "none" }}>
<OutputCard
theme={theme} title="SFT Model Output" icon="🧠" accent="#3b82f6"
content={sftOutput} loading={loading && !sftOutput}
badge={sftHFJudge !== null && <HFJudgeBadge score={sftHFJudge} />}
/>
</div>
{/* Reward output card */}
<div style={{ animation: rewardOutput ? "fadeIn .4s ease" : "none" }}>
<OutputCard
theme={theme} title="Reward Model Output" icon="βš–οΈ" accent="#f59e0b"
content={rewardOutput} loading={loading && sftOutput && !rewardOutput}
badge={rewardScore && (
<div style={{
background: "#f59e0b22", color: "#f59e0b",
border: "1px solid #f59e0b55",
borderRadius: 6, padding: "6px 10px",
fontFamily: "monospace", fontWeight: 700, fontSize: 12,
display: "flex", flexDirection: "column", gap: 2,
}}>
<div>
Reward: {rewardScore}
</div>
<div style={{ fontSize: 11, fontWeight: 600, opacity: 0.9 }}>
(0.25 Γ— contrastive + 0.25 Γ— ROUGE-L + 0.25 Γ— negation_safety + 0.25 Γ— hf_judge)
</div>
</div>
)}
/>
</div>
{/* GRPO output β€” badge shows HF Judge score from server */}
<div style={{ animation: grpoOutput ? "fadeIn .4s ease" : "none" }}>
<OutputCard
theme={theme} title="NeMo Gym Output" icon="🎯" accent="#22c55e"
content={grpoOutput} loading={loading && rewardOutput && !grpoOutput}
badge={grpoHFJudge !== null && <HFJudgeBadge score={grpoHFJudge} />}
/>
</div>
{/* ── HF JUDGE COMPARISON (replaces ROUGE-L comparison) ── */}
{groundTruth && sftOutput && grpoOutput && sftHFJudge !== null && grpoHFJudge !== null && (
<div style={{
background: theme.surface, border: `1px solid ${theme.border}`,
borderRadius: 14, padding: 20, animation: "fadeIn .5s ease",
boxShadow: theme.cardShadow, transition: "background .3s, border-color .3s",
}}>
<div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700, marginBottom: 6 }}>
πŸ€– HF Judge Score Comparison vs Ground Truth
</div>
<div style={{ fontSize: 11, color: theme.textMuted, marginBottom: 16 }}>
BiomedBERT semantic similarity + NLI quality β€” computed by the server reward model
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
{[
{ label: "SFT (Original)", score: sftHFJudge, color: "#3b82f6" },
{ label: "GRPO (Final)", score: grpoHFJudge, color: "#22c55e" },
].map(({ label, score, color }) => {
const pct = (parseFloat(score) * 100).toFixed(1);
const isGRPO = label.includes("GRPO");
const sftPct = parseFloat(sftHFJudge) * 100;
const grpoPct = parseFloat(grpoHFJudge) * 100;
const improved = isGRPO && grpoPct > sftPct;
const delta = (grpoPct - sftPct).toFixed(1);
return (
<div key={label} style={{
background: theme.surfaceAlt, borderRadius: 10, padding: 16,
border: `1px solid ${color}33`, transition: "background .3s",
}}>
<div style={{ fontSize: 12, color: theme.textMuted, marginBottom: 8 }}>{label}</div>
<div style={{ fontSize: 28, fontWeight: 800, color, fontFamily: "monospace" }}>
{pct}%
</div>
<div style={{ fontSize: 11, color: theme.textMuted, marginBottom: 10 }}>
raw: {parseFloat(score).toFixed(4)}
</div>
<div style={{ height: 5, background: theme.barTrack, borderRadius: 3 }}>
<div style={{
width: `${pct}%`, height: "100%",
background: color, borderRadius: 3, transition: "width 1.2s ease",
}} />
</div>
{improved && (
<div style={{ fontSize: 11, color: "#22c55e", marginTop: 7, fontWeight: 600 }}>
β–² +{delta}% improvement
</div>
)}
{isGRPO && !improved && grpoPct < sftPct && (
<div style={{ fontSize: 11, color: "#f59e0b", marginTop: 7, fontWeight: 600 }}>
β–Ό {delta}% vs SFT
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* Ground truth reference */}
{groundTruth && (
<div style={{
background: theme.surface, border: `1px solid ${theme.border}`,
borderRadius: 14, padding: 20,
boxShadow: theme.cardShadow, transition: "background .3s, border-color .3s",
}}>
<div style={{ fontSize: 10, color: theme.textMuted, textTransform: "uppercase", letterSpacing: 2, fontWeight: 700, marginBottom: 10 }}>
πŸ“‹ Ground Truth Reference
</div>
<p style={{ fontSize: 14, lineHeight: 1.78, color: theme.textSub, fontFamily: "Georgia, serif" }}>
{groundTruth}
</p>
</div>
)}
{/* Empty state */}
{!image && !loading && (
<div style={{
flex: 1, display: "flex", flexDirection: "column",
alignItems: "center", justifyContent: "center",
color: theme.emptyColor, gap: 14, paddingTop: 60,
}}>
<div style={{ fontSize: 64, opacity: 0.35 }}>🩻</div>
<div style={{ fontSize: 14, fontWeight: 600, opacity: 0.45 }}>Upload a chest X-ray to begin</div>
<div style={{ fontSize: 12, opacity: 0.3 }}>Results will appear here after running the pipeline</div>
</div>
)}
</main>
</div>
{/* ═══ FOOTER ═══ */}
<footer style={{
borderTop: `2px solid ${theme.border}`, padding: "18px 32px",
background: `linear-gradient(135deg, ${theme.surfaceAlt} 0%, ${theme.surface} 100%)`,
textAlign: "center", fontSize: 13, color: theme.textMuted,
fontWeight: 600, letterSpacing: 0.5,
transition: "background .3s, border-color .3s, color .3s",
boxShadow: "0 -2px 10px rgba(0,0,0,0.05)",
backdropFilter: "blur(8px)", WebkitBackdropFilter: "blur(8px)",
}}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 8, opacity: 0.8 }}>
<span style={{ fontSize: 16 }}>Β©</span>
<span>2026 BioStack. All rights reserved.</span>
</div>
</footer>
</div>
);
}