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"} +