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