From d3d76adaf430395450a8f01cf8a4c43a303cd0a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 01:22:49 +0000 Subject: [PATCH] ui: simplicity and focus pass across every screen Session runner: the prompt card now snapshots the just answered question during review, so feedback and question always match instead of showing the next prompt above the previous prompt's feedback. All review callouts share one border color with only their small labels accented, and the two reflection inputs collapse behind a single add a reflection link, so a wrong answer no longer stacks eight competing colored boxes. The model answer stays visible, dimmed, while writing an error log. The progress bar never regresses when a repair prompt is injected and a quiet note announces the addition. The running score tally is gone from the status line, the keyboard hint hides on touch screens, the diagnostic banner shows its full text only once per run, and feedback excerpts strip raw markdown markers. Navigation: during sessions the nav recedes to the brand plus a single Exit link. Top level destinations consolidated from seven to four (Dashboard, Learn, Plan, Settings); Flashcards, Guides, and Chat are reached from Learn's quick actions. The Mock AI pill moved from the nav into Settings as a one line note, and the unconfigured Google Calendar section collapses to a single sentence without env jargon. Dashboard: Today is the first section and Start is the page's only filled primary button. The five stat cards collapse to one quiet text strip, the milestone bar is gone (the achievements page already shows per badge progress), the heatmap moves to the bottom, and a first run shows only a welcome card with Create your plan. Learn: the stat card row, due cards banner, and XP footer are gone; the recommendation is the hero and the learning path hides once its first steps are complete. Mode labels render in sentence case. The flashcards page now lists decks by course only, matching how Learn counts them, so the two pages can no longer disagree. Plan and end screen: the upload drop zone no longer renders as a white box in the dark theme, plan card subtitles clamp to one line with the same two objectives plus N more rule everywhere, the end screen's duplicate score breakdown section is deleted, and Schedule follow-ups is a quiet button inside the list it acts on. Chat's page title matches its nav label. --- e2e/session-runner.spec.ts | 13 +- src/app/chat/page.tsx | 2 +- src/app/flashcards/page.tsx | 8 +- src/app/learn/page.tsx | 89 +---- src/app/page.tsx | 330 +++++++------------ src/app/plan/page.tsx | 14 +- src/app/s/[sessionId]/screens/end-screen.tsx | 51 +-- src/app/s/[sessionId]/screens/runner.tsx | 308 ++++++++++++----- src/app/settings/page.tsx | 31 +- src/ui/components/NavBar.tsx | 94 +++--- 10 files changed, 452 insertions(+), 488 deletions(-) diff --git a/e2e/session-runner.spec.ts b/e2e/session-runner.spec.ts index 3a48898..a40b55d 100644 --- a/e2e/session-runner.spec.ts +++ b/e2e/session-runner.spec.ts @@ -109,7 +109,10 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { page.getByRole("button", { name: "✓ Correct" }).click(), ]); - // Prompt should advance + // The review panel keeps the just-answered prompt on the card — the + // prompt advances only when the student moves on. + await expect(page.getByText("PROMPT 1 / 3")).toBeVisible({ timeout: 10_000 }); + await page.getByRole("button", { name: /next prompt/i }).click(); await expect(page.getByText("PROMPT 2 / 3")).toBeVisible({ timeout: 10_000 }); }); @@ -140,8 +143,14 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { page.getByRole("button", { name: /save.*next/i }).click(), ]); - // Prompt should advance — variant injection extends deck from 3 to 4 + // The review panel keeps the just-answered prompt (snapshotted at submit + // time, before variant injection grew the deck) on the card. + await expect(page.getByText("PROMPT 2 / 3")).toBeVisible({ timeout: 10_000 }); + await page.getByRole("button", { name: /next prompt/i }).click(); + // Prompt should advance — variant injection extends deck from 3 to 4, + // and the runner names the growth instead of changing the count silently. await expect(page.getByText("PROMPT 3 / 4")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("+1 repair added to this session")).toBeVisible(); }); test("refresh page mid-run preserves progress", async ({ page }) => { diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index 1d57002..7ca6756 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -241,7 +241,7 @@ export default function ChatPage() { {/* Header */}
-

Source Chat

+

Chat

{messages.length > 0 && ( {startError && (

{startError}

@@ -269,22 +251,6 @@ export default function LearnPage() { )} - {/* Due cards alert — only meaningful when the course actually has decks - to review; without decks there is no due queue to send anyone to. */} - {course.deckCount > 0 && course.dueCardCount > 0 && ( -
-
- ! - - {course.dueCardCount} card{course.dueCardCount !== 1 ? "s" : ""} due for review - -
-

- Reviewing now helps retain information using spaced repetition. -

-
- )} - {/* Quick actions — focused on what matters now */}

Quick Actions

@@ -339,7 +305,10 @@ export default function LearnPage() {
- {/* Suggested learning path */} + {/* Suggested learning path — onboarding scaffolding for new courses. + Once materials are uploaded and the first deck exists, the user has + found the core loop and this checklist just repeats the nav. */} + {!(course.processedDocCount > 0 && course.deckCount > 0) && (

Suggested Learning Path

@@ -380,12 +349,6 @@ export default function LearnPage() { />
- - {/* Weekly XP */} - {data.weeklyXp > 0 && ( -
- {data.weeklyXp} XP earned this week -
)} ); @@ -482,32 +445,6 @@ const sectionLabelStyle: React.CSSProperties = { letterSpacing: "0.08em", }; -const miniStatStyle: React.CSSProperties = { - display: "flex", - flexDirection: "column", - alignItems: "center", - padding: "0.75rem 0.5rem", - border: "1px solid var(--color-border)", - borderRadius: "var(--radius)", - backgroundColor: "var(--color-bg-card)", -}; - -const miniStatNumStyle: React.CSSProperties = { - fontSize: "1.3rem", - fontWeight: 700, - color: "var(--color-primary)", - lineHeight: 1, - fontFamily: "var(--font-display)", -}; - -const miniStatLabelStyle: React.CSSProperties = { - fontSize: "0.65rem", - color: "var(--color-text-faint)", - marginTop: "0.3rem", - textTransform: "uppercase", - letterSpacing: "0.05em", -}; - const recommendationStyle: React.CSSProperties = { background: "var(--color-bg-selected)", border: "1px solid var(--color-border)", diff --git a/src/app/page.tsx b/src/app/page.tsx index 312464f..7ded8b4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -69,6 +69,20 @@ function isSameDay(a: Date, b: Date): boolean { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } +/** + * Session cards show a single quiet scope line. topic_scope is often a + * comma-joined objective list (sometimes with a "(+N more)" suffix); when it + * names more than 2 objectives, collapse to "N objectives" — the full scope + * lives on the session preflight screen. + */ +function formatScope(scope: string): string { + const moreMatch = scope.match(/\(\+(\d+) more\)\s*$/); + const base = moreMatch ? scope.slice(0, moreMatch.index).trim() : scope; + const parts = base.split(",").map((s) => s.trim()).filter(Boolean); + const total = parts.length + (moreMatch ? parseInt(moreMatch[1], 10) : 0); + return total > 2 ? `${total} objectives` : scope; +} + const MODE_COLORS: Record = { RETRIEVAL: "var(--color-info)", INTERLEAVED_PRACTICE: "var(--color-review)", @@ -214,6 +228,9 @@ export default function DashboardPage() { const streak = gameState?.streak ?? activityData?.streak ?? 0; const totalXp = gameState?.xpTotal ?? activityData?.total_xp ?? 0; + const earnedBadges = gameState?.achievements.length ?? 0; + // First run: nothing to show but zeros — render only the welcoming Today card. + const firstRun = plans.length === 0 && streak === 0 && totalXp === 0 && earnedBadges === 0; return (
@@ -247,71 +264,22 @@ export default function DashboardPage() {

Dashboard

- {/* XP Progress Ring + Stats Row */} -
-
- {/* XP Progress Ring — tap to adjust goal */} - - -
-
- {gameState?.xpToday ?? 0} / {gameState?.dailyXpGoal ?? 50} XP -
-
Daily Goal
-
- {totalXp} total XP · Tap to adjust -
-
- - {/* Streak */} -
- {streak} - Streak - {gameState && gameState.streakFreezes > 0 && ( - - {gameState.streakFreezes} freeze{gameState.streakFreezes !== 1 ? "s" : ""} - - )} -
- {/* Sessions */} -
- {stats.completed} - Sessions -
- {/* Accuracy */} -
- - {stats.avgAccuracy !== null ? `${stats.avgAccuracy}%` : "--"} - - Accuracy -
- {/* Hours */} -
- {stats.totalHours}h - Total -
-
-
- - {/* Today's Sessions — primary actionable section */} + {/* Today's Sessions — the primary section, first on the page */}

Today

{todaySessions.length === 0 ? (

- {plans.length === 0 ? "No study plans yet" : "No sessions today"} + {plans.length === 0 ? "Welcome to Study Bot" : "No sessions today"}

{plans.length === 0 - ? "Create a plan to get started." + ? "Create a study plan and your daily sessions will show up here." : "Rest is part of effective learning."}

{plans.length === 0 && ( - - Create Plan + + Create your plan )}
@@ -328,11 +296,11 @@ export default function DashboardPage() { {MODE_LABELS[item.mode] || item.mode} - - {item.topic_scope} + + {formatScope(item.topic_scope)} {actionable && ( - + {item.status === "IN_PROGRESS" ? "Continue" : "Start"} )} @@ -361,66 +329,76 @@ export default function DashboardPage() { )}
- {/* Create plan shortcut if plans exist */} - {plans.length > 0 && ( - - + New Plan - - )} - - {/* Achievements */} -
-
-

Achievements

- - View All ({gameState?.achievements.length || 0}/{TOTAL_BADGES}) - -
- {gameState && gameState.achievements.length > 0 ? ( -
- {gameState.achievements.map((a) => { - const info = BADGE_MAP[a.badgeType]; - if (!info) return null; - return ( - - {info.icon} - {info.label} - - ); - })} -
- ) : ( - -

- No badges earned yet. Start studying to unlock achievements! -

- - )} -
- - {/* Activity Heatmap */} - {activityData && ( -
-
-

Activity

-
- - {totalXp} XP - - + {/* Everything below is suppressed on first run — no data, only zeros */} + {!firstRun && ( + <> + {/* Stats — one quiet line, no cards */} +
+

+ + {gameState?.xpToday ?? 0} / {gameState?.dailyXpGoal ?? 50} XP today + + · + {streak} day streak + {gameState && gameState.streakFreezes > 0 && ` (${gameState.streakFreezes} freeze${gameState.streakFreezes !== 1 ? "s" : ""})`} + · + {stats.completed} session{stats.completed !== 1 ? "s" : ""} + · + {stats.avgAccuracy !== null ? `${stats.avgAccuracy}%` : "--"} accuracy + · + {stats.totalHours}h studied + · + {totalXp} XP total +

+
+ + {/* Create plan shortcut if plans exist */} + {plans.length > 0 && ( + + + New Plan + + )} + + {/* Achievements */} +
+
+

Achievements

+ + View All ({earnedBadges}/{TOTAL_BADGES}) +
-
- -
- )} - - {/* Streak milestones preview */} - {streak > 0 && ( -
- a.badgeType) ?? []} /> -
+ {gameState && gameState.achievements.length > 0 ? ( +
+ {gameState.achievements.map((a) => { + const info = BADGE_MAP[a.badgeType]; + if (!info) return null; + return ( + + {info.icon} + {info.label} + + ); + })} +
+ ) : ( + +

+ No badges earned yet. Start studying to unlock achievements! +

+ + )} + + + {/* Activity Heatmap — intentionally last */} + {activityData && ( +
+

Activity

+ +
+ )} + )}
@@ -583,79 +561,6 @@ function OnboardingFlow({ ); } -// ---- XP Progress Ring ---- - -function XpProgressRing({ current, goal }: { current: number; goal: number }) { - const pct = Math.min(current / goal, 1); - const r = 28; - const circumference = 2 * Math.PI * r; - const offset = circumference * (1 - pct); - const complete = pct >= 1; - - return ( - - {/* Background circle */} - - {/* Progress arc */} - - {/* Center text */} - - {complete ? "\u2713" : `${Math.round(pct * 100)}%`} - - - ); -} - -// ---- Streak Milestones ---- - -function StreakMilestones({ streak, earned }: { streak: number; earned: string[] }) { - const milestones = [ - { badge: "STREAK_3", days: 3 }, - { badge: "STREAK_7", days: 7 }, - { badge: "STREAK_14", days: 14 }, - { badge: "STREAK_30", days: 30 }, - { badge: "STREAK_60", days: 60 }, - { badge: "STREAK_100", days: 100 }, - ]; - - // Find next unearned milestone - const nextIdx = milestones.findIndex((m) => !earned.includes(m.badge)); - if (nextIdx === -1) return null; // All earned - - const next = milestones[nextIdx]; - const progress = Math.min(streak / next.days, 1); - const info = BADGE_MAP[next.badge]; - - return ( -
-
- - Next Milestone - - - {info?.icon} {info?.label} - -
-
-
-
-
- {streak} / {next.days} days -
-
- ); -} - // ---- Confetti Overlay ---- function ConfettiOverlay({ badge }: { badge: string | null }) { @@ -864,37 +769,19 @@ const sectionHeadingStyle: React.CSSProperties = { letterSpacing: "0.08em", }; -const statsGridStyle: React.CSSProperties = { - display: "grid", - gridTemplateColumns: "repeat(4, 1fr)", - gap: "0.5rem", -}; - -const statCardStyle: React.CSSProperties = { +const statStripStyle: React.CSSProperties = { display: "flex", - flexDirection: "column", - alignItems: "center", - padding: "1rem 0.5rem", - border: "1px solid var(--color-border-subtle)", - borderRadius: "var(--radius)", - backgroundColor: "var(--color-bg-card)", - boxShadow: "var(--shadow-card)", -}; - -const statNumberStyle: React.CSSProperties = { - fontSize: "1.5rem", - fontWeight: 700, - color: "var(--color-primary)", - lineHeight: 1, - fontFamily: "var(--font-display)", + flexWrap: "wrap", + alignItems: "baseline", + columnGap: "0.5rem", + rowGap: "0.2rem", + margin: 0, + fontSize: "0.8rem", + color: "var(--color-text-muted)", }; -const statLabelStyle: React.CSSProperties = { - fontSize: "0.75rem", +const statStripDotStyle: React.CSSProperties = { color: "var(--color-text-faint)", - marginTop: "0.4rem", - textTransform: "uppercase", - letterSpacing: "0.05em", }; const badgeStyle: React.CSSProperties = { @@ -924,6 +811,21 @@ const emptyCardStyle: React.CSSProperties = { color: "var(--color-text-secondary)", }; +// Filled primary — the single visual primary action on the page. +const primaryBtnStyle: React.CSSProperties = { + display: "inline-block", + padding: "0.45rem 1.1rem", + fontSize: "0.9rem", + fontWeight: 600, + color: "var(--color-bg-darkest)", + backgroundColor: "var(--color-primary)", + border: "none", + borderRadius: "var(--radius-sm)", + textDecoration: "none", + cursor: "pointer", + fontFamily: "inherit", +}; + const actionBtnStyle: React.CSSProperties = { padding: "0.5rem 1rem", fontSize: "0.9rem", diff --git a/src/app/plan/page.tsx b/src/app/plan/page.tsx index 3e708c5..88d053d 100644 --- a/src/app/plan/page.tsx +++ b/src/app/plan/page.tsx @@ -91,9 +91,13 @@ function formatTime(iso: string, tz?: string): string { /** Short per-session focus line: the item's own objectives, first 2 + "+N more". */ function focusLine(item: PlanItem): string { const titles = (item.objectives ?? []).map((o) => o.title).filter(Boolean); - if (titles.length === 0) return item.topic_scope; - const shown = titles.slice(0, 2).join(", "); - return titles.length > 2 ? `${shown} +${titles.length - 2} more` : shown; + // Fall back to topic_scope, but never print the full comma-joined list + const parts = + titles.length > 0 + ? titles + : item.topic_scope.split(",").map((s) => s.trim()).filter(Boolean); + const shown = parts.slice(0, 2).join(", "); + return parts.length > 2 ? `${shown} +${parts.length - 2} more` : shown; } function countdownText(plan: PlanDetail): string { @@ -701,7 +705,7 @@ export default function PlanPage() { {formatTime(item.end_time, tz)}
-
+
{focusLine(item)}
@@ -782,6 +786,8 @@ const inputStyle: React.CSSProperties = { function dropZoneStyle(busy: boolean): React.CSSProperties { return { + background: "transparent", + fontFamily: "inherit", border: "2px dashed var(--color-border)", borderRadius: "var(--radius)", padding: "1.25rem", diff --git a/src/app/s/[sessionId]/screens/end-screen.tsx b/src/app/s/[sessionId]/screens/end-screen.tsx index 74da44c..3dd3154 100644 --- a/src/app/s/[sessionId]/screens/end-screen.tsx +++ b/src/app/s/[sessionId]/screens/end-screen.tsx @@ -201,50 +201,6 @@ export function EndScreen({ run, session, onNewRun }: Props) { />
- {/* Attempts breakdown */} -
-

SCORE BREAKDOWN

-
- {metrics.correct_count > 0 && ( -
- )} - {metrics.partial_count > 0 && ( -
- )} - {metrics.incorrect_count > 0 && ( -
- )} -
-
- ✓ {metrics.correct_count} - ~ {metrics.partial_count} - ✗ {metrics.incorrect_count} -
-
- {/* Calibration Dashboard */} @@ -375,10 +331,11 @@ const sectionTitle: React.CSSProperties = { fontFamily: "var(--font-display)", }; +// Quiet outline button attached to the follow-ups list — deliberately not a +// second full-width primary; "Practice this session again" is the one CTA. const secondaryBtn: React.CSSProperties = { - width: "100%", - padding: "0.6rem 1.25rem", - fontSize: "0.95rem", + padding: "0.45rem 1rem", + fontSize: "0.85rem", fontFamily: "var(--font-body)", fontWeight: 600, background: "transparent", diff --git a/src/app/s/[sessionId]/screens/runner.tsx b/src/app/s/[sessionId]/screens/runner.tsx index 750af2c..709b296 100644 --- a/src/app/s/[sessionId]/screens/runner.tsx +++ b/src/app/s/[sessionId]/screens/runner.tsx @@ -21,6 +21,17 @@ interface Props { type UIPhase = "answering" | "scoring" | "error_log" | "review"; +/** What the student just answered, frozen at submit time — the parent + * advances run.current_prompt immediately, but post-answer phases must keep + * showing the question the feedback belongs to. */ +interface AnsweredPromptSnapshot { + text: string; + label: string; + index: number; + isPretest: boolean; + savedAnswer: string; +} + export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { const isExamSim = run.mode === "EXAM_SIM"; const isExamPhase = isExamSim && run.phase === "EXAM"; @@ -62,6 +73,16 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { // standard the student self-scores against. const [reveal, setReveal] = useState(null); const [revealLoading, setRevealLoading] = useState(false); + // Snapshot of the just-answered prompt, captured at submit time. The parent + // advances run.current_prompt as soon as the attempt lands, so post-answer + // phases would otherwise show the NEXT question above feedback that belongs + // to the one just answered. Cleared when the student advances (goNext). + const [answeredSnapshot, setAnsweredSnapshot] = useState(null); + // Reflection textareas are opt-in (collapsed by default) — one quiet toggle + // instead of two more boxes stacked into the review panel. + const [showReflection, setShowReflection] = useState(false); + // Touch/coarse-pointer viewports never see keyboard-shortcut hints. + const [coarsePointer, setCoarsePointer] = useState(false); const startTimeRef = useRef(Date.now()); const textareaRef = useRef(null); // Landing spot for keyboard focus when a phase swap unmounts the focused @@ -89,7 +110,24 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { : `REVIEWING ${currentIndex + 1} / ${total}` : `PROMPT ${currentIndex + 1} / ${total}`; - const progressPct = total > 0 ? (currentIndex / total) * 100 : 0; + // Progress never regresses: a repair prompt injected mid-run grows the + // total, which would pull the raw fraction backward. Floor the bar at the + // highest value seen this phase (EXAM_SIM's REVIEW pass restarts it). + const rawProgressPct = total > 0 ? (currentIndex / total) * 100 : 0; + const progressFloorRef = useRef({ phase: run.phase, pct: 0 }); + if (progressFloorRef.current.phase !== run.phase) { + progressFloorRef.current = { phase: run.phase, pct: 0 }; + } + if (rawProgressPct > progressFloorRef.current.pct) { + progressFloorRef.current.pct = rawProgressPct; + } + const progressPct = progressFloorRef.current.pct; + + // Deck growth = repair injection. Name it quietly instead of letting the + // "PROMPT k / N" count change silently. + const baseTotalRef = useRef(total); + if (baseTotalRef.current === 0) baseTotalRef.current = total; + const injectedRepairs = Math.max(0, total - baseTotalRef.current); // Get saved answer for REVIEW phase const savedAnswer = isReviewPhase && run.attempts @@ -105,6 +143,26 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { currentPrompt?.source_type === "ERROR_LOG"; const isMcq = currentPrompt?.format === "MCQ" && Array.isArray(currentPrompt.choices); + // Post-answer phases render the snapshot of the prompt just answered — the + // parent has already advanced current_prompt underneath us. The real + // current prompt takes over only when the student advances (goNext). + const shownSnapshot = + uiPhase === "review" || uiPhase === "error_log" ? answeredSnapshot : null; + const displayLabel = shownSnapshot?.label ?? progressLabel; + const displayText = shownSnapshot ? shownSnapshot.text : currentPrompt?.text ?? ""; + const displayIsPretest = shownSnapshot ? shownSnapshot.isPretest : isPretest; + const displaySavedAnswer = shownSnapshot ? shownSnapshot.savedAnswer : savedAnswer; + const displayIndex = shownSnapshot ? shownSnapshot.index : currentIndex; + + // The full diagnostic framing appears once per run (first pretest prompt), + // then compresses to a one-liner — repeating the two-line banner on every + // pretest item just trains banner-blindness. + const firstDiagnosticIndexRef = useRef(null); + if (isPretest && firstDiagnosticIndexRef.current === null) { + firstDiagnosticIndexRef.current = currentIndex; + } + const isFirstDiagnostic = firstDiagnosticIndexRef.current === displayIndex; + // Deferred feedback — generation starts server-side the moment the attempt // lands; poll until it's ready (PENDING means another worker owns it). // Every outcome — content, explicitly-empty (no_sources), UNAVAILABLE, @@ -146,8 +204,13 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { // phase), fetch the model answer + key points so self-scoring happens // against an explicit standard, not a feeling. Not for MCQ (server grades) // and never during the EXAM phase (delayed feedback is the point there). + // Eligibility spans scoring AND error_log as one boolean so the fetch + // survives the scoring -> error_log transition (the card stays up, dimmed, + // while the student writes the correction). + const revealEligible = + (uiPhase === "scoring" || uiPhase === "error_log") && !isExamPhase && !isMcq; useEffect(() => { - if (uiPhase !== "scoring" || isExamPhase || isMcq) return; + if (!revealEligible) return; if (reveal !== null || revealLoading) return; let mounted = true; setRevealLoading(true); @@ -157,7 +220,16 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { .finally(() => { if (mounted) setRevealLoading(false); }); return () => { mounted = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uiPhase, isExamPhase, currentIndex, run.run_id]); + }, [revealEligible, currentIndex, run.run_id]); + + // Keyboard-shortcut hints are noise on touch viewports. + useEffect(() => { + const mq = window.matchMedia("(pointer: coarse)"); + const update = () => setCoarsePointer(mq.matches); + update(); + mq.addEventListener("change", update); + return () => mq.removeEventListener("change", update); + }, []); const resetForPrompt = (key: string) => { uiPromptKeyRef.current = key; @@ -182,6 +254,8 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { setMcqConfidence(null); setReveal(null); setRevealLoading(false); + setAnsweredSnapshot(null); + setShowReflection(false); setHighlightedCitation(null); excerptRefs.current.clear(); startTimeRef.current = Date.now(); @@ -290,6 +364,19 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { } }; + // Freeze the prompt being answered BEFORE the submit lands — the parent + // advances run.current_prompt the moment the attempt is accepted, and the + // review/error_log UI must keep showing the question this feedback is for. + const snapshotAnswered = () => { + setAnsweredSnapshot({ + text: currentPrompt.text, + label: progressLabel, + index: currentIndex, + isPretest: !!isPretest, + savedAnswer, + }); + }; + const doExamAnswer = async () => { setSubmitting(true); const elapsed = Math.round((Date.now() - startTimeRef.current) / 1000); @@ -339,6 +426,7 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { // Immediate modes: the server grades the choice (the client never has // the answer key) and returns the outcome. + snapshotAnswered(); const res = await onSubmit({ prompt_index: currentIndex, user_answer: userAnswer, @@ -409,6 +497,7 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { } try { + snapshotAnswered(); const res = await onSubmit(attempt); if (!res) return; // break intercepted — nothing recorded clearDraft(); @@ -446,6 +535,7 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { } try { + snapshotAnswered(); const res = await onSubmit(attempt); if (!res) return; // break intercepted — nothing recorded setLastScore(s); @@ -544,6 +634,38 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { } }; + // The standard the student corrects against. Full-strength while scoring; + // dimmed (but still visible) behind the error log so the correction is + // written WITH the model answer on screen, not from memory of it. + const renderModelAnswerCard = (dimmed: boolean) => + !isMcq && reveal?.model_answer ? ( +
+

+ MODEL ANSWER — compare before you score +

+

+ {reveal.model_answer} +

+ {reveal.key_points && reveal.key_points.length > 0 && ( +
    + {reveal.key_points.map((kp, i) => ( +
  • {kp}
  • + ))} +
+ )} +
+ ) : null; + // Screen-reader announcement for the loop's key events: answer recorded, // outcome known, feedback arrived. Visually silent. const announceText = @@ -583,14 +705,13 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { color: "var(--color-text-muted)", }} > + {/* No running score tally here — the end screen owns the numbers; + mid-session score-watching competes with the work itself. */} {session.course_name} · {session.mode_label} {isExamPhase && · EXAM — feedback after all answers} {isReviewPhase && · REVIEW — score your answers} - - {run.metrics.correct_count}✓ {run.metrics.partial_count}~ {run.metrics.incorrect_count}✗ -
{/* Progress bar */} @@ -613,6 +734,14 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { />
+ {/* Repair injection grows the deck mid-session — say so in one quiet + line rather than letting the count change silently. */} + {injectedRepairs > 0 && ( +

+ +{injectedRepairs} repair{injectedRepairs === 1 ? "" : "s"} added to this session +

+ )} + {/* Prompt — the question is the biggest thing on screen */}
- {progressLabel} + {displayLabel}
- {isPretest && ( + {displayIsPretest && (
- DIAGNOSTIC — answer from what you already know. Being wrong here is - expected and useful: it primes you to learn this next. + {isFirstDiagnostic + ? "DIAGNOSTIC — answer from what you already know. Being wrong here is expected and useful: it primes you to learn this next." + : "Diagnostic — being wrong is useful"}
)}

- {currentPrompt.text} + {displayText}

- {/* REVIEW: show saved answer read-only */} - {isReviewPhase && savedAnswer && ( + {/* REVIEW: show saved answer read-only (snapshot while the review + panel is up — the parent has already advanced current_index) */} + {isReviewPhase && displaySavedAnswer && (
Your answer: -

{savedAnswer}

+

{displaySavedAnswer}

)} @@ -813,7 +944,11 @@ export function RunnerScreen({ run, session, onSubmit, onComplete }: Props) { )}
- Ctrl+Enter to submit + {/* Keyboard hint is meaningless on touch — keep the spacer span so + the submit button stays right-aligned. */} + + {coarsePointer ? "" : "Ctrl+Enter to submit"} +