From 44c7185c9d5009542e33a2fa4a1b8c2588525185 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 15:35:21 -0400 Subject: [PATCH 01/60] docs(quiz): the frontend redesign contract (#537) Rulings, path ownership, component + data-layer APIs, the session state machine, the error-code map, screen specs and the entry/exit URL API that every implementer on this branch builds against. Co-Authored-By: Claude Fable 5 --- .../2026-08-22-quiz-frontend-contract.md | 486 ++++++++++++++++++ 1 file changed, 486 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md diff --git a/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md b/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md new file mode 100644 index 00000000..43283696 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md @@ -0,0 +1,486 @@ +# Quiz frontend redesign — the contract (#537, frontend) + +Status: binding for every implementer on `feat/537-quiz-frontend`. Amendments are made here, by the lead, +never in a subagent's head. Design source of truth: the Claude Design project "UI mockups for quiz screens" +(`Sapling Quiz Prototype.dc.html` — interactive, the authority; `Sapling Quiz Screens.dc.html` — static 1a–1d). +Backend is frozen: everything below targets the six quiz endpoints that exist on `main@7863210a`. + +--- + +## 0. Rulings (decided from recon; each records what it costs if wrong) + +| # | Ruling | Why | Cost if wrong | +|---|---|---|---| +| R-1 | **Styling = class names only, tokens only.** Quiz files and new `ui/` primitives carry **no `style={{}}`** except (a) binding a CSS custom property to runtime data (`style={{ "--quiz-accent": color } as CSSProperties}`) and (b) SVG geometry attributes (`cx`/`cy`/`r`/`x1`…) computed from data. Primitive rules are appended to `globals.css` in the house style (`.btn`, `.chip` precedent). Quiz screen rules live in per-screen CSS files under `components/quiz/**` imported by the screen component (App Router allows global CSS from any component). Every value is a `var(--token)`; no hex, no ad-hoc px except the design's own geometry constants declared once as tokens at the top of the quiz CSS (`--quiz-col-home: 780px` etc.). | The mission says zero inline styles; the repo's fidelity-bar screens are token-pure but inline-heavy (R2). Classes honour the stricter bar without inventing a new mechanism (no CSS Modules precedent exists). | Reviewer prefers inline tokens → mechanical conversion. | +| R-2 | **Every answer is recorded server-side as it happens** via `POST /attempts/{id}/answer`; the client-side **feedback mode** (`as-you-go` \| `at-end`) only decides *when* the verdict is shown. `/submit` is always called at the end (with the local answers as a belt-and-braces payload; server reconciliation makes recorded rows win). `generate` sends `include_answer_key: false`. | Makes leave-and-resume faithful in both modes and kills client-side grading (#546's intent). Feedback mode is not a backend option (gap G1) so it is a two-value client concept, stored in prefs — the "never hardcode option lists" rule applies to counts/difficulties, which come from `/config`. | If product wants feedback mode server-side later, only the prefs store moves. | +| R-3 | **Resume discovery** = localStorage session record (fast path) verified by `GET /attempts/{id}`, plus `GET /attempts?limit=20` filtered `status === "in_progress"` (other-device path). No abandon endpoint exists (G4): **Discard** hides the attempt client-side (`dismissedAttempts` in storage) and leaves a `TODO(#537-followup: abandon endpoint)` seam. | Backend frozen. | A stale in_progress row lingers until the 24h sweep. | +| R-4 | **Multi-concept scopes run as a queue of single-concept attempts.** `generate` is per `concept_node_id`, so "practice on a course" and "review everything due" are sessions over a queue (max **5** concepts per session = `get_recommendations`' own limit; generation rate limit is 8/300s) of **3-question** attempts by default, each with its own results screen and a "Next: {concept} →" primary exit while the queue has more. | Honest to the backend; keeps the three screens identical per attempt. | Constants `QUEUE_MAX`, `QUEUE_COUNT` tweak. | +| R-5 | **"Practise the one(s) you missed"** = a new attempt on the same concept, `intent: "review"`, `num_questions = clamp(missedCount, config.min, config.max)`, same difficulty. The backend's repetition guard means the questions will differ — accepted and labelled ("Focused on what you missed"). | No endpoint re-serves specific questions (G5). | None beyond copy. | +| R-6 | **"Ask about this"** opens a `Sheet` over the quiz: `startSessionStream({ topic: conceptName, course_id, mode: "socratic" })` then `streamChat` with a composed first message (stem, the student's answer, the correct answer, the explanation, "Help me understand why."). The session is left open on close (it remains in the tutor's session list; no `end-session` call). The attempt is untouched. | Only seeding path that exists (G6). | Orphan tutor sessions accumulate; a follow-up can add a seed field. | +| R-7 | **Ranking reuse**: quiz home mirrors `graph_service.get_recommendations` (tier ∈ struggling/learning/unexplored, non-root, `mastery_score` asc) client-side over the already-loaded graph, in one pure module with the citation comment. Primary slot prefers the first candidate with `times_studied > 0`. "Due" set = the same membership filter over the whole scoped graph (count + distinct courses). | R4; `/recommendations` returns only `{concept_name, reason}`. | If `/recommendations` is later enriched, swap the mirror for a join (TODO left). | +| R-8 | **Concept definition** on the primary proposal comes from `POST /api/graph/{user}/concept-description` for **that one card only**, with the fallback sentence "{Course} · {tier} · {n} connected concepts" while loading/on failure. | R4 — no stored description column. | One LLM call per home visit. | +| R-9 | **XP/streak line** = `GET /api/gamification/me` read at session start and again after submit; the line renders `+{Δxp} XP · {streak}-day streak`; if either read failed the XP segment is omitted (never invented). | G8: submit returns no deltas. | None. | +| R-10 | **Exits**: `returnToSource = source.returnTo ?? (conceptId ? `/tree?node=${conceptId}` : "/tree")`. The tree gains a `?node=` focus param (C1). "Done" → `/quiz`. Cancel from home → `returnToSource` or `/dashboard` when there is no source. **Nothing ever lands on `/learn` without a session.** | Mission; R5 found every exit hardcoded to `/learn`. | — | +| R-11 | **Flag** ("This question is confusing") is always available; it toggles local state + toast and persists nothing — seam `TODO(#537-followup: flag persistence)`. Timing/confidence are never sent (`time_ms`/`confidence` stay undefined) — seam noted in `lib/quiz/api.ts`. | Scope guard. | — | +| R-12 | **Tier thresholds are never recomputed** from `mastery_score`; the quiz reads `mastery_tier` off the wire. `tierFor()` in `nodeStyle.ts` exists only for the `growth` variant's *after* tier (the submit response carries `mastery_after` but no tier) and mirrors `backend/config.py::get_mastery_tier` with a citation + a test that pins it. | R3 (#557). | A fifth copy, but pinned. | +| R-13 | `quiz-product-flows.md` / `quiz-design-brief.md` were not available anywhere; the session model and machine below are derived from the mission text + the prototype. | Missing inputs. | Rework of `machine.ts` if the real §11 differs. | + +--- + +## 1. Directory layout and path ownership + +``` +frontend/src/ + lib/graph/nodeStyle.ts (+ .test.ts) A1 pure node-style layer, extracted from KG2D/KG3D + lib/graph/neighbourhood.ts (+ .test.ts) A1 deterministic sibling pick + components/graph/ConceptNode.tsx A1 + components/graph/ConceptNeighbourhood.tsx A1 + components/graph/KnowledgeGraph2D.tsx, 3D.tsx A1 delete local copies, import nodeStyle (golden snapshot proof) + components/ui/{Button,SegmentedControl,AnswerOption,ProgressDots,InlineBanner,Sheet,EmptyState}.tsx, index.ts A1 + components/Dialog.tsx A1 (only if focus-trap logic is factored for Sheet) + components/screens/Gradebook/Landing.tsx A1 (swap its private EmptyState for ui/EmptyState) + app/globals.css A1 (Wave 2) primitive classes appended; NOBODY else in Waves 3–5 + lib/quiz/{types,api,errors,errors.test,machine,machine.test,session,session.test,source,source.test, + proposals,proposals.test,relativeTime,relativeTime.test,exits,exits.test,prefs, + useQuizConfig,useQuizHome,useQuizSession,useGamificationDelta}.ts A2 + lib/api.ts A2 ApiError gains code/requestId/retryAfter; old quiz fns stay until D1 + lib/errorMessage.ts A2 humanizeError reads `error.message` + components/quiz/QuizScreen.tsx, quiz.css, index.ts A2 phase switch + shared layout classes; stubs for the three screens + components/quiz/home/** (QuizHome.tsx, ConceptDialog.tsx, AdjustDialog.tsx, PickList.tsx, home.css) B1 + components/quiz/question/** (QuizQuestion.tsx, AskPanel.tsx, LeaveDialog.tsx, question.css) B2 + components/quiz/results/** (QuizResults.tsx, MissedList.tsx, results.css) B3 + app/(shell)/quiz/page.tsx A2 mounts QuizScreen + eslint.config.mjs, docs/frontend-testids.md A2 (Wave 2 adds the new files/ids) · D1 (Wave 5 removes the old) + components/screens/Tree.tsx C1 + components/screens/Dashboard.tsx, SideNav.tsx, TopNav.tsx C2 + app/(shell)/notetaker/page.tsx C3 (+ deep-link handling lives in A2's source.ts; C3 verifies) + components/QuizPanel.tsx, QuizPanel.test.tsx, components/screens/Quiz.tsx D1 (delete) + frontend/e2e/quiz.spec.ts (+ new quiz-*.spec.ts) D2 + backend/tests/test_e2e_function_handlers.py D3 (verify only) +``` +Anything not listed is out of bounds; report the need to the lead. + +--- + +## 2. Shared vocabulary + +```ts +// lib/quiz/types.ts +export interface QuizConfig { // GET /api/quiz/config — the ONLY source of option lists + num_questions: { min: number; max: number; options: number[] }; + difficulties: string[]; // never enumerate these in code + question_types: string[]; +} +export type FeedbackMode = "as-you-go" | "at-end"; // client concept (R-2) +export interface QuizPrefs { count: number | null; difficulty: string | null; feedback: FeedbackMode } // localStorage "sapling_quiz_prefs" + +export interface WireOption { label: string; text: string } // keyless +export interface WireQuestion { id: number; question: string; options: WireOption[]; concept_tested?: string; difficulty: string } +export interface GenerateResult { quiz_id: string; questions: WireQuestion[]; requested_difficulty: string; + resolved_difficulty: string; requested_count: number; delivered_count: number } +export interface AnswerResult { question_index: number; question_id: number; is_correct: boolean; correct_index: number; + explanation: string; next_question: WireQuestion | null; recorded: boolean } +export interface SubmitResult { score: number; total: number; mastery_before: number; mastery_after: number; + results: { question_id: string; selected: string; correct: boolean; correct_answer: string; explanation: string }[] } +export type AttemptStatus = "completed" | "abandoned" | "in_progress"; +export interface AttemptSummary { quiz_id: string; status: AttemptStatus; concept_node_id: string; concept_name: string; + course_id: string | null; score: number | null; total: number | null; difficulty: string; mastery_before: number | null; + mastery_after: number | null; mastery_delta: number | null; created_at: string; completed_at: string | null } +export interface AttemptsPage { total: number; limit: number; offset: number; attempts: AttemptSummary[] } +export interface AttemptDetail { quiz_id: string; status: AttemptStatus; resumable: boolean; difficulty: string; + concept_node_id: string; questions: WireQuestion[]; responses: { question_index: number; selected_index: number; + is_correct: boolean; answered_at: string }[]; score: number | null; total: number | null; created_at: string } + +export type SourceKind = "tree" | "dashboard" | "notes" | "nav" | "link" | "quiz"; +export interface QuizSource { kind: SourceKind; returnTo?: string; conceptId?: string; noteId?: string } +export type QuizIntent = "practice" | "review"; +export type QuizScope = + | { kind: "concept"; conceptId: string } + | { kind: "course"; courseId: string; queue: string[] } // concept ids, weakest first, ≤ QUEUE_MAX + | { kind: "due"; queue: string[] } + | { kind: "missed"; conceptId: string; missedCount: number }; + +export interface QuizItem { index: number; question: WireQuestion; selectedIndex: number | null; + verdict: { isCorrect: boolean; correctIndex: number; explanation: string } | null; flagged: boolean } +export type Phase = "home" | "configuring" | "generating" | "active" | "answered" | "confirm-leave" + | "submitting" | "results" | "paused" | "error"; +export interface QuizSession { + intent: QuizIntent; scope: QuizScope; source: QuizSource; + config: { count: number; difficulty: string; feedback: FeedbackMode }; + conceptId: string; courseId: string | null; + attemptId: string | null; items: QuizItem[]; cursor: number; // cursor = index of the current item + queueIndex: number; // position in scope.queue (0 for concept/missed) + phase: Phase; error: QuizError | null; + result: SubmitResult | null; xp: { before: number; after: number; streak: number } | null; + deliveredShort: boolean; // delivered_count < requested_count +} +``` + +Constants (in `lib/quiz/session.ts`): `QUEUE_MAX = 5`, `QUEUE_COUNT = 3`, `STORAGE_KEY = "sapling_quiz_session"`, +`PREFS_KEY = "sapling_quiz_prefs"`, `DISMISSED_KEY = "sapling_quiz_dismissed"`. + +--- + +## 3. Component API (A1) + +All components: `"use client"`, named exports, `data-testid` passthrough prop `testid?: string`, forward no +`style`. Class names below are the public CSS API; every rule uses tokens only. Course accent is read from +`var(--quiz-accent, var(--accent))` set by the screen root. + +### `lib/graph/nodeStyle.ts` (pure, no React) +```ts +export function hexToHsl(hex: string): { h: number; s: number; l: number } | null +export function hslToHex(h: number, s: number, l: number): string +export function shadeFor(baseHex: string, nodeId: string, as?: "css" | "hex"): string // default "css" = hsl(...) (2D); "hex" (3D) +export function radiusFor(mastery: number, isRoot?: boolean): number // 8 + m*12 ; root 22 +export function tierFor(score: number): "mastered" | "learning" | "struggling" | "unexplored" // 0.75/0.45/0.1 — cites backend/config.py get_mastery_tier +export const TIER_OPACITY: Record<"mastered"|"learning"|"struggling"|"unexplored", number> // 1 / .78 / .55 / .28 +export function opacityFor(tier: string): number // subject_root → 1 +export function edgeWidthFor(strength: number): number // 0.5 + s*1.2 +export function truncateLabel(name: string, max?: number): string // 18 +export const NODE_STROKE_OPACITY = 0.4 +export const GLOW = { pad: 8, opacity: 0.15, blur: 3 } as const +``` +Proof: extend `KnowledgeGraph2D.testmode.test.tsx`'s `snapshot()` with `fill`/`opacity`/`stroke-opacity`, capture a golden +BEFORE the refactor, assert equality AFTER; KG3D's existing `nodeColor`/`nodeVal` tests keep passing; add a frozen +`shadeFor` golden table (≥5 ids). + +### `lib/graph/neighbourhood.ts` +```ts +export interface NeighbourNode { id: string; name: string; mastery: number; tier: string; strength: number } +export function siblingsFor(centreId: string, nodes: GraphNode[], edges: GraphEdge[], n?: number): NeighbourNode[] +// real neighbours by strength desc (excluding ids starting "subject_root__"), then same-course peers ordered by hashSeed(id); n = 3 +``` + +### `` — `components/graph/ConceptNode.tsx` +```ts +type ConceptNodeVariant = { kind: "dot" } | { kind: "node" } | { kind: "growth"; before: number; after: number }; +interface ConceptNodeProps { size: number; mastery: number; tier: string; courseColor: string; nodeId: string; + label?: string; variant?: ConceptNodeVariant; isRoot?: boolean; animate?: boolean; title?: string; testid?: string } +``` +- `dot`: flat circle, fill `shadeFor`, opacity `opacityFor(tier)`, stroke same colour at `NODE_STROKE_OPACITY`. Used at 15px (question header) and 11px/14px (rows). +- `node`: the same mark plus the soft glow (`GLOW`) — 26px on quiz home. +- `growth`: dashed ring at `radiusFor(before)` (`stroke-dasharray 4 4`, opacity .5), filled circle at `radiusFor(after)` with `opacityFor(tierFor(after))`, glow `r+21`. On mount the filled circle grows ONCE from `before` to `after` over `var(--dur-slow)`; with `prefers-reduced-motion` (`usePrefersReducedMotion`) or `animate={false}` it renders the identical end state with no transition. The `` has `role="img"` and `aria-label` = `title` (e.g. "Recursion node grew from 29% to 46% mastery"). +- Mastery radius is scaled into `size` so 15px and 26px are the same mark at two sizes. + +### `` — `components/graph/ConceptNeighbourhood.tsx` +```ts +interface ConceptNeighbourhoodProps { centre: { id: string; name: string; mastery: number; tier: string }; + siblings: NeighbourNode[]; courseColor: string; width: number; height: number; scale: number; // scale 2 | 2.5 (×radiusFor) + centreVariant?: ConceptNodeVariant; showLabels?: boolean; ariaLabel: string; testid?: string } +``` +Layout: centre at (w/2 − small offset, h/2) per the prototype's three fixed sibling positions (top-left, top-right, bottom-left); +edges `stroke: var(--text-muted)` at opacity .2 and width `edgeWidthFor(strength)`; labels `font-size: var(--fs-xs)`, +`fill: var(--text-dim)`, truncated. Presets used: home 320×204 (scale 2.5), concept dialog 300×200 (scale 2), results 640×212 (scale 2.5, `centreVariant` growth). + +### `
void; }) { return ( -
-
- {semesterLabel || "This semester"} -
-

- A blank semester, ready to plant. -

-

- Drop in a syllabus and Sapling lays out every assignment, due date, and - weight, so you can see what's coming, not just what already happened. -

- -
+ + Upload syllabus + + } + /> ); } diff --git a/frontend/src/components/ui/AnswerOption.test.tsx b/frontend/src/components/ui/AnswerOption.test.tsx new file mode 100644 index 00000000..99d9e4ef --- /dev/null +++ b/frontend/src/components/ui/AnswerOption.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import React from "react"; +import { AnswerOption, type AnswerState } from "./AnswerOption"; + +afterEach(cleanup); + +const TEXT = "It stops the recursion by returning a result"; + +function setup(state: AnswerState, over: Partial> = {}) { + const onSelect = vi.fn(); + const utils = render( + , + ); + return { onSelect, ...utils }; +} + +describe("AnswerOption", () => { + it("is a radio carrying the letter and the text", () => { + setup("default"); + const row = screen.getByRole("radio"); + expect(row).toHaveAttribute("aria-checked", "false"); + expect(row).toHaveTextContent("B"); + expect(row).toHaveTextContent(TEXT); + expect(row).toHaveClass("answer-option", "answer-option--default"); + expect(row).toHaveAttribute("data-testid", "quiz-answer-option-B"); + }); + + it("is checked when selected, and when it was the chosen wrong answer", () => { + setup("selected"); + expect(screen.getByRole("radio")).toHaveAttribute("aria-checked", "true"); + cleanup(); + setup("chosen-wrong"); + expect(screen.getByRole("radio")).toHaveAttribute("aria-checked", "true"); + cleanup(); + setup("correct"); + // `correct` is the answer, not the student's pick. + expect(screen.getByRole("radio")).toHaveAttribute("aria-checked", "false"); + }); + + it("selects on click and on Enter/Space, because it is a real button", () => { + const { onSelect } = setup("default"); + const row = screen.getByRole("radio"); + fireEvent.click(row); + fireEvent.keyDown(row, { key: "Enter" }); + fireEvent.keyUp(row, { key: " " }); + // jsdom doesn't synthesise the click for key events, so assert the type + // that actually carries them: a + ); +} diff --git a/frontend/src/components/ui/Button.test.tsx b/frontend/src/components/ui/Button.test.tsx new file mode 100644 index 00000000..c1ad8dcb --- /dev/null +++ b/frontend/src/components/ui/Button.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import React from "react"; +import { Button } from "./Button"; + +afterEach(cleanup); + +describe("Button — the link variant (#537)", () => { + it("carries .btn--link and stays a real button", () => { + render(); + const btn = screen.getByRole("button", { name: "adjust" }); + expect(btn).toHaveClass("btn", "btn--link"); + expect(btn).toHaveAttribute("type", "button"); + }); + + it("leaves the other variants alone", () => { + render( + <> + + + + , + ); + // secondary is the default and adds no modifier + expect(screen.getByRole("button", { name: "secondary" }).className).toBe("btn"); + expect(screen.getByRole("button", { name: "primary" })).toHaveClass("btn--primary"); + expect(screen.getByRole("button", { name: "ghost" })).toHaveClass("btn--ghost", "btn--sm"); + }); + + it("exposes the open-dialog state the quiz's `adjust` link needs", () => { + render( + , + ); + const btn = screen.getByRole("button", { name: "adjust" }); + expect(btn).toHaveAttribute("aria-pressed", "true"); + expect(btn).toHaveAttribute("data-active", "true"); + }); +}); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index cc8f60eb..e4dda3df 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -1,14 +1,22 @@ "use client"; import React from "react"; -type Variant = "primary" | "secondary" | "ghost" | "danger"; +type Variant = "primary" | "secondary" | "ghost" | "danger" | "link"; type Size = "sm" | "md" | "lg" | "xl"; // Shared button primitive. Wraps the canonical .btn classes in globals.css so // every action button is one shape (6px) with consistent hover/transitions. -// - variant: primary (forest fill) | secondary (bordered, default) | ghost | danger +// - variant: primary (forest fill) | secondary (bordered, default) | ghost | +// danger | link // - size: sm | md (default) | lg (the hero size for de-pilled CTAs) // Pills are NOT a Button — use for segmented controls. +// +// `link` is a bare text button — no padding, border or background, muted until +// hover. It exists because "adjust", "Discard", "Pick something specific →" +// and "Done" are text, not chrome, and `ghost` keeps button padding so it +// still reads as a control (#537). `aria-pressed` / `data-active="true"` +// underlines it in the accent, which is how the quiz shows "this link's dialog +// is open". `size` is ignored by `link`: it has no padding to scale. export function Button({ variant = "secondary", size = "md", diff --git a/frontend/src/components/ui/EmptyState.test.tsx b/frontend/src/components/ui/EmptyState.test.tsx new file mode 100644 index 00000000..89c268ea --- /dev/null +++ b/frontend/src/components/ui/EmptyState.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import React from "react"; +import { EmptyState } from "./EmptyState"; + +afterEach(cleanup); + +describe("EmptyState", () => { + it("renders the title as a heading, with the body and the eyebrow", () => { + render( + , + ); + expect(screen.getByRole("heading", { name: "Your tree is empty" })).toBeInTheDocument(); + expect(screen.getByText(/Upload notes or talk to the tutor/)).toBeInTheDocument(); + expect(screen.getByText("Spring 2026")).toHaveClass("label-micro"); + expect(screen.getByTestId("quiz-empty-state")).toHaveClass("empty-state--md"); + }); + + it("turns a {label, href} action into a primary link out", () => { + render( + , + ); + const link = screen.getByRole("link", { name: "Go to dashboard" }); + expect(link).toHaveAttribute("href", "/dashboard"); + expect(link).toHaveClass("btn", "btn--primary"); + }); + + it("takes an arbitrary node when the caller needs a handler, not a link", () => { + const onUpload = vi.fn(); + render( + + Upload syllabus + + } + />, + ); + fireEvent.click(screen.getByRole("button", { name: "Upload syllabus" })); + expect(onUpload).toHaveBeenCalled(); + }); + + it("omits the parts it wasn't given", () => { + const { container } = render(); + expect(container.querySelector(".empty-state__body")).toBeNull(); + expect(container.querySelector(".empty-state__eyebrow")).toBeNull(); + expect(container.querySelector(".empty-state__action")).toBeNull(); + expect(container.querySelector(".empty-state__icon")).toBeNull(); + }); + + it("renders a decorative icon when asked", () => { + const { container } = render(); + const icon = container.querySelector(".empty-state__icon")!; + expect(icon).toHaveAttribute("aria-hidden", "true"); + expect(icon.querySelector("svg")).not.toBeNull(); + }); + + it("carries Gradebook's display-scale treatment under size=hero", () => { + const { container } = render(); + expect(container.querySelector(".empty-state")).toHaveClass("empty-state--hero"); + }); +}); diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx new file mode 100644 index 00000000..0e2e480e --- /dev/null +++ b/frontend/src/components/ui/EmptyState.tsx @@ -0,0 +1,72 @@ +"use client"; +import React from "react"; +import { Icon } from "@/components/Icon"; + +/** + * EmptyState — "there's nothing here yet, and here's the way out" (#537). + * + * Promoted from the private component inside `screens/Gradebook/Landing.tsx`, + * which was the only real empty state in the app; the quiz's two ("add a + * course", "your tree is empty") would otherwise have been the second and + * third one-off. Landing now imports this. + * + * `size="hero"` is Landing's treatment — a display-scale title that owns a + * whole blank screen. `md` (the default) is the in-page version the quiz uses. + * An empty state is never a dead end: `action` is the way out, and callers are + * expected to supply one. + */ +export interface EmptyStateProps { + title: string; + body?: string; + /** A link out, or any control the caller would rather build itself. */ + action?: { label: string; href: string } | React.ReactNode; + /** Mono/uppercase line above the title. */ + eyebrow?: string; + /** Name from `components/Icon`. */ + icon?: string; + size?: "md" | "hero"; + testid?: string; +} + +function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } { + return ( + typeof a === "object" && + a !== null && + "href" in a && + typeof (a as { href?: unknown }).href === "string" + ); +} + +export function EmptyState({ + title, + body, + action, + eyebrow, + icon, + size = "md", + testid, +}: EmptyStateProps) { + return ( +
+ {icon && ( + + )} + {eyebrow &&
{eyebrow}
} +

{title}

+ {body &&

{body}

} + {action && ( +
+ {isHrefAction(action) ? ( + + {action.label} + + ) : ( + action + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/InlineBanner.test.tsx b/frontend/src/components/ui/InlineBanner.test.tsx new file mode 100644 index 00000000..f9523ead --- /dev/null +++ b/frontend/src/components/ui/InlineBanner.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import React from "react"; +import { InlineBanner } from "./InlineBanner"; +import { Button } from "./Button"; + +afterEach(cleanup); + +describe("InlineBanner", () => { + it("announces itself — the strip appears without the user asking", () => { + render(You left a quiz on Recursion — 2 of 5 answered); + const banner = screen.getByRole("status"); + expect(banner).toHaveTextContent("You left a quiz on Recursion — 2 of 5 answered"); + expect(banner).toHaveClass("inline-banner", "inline-banner--accent"); + }); + + it("keeps its actions in their own slot, after the body", () => { + render( + + + + + } + > + You left a quiz on Recursion + , + ); + const banner = screen.getByTestId("quiz-resume-strip"); + expect(banner.querySelector(".inline-banner__body")).toHaveTextContent( + "You left a quiz on Recursion", + ); + const actions = banner.querySelector(".inline-banner__actions")!; + expect(actions.querySelectorAll("button")).toHaveLength(2); + expect(screen.getByTestId("quiz-resume-discard")).toHaveClass("btn--link"); + }); + + it("omits the actions slot entirely when there are none", () => { + const { container } = render(Nothing to do here); + expect(container.querySelector(".inline-banner__actions")).toBeNull(); + }); + + it("takes the neutral tone for strips that aren't about the course", () => { + render(Heads up); + expect(screen.getByRole("status")).toHaveClass("inline-banner--neutral"); + }); +}); diff --git a/frontend/src/components/ui/InlineBanner.tsx b/frontend/src/components/ui/InlineBanner.tsx new file mode 100644 index 00000000..98b2a3f3 --- /dev/null +++ b/frontend/src/components/ui/InlineBanner.tsx @@ -0,0 +1,32 @@ +"use client"; +import React from "react"; + +/** + * InlineBanner — a full-width strip under a page header (#537). + * + * The quiz's "You left a quiz on Recursion — 2 of 5 answered · Resume · + * Discard" line. There was no precedent for this anywhere in the app (Learn + * offers an ordinary "Resume session" button and nothing else), and Dashboard + * and Study both plausibly want the same shape later, so it lands here rather + * than inside the quiz. + * + * `role="status"` because it appears in response to state the user didn't just + * ask about — a resumable attempt found on another device should be announced, + * not silently painted. + */ +export interface InlineBannerProps { + children: React.ReactNode; + /** Right-aligned controls. Usually a secondary button and a link button. */ + actions?: React.ReactNode; + tone?: "accent" | "neutral"; + testid?: string; +} + +export function InlineBanner({ children, actions, tone = "accent", testid }: InlineBannerProps) { + return ( +
+
{children}
+ {actions &&
{actions}
} +
+ ); +} diff --git a/frontend/src/components/ui/ProgressDots.test.tsx b/frontend/src/components/ui/ProgressDots.test.tsx new file mode 100644 index 00000000..994ad88c --- /dev/null +++ b/frontend/src/components/ui/ProgressDots.test.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import React from "react"; +import { ProgressDots } from "./ProgressDots"; + +afterEach(cleanup); + +const kinds = (container: HTMLElement) => + Array.from(container.querySelectorAll(".progress-dots__dot")).map((d) => + d.className.replace("progress-dots__dot progress-dots__dot--", ""), + ); + +describe("ProgressDots", () => { + it("is one labelled image, not a list of anonymous dots", () => { + render(); + const el = screen.getByRole("img", { name: "Question 3 of 5" }); + expect(el).toHaveClass("progress-dots", "progress-dots--column"); + }); + + it("marks answered, current and upcoming", () => { + const { container } = render( + , + ); + expect(kinds(container)).toEqual(["done", "done", "current", "todo", "todo"]); + }); + + it("shows 'you are here' over 'you answered this' when revisiting", () => { + const { container } = render( + , + ); + expect(kinds(container)).toEqual(["done", "current", "done", "done"]); + }); + + it("renders one dot per item at any count, and nothing at zero", () => { + const { container, rerender } = render( + , + ); + expect(kinds(container)).toHaveLength(3); + rerender(); + expect(kinds(container)).toHaveLength(0); + }); + + it("takes the row orientation Onboarding's step indicator wants", () => { + render( + , + ); + const el = screen.getByTestId("onboarding-progress"); + expect(el).toHaveClass("progress-dots--row"); + expect(el).not.toHaveClass("progress-dots--column"); + }); +}); diff --git a/frontend/src/components/ui/ProgressDots.tsx b/frontend/src/components/ui/ProgressDots.tsx new file mode 100644 index 00000000..923d4815 --- /dev/null +++ b/frontend/src/components/ui/ProgressDots.tsx @@ -0,0 +1,51 @@ +"use client"; +import React from "react"; + +/** + * ProgressDots — where you are in a fixed-length sequence (#537). + * + * The column variant is the design's "branch": a hairline rail with a dot per + * question — filled for answered, a hollow accent ring for the current one, + * a smaller hollow dot for what's ahead. Onboarding has the same grammar + * inline and horizontal (`Onboarding.tsx`), which is what `orientation="row"` + * is for; this is the shared version. + * + * One `role="img"` with a spoken label ("Question 3 of 5") rather than a list + * of nine anonymous dots: the dots are a picture of the label, and reading + * them out individually tells a screen-reader user nothing. + */ +export interface ProgressDotsProps { + total: number; + /** 0-based index of the item being worked on. */ + current: number; + /** How many items are answered — contiguous from 0. */ + answered: number; + orientation?: "column" | "row"; + ariaLabel: string; + testid?: string; +} + +export function ProgressDots({ + total, + current, + answered, + orientation = "column", + ariaLabel, + testid, +}: ProgressDotsProps) { + return ( +
+ {Array.from({ length: Math.max(0, total) }, (_, i) => { + // Current wins over answered: revisiting an answered item still shows + // "you are here", which is the question the rail exists to answer. + const kind = i === current ? "current" : i < answered ? "done" : "todo"; + return ; + })} +
+ ); +} diff --git a/frontend/src/components/ui/SegmentedControl.test.tsx b/frontend/src/components/ui/SegmentedControl.test.tsx new file mode 100644 index 00000000..a01d4760 --- /dev/null +++ b/frontend/src/components/ui/SegmentedControl.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import React from "react"; +import { SegmentedControl } from "./SegmentedControl"; + +afterEach(cleanup); + +const OPTIONS = [ + { value: "easy", label: "easy" }, + { value: "medium", label: "medium" }, + { value: "hard", label: "hard" }, +]; + +function setup(over: Partial>> = {}) { + const onChange = vi.fn(); + const utils = render( + , + ); + return { onChange, ...utils }; +} + +describe("SegmentedControl", () => { + it("is a labelled radiogroup of radios with exactly one checked", () => { + setup(); + const group = screen.getByRole("radiogroup", { name: "Difficulty" }); + expect(group).toBeInTheDocument(); + const radios = screen.getAllByRole("radio"); + expect(radios).toHaveLength(3); + expect(radios.filter((r) => r.getAttribute("aria-checked") === "true")).toHaveLength(1); + expect(screen.getByRole("radio", { name: "medium" })).toHaveAttribute("aria-checked", "true"); + }); + + it("gives the group one tab stop — the selected option", () => { + setup(); + expect(screen.getByRole("radio", { name: "medium" })).toHaveAttribute("tabindex", "0"); + expect(screen.getByRole("radio", { name: "easy" })).toHaveAttribute("tabindex", "-1"); + expect(screen.getByRole("radio", { name: "hard" })).toHaveAttribute("tabindex", "-1"); + }); + + it("puts the tab stop on the first option when nothing is selected yet", () => { + setup({ value: "" }); + expect(screen.getByRole("radio", { name: "easy" })).toHaveAttribute("tabindex", "0"); + }); + + it("selects on click", () => { + const { onChange } = setup(); + fireEvent.click(screen.getByRole("radio", { name: "hard" })); + expect(onChange).toHaveBeenCalledWith("hard"); + }); + + it("moves and selects with the arrow keys, wrapping at both ends", () => { + const { onChange } = setup(); + const group = screen.getByRole("radiogroup"); + fireEvent.keyDown(group, { key: "ArrowRight" }); + expect(onChange).toHaveBeenLastCalledWith("hard"); + fireEvent.keyDown(group, { key: "ArrowLeft" }); + expect(onChange).toHaveBeenLastCalledWith("easy"); + cleanup(); + + const last = setup({ value: "hard" }); + fireEvent.keyDown(screen.getByRole("radiogroup"), { key: "ArrowRight" }); + expect(last.onChange).toHaveBeenLastCalledWith("easy"); + }); + + it("treats up/down like left/right, and Home/End as the ends", () => { + const { onChange } = setup(); + const group = screen.getByRole("radiogroup"); + fireEvent.keyDown(group, { key: "ArrowDown" }); + expect(onChange).toHaveBeenLastCalledWith("hard"); + fireEvent.keyDown(group, { key: "ArrowUp" }); + expect(onChange).toHaveBeenLastCalledWith("easy"); + fireEvent.keyDown(group, { key: "Home" }); + expect(onChange).toHaveBeenLastCalledWith("easy"); + fireEvent.keyDown(group, { key: "End" }); + expect(onChange).toHaveBeenLastCalledWith("hard"); + }); + + it("moves focus with the selection so the keyboard user follows it", () => { + setup(); + fireEvent.keyDown(screen.getByRole("radiogroup"), { key: "ArrowRight" }); + expect(document.activeElement).toBe(screen.getByRole("radio", { name: "hard" })); + }); + + it("skips a disabled option rather than landing on it", () => { + const { onChange } = setup({ + options: [ + { value: "easy", label: "easy" }, + { value: "medium", label: "medium" }, + { value: "hard", label: "hard", disabled: true }, + ], + }); + fireEvent.keyDown(screen.getByRole("radiogroup"), { key: "ArrowRight" }); + expect(onChange).toHaveBeenLastCalledWith("easy"); // wrapped past `hard` + fireEvent.click(screen.getByRole("radio", { name: "hard" })); + expect(onChange).not.toHaveBeenCalledWith("hard"); + expect(screen.getByRole("radio", { name: "hard" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("suffixes each option's testid with its value", () => { + setup(); + expect(screen.getByTestId("quiz-seg-difficulty")).toBeInTheDocument(); + expect(screen.getByTestId("quiz-seg-difficulty-easy")).toBeInTheDocument(); + expect(screen.getByTestId("quiz-seg-difficulty-hard")).toBeInTheDocument(); + }); + + it("takes numeric values, which is how the question-count row uses it", () => { + const onChange = vi.fn(); + render( + ({ value: v, label: `${v} questions` }))} + value={5} + onChange={onChange} + ariaLabel="Length" + testid="quiz-seg-count" + />, + ); + fireEvent.click(screen.getByRole("radio", { name: "10 questions" })); + expect(onChange).toHaveBeenCalledWith(10); + expect(screen.getByTestId("quiz-seg-count-3")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/SegmentedControl.tsx b/frontend/src/components/ui/SegmentedControl.tsx new file mode 100644 index 00000000..7c60e778 --- /dev/null +++ b/frontend/src/components/ui/SegmentedControl.tsx @@ -0,0 +1,121 @@ +"use client"; +import React from "react"; + +/** + * SegmentedControl — the design's mono/uppercase pick-one row (#537). + * + * NOT ``: that is the filled pill, a different visual language. This + * one is `.label-micro` type with a 2px accent underline on the selected + * option, which is the mechanic four screens already re-implement privately + * (Achievements, Admin, Learn's mobile tabs, FlashcardImportModal) — none of + * them shared, none of them mono. This is the shared one. + * + * Semantics are a real radiogroup: one tab stop for the whole control (roving + * tabindex), arrows move AND select, Home/End jump to the ends, and disabled + * options are skipped rather than focused-and-refused. + * + * The option list is always the caller's — the quiz reads its counts and + * difficulties off `GET /api/quiz/config` and never enumerates them in code. + */ +export interface SegmentedControlProps { + options: { value: V; label: string; disabled?: boolean }[]; + value: V; + onChange: (v: V) => void; + ariaLabel?: string; + labelledBy?: string; + testid?: string; +} + +export function SegmentedControl({ + options, + value, + onChange, + ariaLabel, + labelledBy, + testid, +}: SegmentedControlProps) { + const refs = React.useRef(new Map()); + + const enabled = options.filter((o) => !o.disabled); + const selectedIndex = enabled.findIndex((o) => o.value === value); + // Nothing selected yet → the first enabled option carries the tab stop, so + // the control is always reachable. + const tabStop = selectedIndex >= 0 ? value : enabled[0]?.value; + + const move = (delta: number) => { + if (enabled.length === 0) return; + const from = selectedIndex >= 0 ? selectedIndex : 0; + const next = enabled[(from + delta + enabled.length) % enabled.length]; + onChange(next.value); + refs.current.get(next.value)?.focus(); + }; + + const jump = (index: number) => { + const target = enabled[index]; + if (!target) return; + onChange(target.value); + refs.current.get(target.value)?.focus(); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case "ArrowRight": + case "ArrowDown": + e.preventDefault(); + move(1); + break; + case "ArrowLeft": + case "ArrowUp": + e.preventDefault(); + move(-1); + break; + case "Home": + e.preventDefault(); + jump(0); + break; + case "End": + e.preventDefault(); + jump(enabled.length - 1); + break; + default: + break; + } + }; + + return ( +
+ {options.map((option) => { + const checked = option.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/ui/Sheet.test.tsx b/frontend/src/components/ui/Sheet.test.tsx new file mode 100644 index 00000000..9c935833 --- /dev/null +++ b/frontend/src/components/ui/Sheet.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; +import React from "react"; +import { __resetScrollLocksForTests } from "@/lib/useScrollLock"; +import { Sheet } from "./Sheet"; + +afterEach(() => { + cleanup(); + __resetScrollLocksForTests(); + document.body.style.removeProperty("overflow-y"); + document.body.style.removeProperty("overflow-x"); +}); + +function open(over: Partial> = {}) { + const onClose = vi.fn(); + const utils = render( + +

Why is B the answer?

+ +
, + ); + return { onClose, ...utils }; +} + +describe("Sheet", () => { + it("is a modal dialog labelled by its own title", () => { + open(); + const panel = screen.getByRole("dialog", { name: "Ask about this" }); + expect(panel).toHaveAttribute("aria-modal", "true"); + expect(panel).toHaveClass("sheet", "sheet--right"); + expect(panel).toHaveTextContent("Why is B the answer?"); + }); + + it("renders into a portal on document.body, over the page", () => { + const { container } = open(); + // Nothing in the caller's own subtree… + expect(container.querySelector(".sheet")).toBeNull(); + // …but present in the document. + expect(document.body.querySelector(".sheet")).not.toBeNull(); + }); + + it("renders nothing at all when closed", () => { + render( + {}} title="Ask about this"> +

hidden

+
, + ); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("closes on Escape, on the close button, and on a backdrop click", () => { + const first = open(); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + expect(first.onClose).toHaveBeenCalledTimes(1); + cleanup(); + + const second = open(); + fireEvent.click(screen.getByTestId("quiz-ask-panel-close")); + expect(second.onClose).toHaveBeenCalledTimes(1); + cleanup(); + + const third = open(); + fireEvent.click(document.body.querySelector(".sheet-backdrop")!); + expect(third.onClose).toHaveBeenCalledTimes(1); + }); + + it("does not close when the click lands inside the panel", () => { + const { onClose } = open(); + fireEvent.click(screen.getByRole("dialog")); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("names its close button and suffixes its testid", () => { + open(); + const close = screen.getByRole("button", { name: "Close" }); + expect(close).toHaveAttribute("data-testid", "quiz-ask-panel-close"); + }); + + it("traps Tab inside the panel", () => { + open(); + const panel = screen.getByRole("dialog"); + const focusable = Array.from(panel.querySelectorAll("button")); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + last.focus(); + fireEvent.keyDown(panel, { key: "Tab" }); + expect(document.activeElement).toBe(first); + + first.focus(); + fireEvent.keyDown(panel, { key: "Tab", shiftKey: true }); + expect(document.activeElement).toBe(last); + }); + + it("moves focus into the panel on open and restores it on close", () => { + vi.useFakeTimers(); + const opener = document.createElement("button"); + document.body.appendChild(opener); + opener.focus(); + expect(document.activeElement).toBe(opener); + + const { unmount } = render( + {}} title="Ask about this"> + + , + ); + act(() => { + vi.advanceTimersByTime(50); + }); + expect(document.activeElement).not.toBe(opener); + expect(screen.getByRole("dialog").contains(document.activeElement)).toBe(true); + + unmount(); + expect(document.activeElement).toBe(opener); + opener.remove(); + vi.useRealTimers(); + }); + + it("takes its width as a custom property so the rule stays in the stylesheet", () => { + open({ width: 560 }); + expect(screen.getByRole("dialog").style.getPropertyValue("--sheet-width")).toBe("560px"); + }); +}); diff --git a/frontend/src/components/ui/Sheet.tsx b/frontend/src/components/ui/Sheet.tsx new file mode 100644 index 00000000..7dff6952 --- /dev/null +++ b/frontend/src/components/ui/Sheet.tsx @@ -0,0 +1,93 @@ +"use client"; +import React from "react"; +import { createPortal } from "react-dom"; +import { useOverlayBehaviour } from "@/components/Dialog"; +import { Icon } from "@/components/Icon"; + +/** + * Sheet — a panel that opens OVER the page, anchored right (#537). + * + * The quiz's "Ask about this" needs the question to stay visible while the + * tutor answers, which nothing in the app did: the notetaker's chat aside and + * the tree's node panel are permanent grid columns, and `Dialog` is centred. + * + * Everything modal about it — portal, scroll-lock, focus trap, Escape, focus + * restore — is `Dialog`'s own `useOverlayBehaviour`, so the two can't drift. + * All that differs is the geometry and the entrance: the panel slides in from + * the right, and doesn't when the viewer asked for reduced motion (the global + * `prefers-reduced-motion` rule zeroes the transition). + */ +export interface SheetProps { + open: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + /** Panel width in px. Capped at the viewport by the stylesheet. */ + width?: number; + side?: "right"; + initialFocusRef?: React.RefObject; + zIndex?: number; + testid?: string; +} + +export function Sheet({ + open, + onClose, + title, + children, + width = 480, + side = "right", + initialFocusRef, + zIndex = 100, + testid, +}: SheetProps) { + const titleId = `sheet-title-${React.useId()}`; + const { mounted, visible, panelRef, onKeyDown } = useOverlayBehaviour({ + open, + onClose, + initialFocusRef, + }); + + if (!mounted || !open) return null; + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+

+ {title} +

+ +
+
{children}
+
+
, + document.body, + ); +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 6eeba134..9de02a05 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -3,3 +3,11 @@ export { Chip } from "./Chip"; export { Toggle } from "./Toggle"; export { Badge } from "./Badge"; export { FilterPills } from "./FilterPills"; +// Added for the quiz redesign (#537) — shared, not quiz-only: each one +// replaces a pattern that was already re-implemented privately elsewhere. +export { SegmentedControl, type SegmentedControlProps } from "./SegmentedControl"; +export { AnswerOption, type AnswerOptionProps, type AnswerState } from "./AnswerOption"; +export { ProgressDots, type ProgressDotsProps } from "./ProgressDots"; +export { InlineBanner, type InlineBannerProps } from "./InlineBanner"; +export { Sheet, type SheetProps } from "./Sheet"; +export { EmptyState, type EmptyStateProps } from "./EmptyState"; From fd19d2de71b7b00bc2ffa9d7cd79273866ec1a2b Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:04:42 -0400 Subject: [PATCH 10/60] feat(quiz): the four hooks that own the quiz's effects (#537 A2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useQuizSession` is the only place a quiz call happens. It chains off the session the reducer HANDS BACK rather than off a phase-watching effect: an effect keyed on `phase === "submitting"` re-fires whenever that phase is re-entered, and a dismissed submit error does exactly that — double-submitting an attempt is a 409 the student would have to read. - Every answer goes to `POST /attempts/{id}/answer` as it happens (R-2); the feedback mode only decides whether the verdict is shown. One retry, and only for a transport failure — `/answer` is idempotent on `(attempt_id, question_index)`, so replaying a dropped call is safe, while retrying a rejected one just spends the rate limit. - Persistence rides every transition plus `beforeunload` and unmount, so "answered, then navigated away" is resumable without a leave dialog. - `useQuizHome` bootstraps courses+graph exactly the way `screens/Quiz.tsx` does today (semester-hydration gate included) and adds resume discovery: the stored attempt id is only a hint, `GET /attempts/{id}` decides. A failing history read costs the history, not the home screen. - `useGamificationDelta` reads `/gamification/me` before and after, because submit returns no XP (G8). If either read fails the whole line is dropped rather than showing a delta we'd have invented. - Two events beyond §4's list, both documented at the union: `FAILED` (a resume 409 has nowhere else to land) and `SET_CONFIG` (the Adjust dialog's "Done" changes settings without starting). Co-Authored-By: Claude Fable 5 --- frontend/src/lib/quiz/machine.test.ts | 55 +++ frontend/src/lib/quiz/machine.ts | 33 +- frontend/src/lib/quiz/useGamificationDelta.ts | 69 +++ frontend/src/lib/quiz/useQuizConfig.test.ts | 102 ++++ frontend/src/lib/quiz/useQuizConfig.ts | 55 +++ frontend/src/lib/quiz/useQuizHome.test.ts | 253 ++++++++++ frontend/src/lib/quiz/useQuizHome.ts | 238 +++++++++ frontend/src/lib/quiz/useQuizSession.test.ts | 461 ++++++++++++++++++ frontend/src/lib/quiz/useQuizSession.ts | 384 +++++++++++++++ 9 files changed, 1649 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/quiz/useGamificationDelta.ts create mode 100644 frontend/src/lib/quiz/useQuizConfig.test.ts create mode 100644 frontend/src/lib/quiz/useQuizConfig.ts create mode 100644 frontend/src/lib/quiz/useQuizHome.test.ts create mode 100644 frontend/src/lib/quiz/useQuizHome.ts create mode 100644 frontend/src/lib/quiz/useQuizSession.test.ts create mode 100644 frontend/src/lib/quiz/useQuizSession.ts diff --git a/frontend/src/lib/quiz/machine.test.ts b/frontend/src/lib/quiz/machine.test.ts index 5241e2ed..c0065f61 100644 --- a/frontend/src/lib/quiz/machine.test.ts +++ b/frontend/src/lib/quiz/machine.test.ts @@ -503,6 +503,59 @@ describe("FINISH", () => { }); }); +describe("FAILED and SET_CONFIG (the two events beyond §4's list)", () => { + const ABANDONED = { + code: "QUIZ_ATTEMPT_ABANDONED" as const, + message: "expired", + retryable: false, + }; + + it("FAILED surfaces a resume failure on quiz home", () => { + const home = initialSession(entry(), CONFIG, PREFS); + const failed = reduce(home, { type: "FAILED", error: ABANDONED }); + expect(failed.phase).toBe("error"); + expect(failed.error).toEqual(ABANDONED); + expect(reduce(failed, { type: "DISMISS_ERROR" }).phase).toBe("home"); + }); + + it("FAILED never clobbers a live attempt", () => { + const active = activeSession(3); + expect(reduce(active, { type: "FAILED", error: ABANDONED })).toBe(active); + const confirming = reduce(active, { type: "REQUEST_LEAVE" }); + expect(reduce(confirming, { type: "FAILED", error: ABANDONED })).toBe(confirming); + }); + + it("FAILED does not overwrite an error already on screen", () => { + const home = initialSession(entry(), CONFIG, PREFS); + const first = reduce(home, { type: "FAILED", error: ABANDONED }); + const second = reduce(first, { + type: "FAILED", + error: { code: "NETWORK", message: "offline", retryable: true }, + }); + expect(second).toBe(first); + }); + + it("SET_CONFIG holds an Adjust-dialog choice made without starting", () => { + const home = initialSession(entry(), CONFIG, PREFS); + const tuned = reduce(home, { + type: "SET_CONFIG", + config: { count: 10, difficulty: "hard", feedback: "as-you-go" }, + }); + expect(tuned.config).toEqual({ count: 10, difficulty: "hard", feedback: "as-you-go" }); + expect(tuned.phase).toBe("home"); + }); + + it("SET_CONFIG is ignored mid-quiz — the attempt is already generated", () => { + const active = activeSession(3); + expect( + reduce(active, { + type: "SET_CONFIG", + config: { count: 10, difficulty: "hard", feedback: "as-you-go" }, + }), + ).toBe(active); + }); +}); + // ── The six invariants (§4) ──────────────────────────────────────────────── describe("invariant 1 — nothing walks out of a live quiz by accident", () => { @@ -516,6 +569,8 @@ describe("invariant 1 — nothing walks out of a live quiz by accident", () => { { type: "SUBMITTED", result: SUBMITTED, xp: null }, { type: "DISMISS_ERROR" }, { type: "CANCEL_LEAVE" }, + { type: "FAILED", error: { code: "NETWORK", message: "offline", retryable: true } }, + { type: "SET_CONFIG", config: { count: 10, difficulty: "hard", feedback: "as-you-go" } }, ]; it("no event takes active to home or to an exit", () => { diff --git a/frontend/src/lib/quiz/machine.ts b/frontend/src/lib/quiz/machine.ts index 8dd84cdd..15ac5a87 100644 --- a/frontend/src/lib/quiz/machine.ts +++ b/frontend/src/lib/quiz/machine.ts @@ -62,7 +62,23 @@ export type QuizEvent = | { type: "NEXT_IN_QUEUE"; courseId?: string | null } | { type: "EXIT" } | { type: "FLAG" } - | { type: "DISMISS_ERROR" }; + | { type: "DISMISS_ERROR" } + // ── Two events beyond §4's list, both forced by effects that have nowhere + // else to land. Documented as such rather than smuggled in. + /** + * A failure with no phase of its own: `resume` 409s on an attempt the 24h + * sweep abandoned, or the initial load falls over. §4 lists a failure event + * for generate, answer and submit but not for these, and swallowing a + * `QUIZ_ATTEMPT_ABANDONED` would leave the resume strip permanently broken + * with no explanation. Only accepted from a phase with nothing live. + */ + | { type: "FAILED"; error: QuizError } + /** + * The Adjust dialog's "Done" (§5 B1.6) changes length/difficulty/feedback + * WITHOUT starting a quiz, so the choice has to live somewhere before the + * next `START` carries it. Only accepted where no attempt is in flight. + */ + | { type: "SET_CONFIG"; config: SessionConfig }; /** * The count used before `GET /api/quiz/config` resolves. Not an option list — @@ -419,6 +435,21 @@ export function reduce(session: QuizSession, event: QuizEvent): QuizSession { return { ...session, phase: errorReturnPhase(session), error: null }; } + case "FAILED": { + // Never over a live attempt — an in-flight quiz has ANSWER_FAILED / + // SUBMIT_FAILED, which keep the items so DISMISS_ERROR can go back to them. + if (!canExit(session)) return session; + if (session.phase === "error") return session; + return { ...session, phase: "error", error: event.error }; + } + + case "SET_CONFIG": { + if (session.phase !== "home" && session.phase !== "configuring" && session.phase !== "results") { + return session; + } + return { ...session, config: event.config }; + } + default: return session; } diff --git a/frontend/src/lib/quiz/useGamificationDelta.ts b/frontend/src/lib/quiz/useGamificationDelta.ts new file mode 100644 index 00000000..4c60366d --- /dev/null +++ b/frontend/src/lib/quiz/useGamificationDelta.ts @@ -0,0 +1,69 @@ +"use client"; + +/** + * The XP/streak line on the results screen (R-9). + * + * `POST /api/quiz/submit` pays XP through `award_xp_safe` and bumps the streak + * inside `apply_graph_update`, but returns neither (gap G8) — the response is + * score/total/mastery only. The only way to show "+30 XP · 4-day streak" is to + * read `GET /api/gamification/me` before the session and again after the submit + * and subtract. + * + * Both reads are best-effort. If either fails the whole line is omitted rather + * than showing a delta we'd have had to invent. + */ + +import { useCallback, useRef, useState } from "react"; +import { fetchGamificationMe } from "@/lib/api"; +import type { GamificationMe } from "@/lib/types"; +import type { QuizSession } from "./types"; + +export interface GamificationDelta { + /** The pre-quiz snapshot, for anything that wants to render it live. */ + before: GamificationMe | null; + /** Take the "before" reading. Call at session start; never throws. */ + snapshotBefore(): Promise; + /** Take the "after" reading. Call once the submit has landed; never throws. */ + readAfter(): Promise<{ xp: number; streak: number } | null>; + /** The two composed into the session's `xp` field — `null` if either read + * failed, or if no snapshot was taken. */ + deltaAfterSubmit(): Promise; +} + +export function useGamificationDelta(userId: string): GamificationDelta { + const [before, setBefore] = useState(null); + // The ref is what the async submit chain reads: a closure captured at render + // time would still be holding the pre-snapshot `null`. + const beforeRef = useRef(null); + + const snapshotBefore = useCallback(async () => { + if (!userId) return; + try { + const me = await fetchGamificationMe(userId); + beforeRef.current = me; + setBefore(me); + } catch { + beforeRef.current = null; + setBefore(null); + } + }, [userId]); + + const readAfter = useCallback(async () => { + if (!userId) return null; + try { + const me = await fetchGamificationMe(userId); + return { xp: me.total_xp, streak: me.streak }; + } catch { + return null; + } + }, [userId]); + + const deltaAfterSubmit = useCallback(async () => { + const start = beforeRef.current; + const end = await readAfter(); + if (!start || !end) return null; + return { before: start.total_xp, after: end.xp, streak: end.streak }; + }, [readAfter]); + + return { before, snapshotBefore, readAfter, deltaAfterSubmit }; +} diff --git a/frontend/src/lib/quiz/useQuizConfig.test.ts b/frontend/src/lib/quiz/useQuizConfig.test.ts new file mode 100644 index 00000000..fef5282e --- /dev/null +++ b/frontend/src/lib/quiz/useQuizConfig.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/lib/api"; + +const quizApi = vi.hoisted(() => ({ fetchQuizConfig: vi.fn() })); +vi.mock("./api", () => quizApi); + +const gamification = vi.hoisted(() => ({ fetchGamificationMe: vi.fn() })); +vi.mock("@/lib/api", async importActual => { + const actual = await importActual(); + return { ...actual, fetchGamificationMe: gamification.fetchGamificationMe }; +}); + +import { resetQuizConfigCache, useQuizConfig } from "./useQuizConfig"; +import { useGamificationDelta } from "./useGamificationDelta"; + +const CONFIG = { + num_questions: { min: 1, max: 10, options: [3, 5, 10] }, + difficulties: ["easy", "medium", "hard", "adaptive"], + question_types: ["multiple_choice"], +}; + +function me(total_xp: number, streak: number) { + return { + level: 3, next_level: 4, stage: "sprout", total_xp, xp_into_level: 10, xp_for_level: 100, + level_pct: 10, streak, longest_streak: streak, daily_goal_xp: 50, today_xp: 10, + earned_count: 1, total_count: 10, + }; +} + +beforeEach(() => { + resetQuizConfigCache(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useQuizConfig", () => { + it("fetches once and shares the result across mounts", async () => { + quizApi.fetchQuizConfig.mockResolvedValue(CONFIG); + const a = renderHook(() => useQuizConfig()); + const b = renderHook(() => useQuizConfig()); + await waitFor(() => expect(a.result.current.config).toEqual(CONFIG)); + await waitFor(() => expect(b.result.current.config).toEqual(CONFIG)); + expect(quizApi.fetchQuizConfig).toHaveBeenCalledTimes(1); + }); + + it("maps a failure to quiz error copy and lets the next mount retry", async () => { + quizApi.fetchQuizConfig.mockRejectedValueOnce(new ApiError("down", 500)); + const first = renderHook(() => useQuizConfig()); + await waitFor(() => expect(first.result.current.error).not.toBeNull()); + expect(first.result.current.error?.code).toBe("QUIZ_INTERNAL_ERROR"); + + quizApi.fetchQuizConfig.mockResolvedValue(CONFIG); + const second = renderHook(() => useQuizConfig()); + await waitFor(() => expect(second.result.current.config).toEqual(CONFIG)); + }); +}); + +describe("useGamificationDelta", () => { + it("subtracts the two reads", async () => { + gamification.fetchGamificationMe + .mockResolvedValueOnce(me(100, 3)) + .mockResolvedValueOnce(me(130, 4)); + + const { result } = renderHook(() => useGamificationDelta("u1")); + await result.current.snapshotBefore(); + await waitFor(() => expect(result.current.before?.total_xp).toBe(100)); + await expect(result.current.deltaAfterSubmit()).resolves.toEqual({ + before: 100, + after: 130, + streak: 4, + }); + }); + + it("returns null when the before read failed — never an invented delta", async () => { + gamification.fetchGamificationMe.mockRejectedValueOnce(new Error("down")); + gamification.fetchGamificationMe.mockResolvedValueOnce(me(130, 4)); + + const { result } = renderHook(() => useGamificationDelta("u1")); + await result.current.snapshotBefore(); + await expect(result.current.deltaAfterSubmit()).resolves.toBeNull(); + }); + + it("returns null when the after read failed", async () => { + gamification.fetchGamificationMe.mockResolvedValueOnce(me(100, 3)); + gamification.fetchGamificationMe.mockRejectedValueOnce(new Error("down")); + + const { result } = renderHook(() => useGamificationDelta("u1")); + await result.current.snapshotBefore(); + await expect(result.current.deltaAfterSubmit()).resolves.toBeNull(); + }); + + it("does nothing without a user id", async () => { + const { result } = renderHook(() => useGamificationDelta("")); + await result.current.snapshotBefore(); + await expect(result.current.readAfter()).resolves.toBeNull(); + expect(gamification.fetchGamificationMe).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/quiz/useQuizConfig.ts b/frontend/src/lib/quiz/useQuizConfig.ts new file mode 100644 index 00000000..05392426 --- /dev/null +++ b/frontend/src/lib/quiz/useQuizConfig.ts @@ -0,0 +1,55 @@ +"use client"; + +/** + * `GET /api/quiz/config` — fetched once per page load and shared. + * + * The config endpoint is the single source of truth for the count and + * difficulty option lists (#540 A2): the UI must never offer a value the route + * would reject. It is unauthenticated, immutable for the life of a deploy and + * tiny, so one in-flight promise is cached at module scope and every consumer + * shares it. `resetQuizConfigCache` exists for tests. + */ + +import { useEffect, useState } from "react"; +import { fetchQuizConfig } from "./api"; +import { describeQuizError, type QuizError } from "./errors"; +import type { QuizConfig } from "./types"; + +let cached: Promise | null = null; + +function load(): Promise { + if (!cached) { + cached = fetchQuizConfig().catch(err => { + // Don't cache a failure — the next mount should be allowed to try again. + cached = null; + throw err; + }); + } + return cached; +} + +export function resetQuizConfigCache(): void { + cached = null; +} + +export function useQuizConfig(): { config: QuizConfig | null; error: QuizError | null } { + const [config, setConfig] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + load().then( + value => { + if (!cancelled) setConfig(value); + }, + err => { + if (!cancelled) setError(describeQuizError(err)); + }, + ); + return () => { + cancelled = true; + }; + }, []); + + return { config, error }; +} diff --git a/frontend/src/lib/quiz/useQuizHome.test.ts b/frontend/src/lib/quiz/useQuizHome.test.ts new file mode 100644 index 00000000..351fc97b --- /dev/null +++ b/frontend/src/lib/quiz/useQuizHome.test.ts @@ -0,0 +1,253 @@ +// @vitest-environment jsdom +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/lib/api"; +import type { GraphNode } from "@/lib/types"; + +const quizApi = vi.hoisted(() => ({ + fetchQuizConfig: vi.fn(), + generateQuiz: vi.fn(), + answerQuestion: vi.fn(), + submitQuiz: vi.fn(), + getAttempt: vi.fn(), + listAttempts: vi.fn(), + describeConcept: vi.fn(), +})); +vi.mock("./api", () => quizApi); + +const coreApi = vi.hoisted(() => ({ getCourses: vi.fn(), getGraph: vi.fn() })); +vi.mock("@/lib/api", async importActual => { + const actual = await importActual(); + return { ...actual, getCourses: coreApi.getCourses, getGraph: coreApi.getGraph }; +}); + +import { fallbackDefinition, useQuizHome } from "./useQuizHome"; +import { dismissAttempt, saveSession } from "./session"; +import { initialSession } from "./machine"; +import { DEFAULT_PREFS } from "./prefs"; +import type { AttemptSummary } from "./types"; + +function node(over: Partial & { id: string }): GraphNode { + return { + concept_name: over.id, + mastery_score: 0.3, + mastery_tier: "struggling", + times_studied: 1, + last_studied_at: null, + subject: "CS", + course_id: "course-a", + ...over, + }; +} + +const COURSES = [ + { + enrollment_id: "e1", course_id: "course-a", course_code: "CS 330", course_name: "Algorithms", + school: "BU", department: "CS", color: "#123456", nickname: null, node_count: 3, + enrolled_at: "2026-01-01T00:00:00Z", term: "Fall 2026", terms: ["Fall 2026"], + }, +]; + +function attempt(over: Partial & { quiz_id: string }): AttemptSummary { + return { + status: "in_progress", + concept_node_id: "n1", + concept_name: "n1", + course_id: "course-a", + score: null, + total: null, + difficulty: "medium", + mastery_before: null, + mastery_after: null, + mastery_delta: null, + created_at: "2026-08-22T09:00:00Z", + completed_at: null, + ...over, + }; +} + +beforeEach(() => { + window.localStorage.clear(); + coreApi.getCourses.mockResolvedValue({ courses: COURSES }); + coreApi.getGraph.mockResolvedValue({ + nodes: [ + node({ id: "n-new", mastery_score: 0, mastery_tier: "unexplored", times_studied: 0 }), + node({ id: "n1", mastery_score: 0.29, times_studied: 3 }), + node({ id: "n2", mastery_score: 0.44, mastery_tier: "learning" }), + node({ id: "n-done", mastery_score: 0.9, mastery_tier: "mastered" }), + ], + edges: [], + stats: {}, + }); + quizApi.listAttempts.mockResolvedValue({ total: 0, limit: 20, offset: 0, attempts: [] }); + quizApi.getAttempt.mockResolvedValue({ resumable: false }); + quizApi.describeConcept.mockResolvedValue("Recursion is a function calling itself."); +}); + +afterEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); +}); + +describe("bootstrap", () => { + it("waits for the active semester to hydrate before fetching", async () => { + renderHook(() => useQuizHome("u1", null)); + await new Promise(r => setTimeout(r, 0)); + expect(coreApi.getGraph).not.toHaveBeenCalled(); + }); + + it("fetches unscoped for 'All semesters' and scoped for a term", async () => { + const { rerender } = renderHook(({ s }: { s: string }) => useQuizHome("u1", s), { + initialProps: { s: "" }, + }); + await waitFor(() => expect(coreApi.getGraph).toHaveBeenCalledWith("u1", undefined)); + + rerender({ s: "Fall 2026" }); + await waitFor(() => expect(coreApi.getGraph).toHaveBeenCalledWith("u1", "Fall 2026")); + }); + + it("ranks candidates, picks a studied primary, and counts the due set", async () => { + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("ready")); + + expect(result.current.candidates.map(c => c.node.id)).toEqual(["n-new", "n1", "n2"]); + // R-7: the primary prefers the weakest concept actually studied. + expect(result.current.primary?.node.id).toBe("n1"); + expect(result.current.alternatives.map(c => c.node.id)).toEqual(["n-new", "n2"]); + expect(result.current.due.count).toBe(3); + expect(result.current.due.courseCount).toBe(1); + expect(result.current.byCourse[0].course.course_code).toBe("CS 330"); + }); + + it("keeps the home screen when only the history read fails", async () => { + quizApi.listAttempts.mockRejectedValue(new ApiError("boom", 500)); + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("ready")); + expect(result.current.attempts).toEqual([]); + }); + + it("reports an error when the graph read fails", async () => { + coreApi.getGraph.mockRejectedValue(new ApiError("boom", 500, { code: "QUIZ_INTERNAL_ERROR" })); + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("error")); + expect(result.current.error?.message).toBe( + "Something went wrong on our side. Try again in a moment.", + ); + }); +}); + +describe("resume discovery (R-3)", () => { + it("verifies the stored attempt id against the wire before offering it", async () => { + const stored = { + ...initialSession({ source: { kind: "tree" }, concept: "n1" }, null, DEFAULT_PREFS), + attemptId: "attempt-stored", + phase: "paused" as const, + }; + saveSession(stored); + quizApi.getAttempt.mockResolvedValue({ + quiz_id: "attempt-stored", + status: "in_progress", + resumable: true, + difficulty: "medium", + concept_node_id: "n1", + questions: [], + responses: [{ question_index: 0, selected_index: 1, is_correct: true, answered_at: "x" }], + score: null, + total: null, + created_at: "2026-08-22T09:00:00Z", + }); + + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.resumable).not.toBeNull()); + expect(quizApi.getAttempt).toHaveBeenCalledWith("attempt-stored"); + expect(result.current.resumable?.answered).toBe(1); + expect(result.current.resumable?.session?.attemptId).toBe("attempt-stored"); + }); + + it("finds an in_progress attempt started on another device", async () => { + quizApi.listAttempts.mockResolvedValue({ + total: 2, + limit: 20, + offset: 0, + attempts: [ + attempt({ quiz_id: "done", status: "completed", score: 3, total: 3 }), + attempt({ quiz_id: "open" }), + ], + }); + quizApi.getAttempt.mockResolvedValue({ + quiz_id: "open", status: "in_progress", resumable: true, difficulty: "medium", + concept_node_id: "n1", questions: [], responses: [], score: null, total: null, + created_at: "2026-08-22T09:00:00Z", + }); + + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.resumable?.attempt.quiz_id).toBe("open")); + // No local record for it, so there is no stored session to restore. + expect(result.current.resumable?.session).toBeNull(); + expect(quizApi.getAttempt).toHaveBeenCalledTimes(1); + expect(quizApi.getAttempt).toHaveBeenCalledWith("open"); + }); + + it("skips a discarded attempt (there is no abandon endpoint — G4)", async () => { + dismissAttempt("open"); + quizApi.listAttempts.mockResolvedValue({ + total: 1, limit: 20, offset: 0, attempts: [attempt({ quiz_id: "open" })], + }); + + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("ready")); + await new Promise(r => setTimeout(r, 0)); + expect(quizApi.getAttempt).not.toHaveBeenCalled(); + expect(result.current.resumable).toBeNull(); + }); + + it("moves on when the wire says an attempt is no longer resumable", async () => { + quizApi.listAttempts.mockResolvedValue({ + total: 2, limit: 20, offset: 0, + attempts: [attempt({ quiz_id: "stale" }), attempt({ quiz_id: "live" })], + }); + quizApi.getAttempt.mockImplementation((id: string) => + id === "stale" + ? Promise.reject(new ApiError("gone", 409, { code: "QUIZ_ATTEMPT_ABANDONED" })) + : Promise.resolve({ + quiz_id: "live", status: "in_progress", resumable: true, difficulty: "medium", + concept_node_id: "n1", questions: [], responses: [], score: null, total: null, + created_at: "2026-08-22T09:00:00Z", + }), + ); + + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.resumable?.attempt.quiz_id).toBe("live")); + }); +}); + +describe("the primary definition (R-8)", () => { + it("asks for exactly one concept description — the primary's", async () => { + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.primaryDescription).not.toBeNull()); + expect(quizApi.describeConcept).toHaveBeenCalledTimes(1); + expect(quizApi.describeConcept).toHaveBeenCalledWith("u1", "n1", "CS 330"); + }); + + it("leaves the description null when the call fails, so the card falls back", async () => { + quizApi.describeConcept.mockRejectedValue(new ApiError("agent down", 502)); + const { result } = renderHook(() => useQuizHome("u1", "")); + await waitFor(() => expect(result.current.status).toBe("ready")); + await new Promise(r => setTimeout(r, 0)); + expect(result.current.primaryDescription).toBeNull(); + }); +}); + +describe("fallbackDefinition", () => { + it("builds the sentence the card shows while the description loads", () => { + const candidate = { + node: node({ id: "n1", mastery_tier: "struggling" }), + course: COURSES[0], + color: "#123456", + rationale: "", + }; + expect(fallbackDefinition(candidate, 4)).toBe("CS 330 · struggling · 4 connected concepts"); + expect(fallbackDefinition(candidate, 1)).toBe("CS 330 · struggling · 1 connected concept"); + expect(fallbackDefinition(null, 3)).toBe(""); + }); +}); diff --git a/frontend/src/lib/quiz/useQuizHome.ts b/frontend/src/lib/quiz/useQuizHome.ts new file mode 100644 index 00000000..dc86d590 --- /dev/null +++ b/frontend/src/lib/quiz/useQuizHome.ts @@ -0,0 +1,238 @@ +"use client"; + +/** + * Everything quiz home renders from: the graph, the courses, the attempt + * history, the ranked proposals derived from all three, and whichever unfinished + * quiz is waiting to be resumed. + * + * The bootstrap deliberately matches what `screens/Quiz.tsx` does today — + * `getCourses` + `getGraph(userId, activeSemester || undefined)` in parallel, + * held until the active semester has hydrated so returning users fetch scoped + * once instead of unscoped-then-scoped — because the semester contract is the + * same and re-deriving it would be a fourth divergent copy (R4 §4). + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getCourses, getGraph, type EnrolledCourse } from "@/lib/api"; +import { courseInTerm } from "@/lib/useActiveSemester"; +import type { GraphEdge, GraphNode } from "@/lib/types"; +import { describeConcept, getAttempt, listAttempts } from "./api"; +import { describeQuizError, type QuizError } from "./errors"; +import { + alternativesOf, + dueSet, + groupByCourse, + primaryOf, + rankCandidates, + type Candidate, +} from "./proposals"; +import { isDismissed, loadSession } from "./session"; +import type { AttemptDetail, AttemptSummary, QuizSession } from "./types"; + +/** One page of history is enough for both the resume sweep and the "missed N + * last time" join; the route clamps `limit` to 100 anyway. */ +const HISTORY_LIMIT = 20; + +export interface ResumableQuiz { + attempt: AttemptDetail; + /** The locally stored session for this attempt, when there is one. It is the + * only place the scope, queue, feedback mode and origin survive. */ + session: QuizSession | null; + answered: number; +} + +export interface QuizHome { + status: "loading" | "ready" | "error"; + error: QuizError | null; + nodes: GraphNode[]; + edges: GraphEdge[]; + courses: EnrolledCourse[]; + attempts: AttemptSummary[]; + candidates: Candidate[]; + primary: Candidate | null; + alternatives: Candidate[]; + due: ReturnType; + byCourse: ReturnType; + resumable: ResumableQuiz | null; + /** The AI one-liner for the PRIMARY proposal only (R-8). `null` while it is + * in flight or after a failure — the card falls back to a built sentence + * rather than blocking or showing an empty paragraph. */ + primaryDescription: string | null; + refresh(): void; +} + +/** The fallback definition when `concept-description` is slow or fails (R-8), + * the same shape Learn's focus card falls back to. */ +export function fallbackDefinition(candidate: Candidate | null, connected: number): string { + if (!candidate) return ""; + const course = candidate.course?.course_code ?? candidate.node.subject ?? "Your tree"; + const plural = connected === 1 ? "concept" : "concepts"; + return `${course} · ${candidate.node.mastery_tier} · ${connected} connected ${plural}`; +} + +/** + * Resume discovery (R-3), in two passes. + * + * The stored session names an attempt id without a round trip, so it goes first + * — but it is only a hint, and `GET /attempts/{id}` is what decides whether the + * attempt is really resumable (it may have been submitted elsewhere or swept + * past the 24h TTL). The history page then covers the other-device case, where + * this browser has no record at all. Discarded ids are skipped: there is no + * abandon endpoint, so a discard is client-side only (gap G4). + */ +async function discoverResumable( + attempts: AttemptSummary[], + stored: QuizSession | null, +): Promise { + const ids: string[] = []; + if (stored?.attemptId) ids.push(stored.attemptId); + for (const a of attempts) { + if (a.status === "in_progress" && !ids.includes(a.quiz_id)) ids.push(a.quiz_id); + } + + for (const id of ids) { + if (isDismissed(id)) continue; + try { + const attempt = await getAttempt(id); + if (!attempt.resumable) continue; + return { + attempt, + session: stored?.attemptId === id ? stored : null, + answered: attempt.responses?.length ?? 0, + }; + } catch { + // A 404 or a 409 means this one is not resumable after all; try the next. + } + } + return null; +} + +/** + * @param userId the signed-in student. + * @param semester the active semester label, or `null` while it is still + * hydrating from localStorage — nothing is fetched until it resolves. The + * empty string means "All semesters" and fetches unscoped (#360). + */ +export function useQuizHome(userId: string, semester: string | null): QuizHome { + const [status, setStatus] = useState("loading"); + const [error, setError] = useState(null); + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [courses, setCourses] = useState([]); + const [attempts, setAttempts] = useState([]); + const [resumable, setResumable] = useState(null); + const [nonce, setNonce] = useState(0); + + const refresh = useCallback(() => setNonce(n => n + 1), []); + const liveRef = useRef(0); + + useEffect(() => { + if (!userId || semester === null) return; + const token = liveRef.current + 1; + liveRef.current = token; + let cancelled = false; + setStatus("loading"); + setError(null); + + (async () => { + try { + // The three reads are independent; a failing history must not cost the + // student their whole home screen, so only the graph pair is fatal. + const [courseRes, graphRes, attemptRes] = await Promise.all([ + getCourses(userId), + getGraph(userId, semester || undefined), + listAttempts(userId, { limit: HISTORY_LIMIT }).catch( + () => ({ total: 0, limit: HISTORY_LIMIT, offset: 0, attempts: [] as AttemptSummary[] }), + ), + ]); + if (cancelled || liveRef.current !== token) return; + + setCourses(courseRes.courses ?? []); + setNodes((graphRes.nodes ?? []) as GraphNode[]); + setEdges((graphRes.edges ?? []) as GraphEdge[]); + setAttempts(attemptRes.attempts ?? []); + setStatus("ready"); + + const found = await discoverResumable(attemptRes.attempts ?? [], loadSession()); + if (cancelled || liveRef.current !== token) return; + setResumable(found); + } catch (err) { + if (cancelled || liveRef.current !== token) return; + setError(describeQuizError(err)); + setStatus("error"); + } + })(); + + return () => { + cancelled = true; + }; + }, [userId, semester, nonce]); + + // The graph fetch is already scoped server-side; this mirrors the Tree/Learn + // pickers' defensive re-filter so a stale payload can't leak another term in. + const scopedCourses = useMemo( + () => (semester ? courses.filter(c => courseInTerm(c, semester)) : courses), + [courses, semester], + ); + const scopedNodes = useMemo(() => { + if (!semester) return nodes; + const allowed = new Set(scopedCourses.map(c => c.course_id)); + return nodes.filter(n => !n.course_id || allowed.has(n.course_id)); + }, [nodes, semester, scopedCourses]); + + const candidates = useMemo( + () => rankCandidates(scopedNodes, scopedCourses, attempts), + [scopedNodes, scopedCourses, attempts], + ); + const primary = useMemo(() => primaryOf(candidates), [candidates]); + const alternatives = useMemo(() => alternativesOf(candidates, primary), [candidates, primary]); + const due = useMemo(() => dueSet(scopedNodes), [scopedNodes]); + const byCourse = useMemo( + () => groupByCourse(scopedNodes, scopedCourses), + [scopedNodes, scopedCourses], + ); + + // One LLM call per home visit, for the one card that shows a paragraph (R-8). + // `graph_nodes` has no `description` column, so there is nothing stored to + // read; fetching this for every card would multiply the cost by the number of + // rows on screen, which is exactly why it is scoped to the primary. + const [primaryDescription, setPrimaryDescription] = useState(null); + const primaryId = primary?.node.id ?? null; + const primaryName = primary?.node.concept_name ?? null; + const primaryCourseLabel = primary?.course?.course_code ?? undefined; + + useEffect(() => { + setPrimaryDescription(null); + if (!userId || !primaryId || !primaryName) return; + let cancelled = false; + describeConcept(userId, primaryName, primaryCourseLabel).then( + description => { + if (!cancelled) setPrimaryDescription(description.trim() || null); + }, + () => { + // The card renders the built fallback sentence instead. A missing + // definition must never hold up the Start button. + }, + ); + return () => { + cancelled = true; + }; + }, [userId, primaryId, primaryName, primaryCourseLabel]); + + return { + status, + error, + nodes: scopedNodes, + edges, + courses: scopedCourses, + attempts, + candidates, + primary, + alternatives, + due, + byCourse, + resumable, + primaryDescription, + refresh, + }; +} diff --git a/frontend/src/lib/quiz/useQuizSession.test.ts b/frontend/src/lib/quiz/useQuizSession.test.ts new file mode 100644 index 00000000..9c498bf9 --- /dev/null +++ b/frontend/src/lib/quiz/useQuizSession.test.ts @@ -0,0 +1,461 @@ +// @vitest-environment jsdom +/** + * `useQuizSession` is where the machine meets the network. These drive the + * whole loop through mocked clients: start → answer → answer → submit, the + * leave-and-resume round trip, the answered-then-unmounted resume, and the two + * failures whose copy the student actually sees. + */ + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "@/lib/api"; +import type { AnswerResult, AttemptDetail, GenerateResult, SubmitResult } from "./types"; + +const push = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace: vi.fn(), back: vi.fn(), prefetch: vi.fn() }), +})); + +const quizApi = vi.hoisted(() => ({ + fetchQuizConfig: vi.fn(), + generateQuiz: vi.fn(), + answerQuestion: vi.fn(), + submitQuiz: vi.fn(), + getAttempt: vi.fn(), + listAttempts: vi.fn(), + describeConcept: vi.fn(), +})); +vi.mock("./api", () => quizApi); + +const gamification = vi.hoisted(() => ({ fetchGamificationMe: vi.fn() })); +vi.mock("@/lib/api", async importActual => { + const actual = await importActual(); + return { ...actual, fetchGamificationMe: gamification.fetchGamificationMe }; +}); + +import { useQuizSession } from "./useQuizSession"; +import { resetQuizConfigCache } from "./useQuizConfig"; +import { STORAGE_KEY, loadSession } from "./session"; +import type { EntryRequest } from "./source"; + +const CONFIG = { + num_questions: { min: 1, max: 10, options: [3, 5, 10] }, + difficulties: ["easy", "medium", "hard", "adaptive"], + question_types: ["multiple_choice"], +}; + +const ENTRY: EntryRequest = { + concept: "c1", + source: { kind: "tree", returnTo: "/tree?node=c1", conceptId: "c1" }, +}; + +function question(id: number) { + return { + id, + question: `Q${id}?`, + options: [ + { label: "A", text: "a" }, + { label: "B", text: "b" }, + { label: "C", text: "c" }, + { label: "D", text: "d" }, + ], + difficulty: "medium", + }; +} + +function generated(n: number): GenerateResult { + return { + quiz_id: "attempt-1", + questions: Array.from({ length: n }, (_, i) => question(i + 1)), + requested_difficulty: "medium", + resolved_difficulty: "medium", + requested_count: n, + delivered_count: n, + }; +} + +function answerResult(index: number): AnswerResult { + return { + question_index: index, + question_id: index + 1, + is_correct: true, + correct_index: 1, + explanation: `because ${index}`, + next_question: null, + recorded: true, + }; +} + +const SUBMIT_RESULT: SubmitResult = { + score: 2, + total: 2, + mastery_before: 0.25, + mastery_after: 0.31, + results: [], +}; + +function me(total_xp: number, streak: number) { + return { + level: 3, next_level: 4, stage: "sprout", total_xp, xp_into_level: 10, xp_for_level: 100, + level_pct: 10, streak, longest_streak: streak, daily_goal_xp: 50, today_xp: 10, + earned_count: 1, total_count: 10, + }; +} + +const START = { + intent: "practice" as const, + scope: { kind: "concept" as const, conceptId: "c1" }, + conceptId: "c1", + courseId: "course-1", +}; + +beforeEach(() => { + resetQuizConfigCache(); + window.localStorage.clear(); + push.mockClear(); + quizApi.fetchQuizConfig.mockResolvedValue(CONFIG); + quizApi.generateQuiz.mockResolvedValue(generated(2)); + quizApi.answerQuestion.mockImplementation( + (_id: string, p: { questionIndex: number }) => Promise.resolve(answerResult(p.questionIndex)), + ); + quizApi.submitQuiz.mockResolvedValue(SUBMIT_RESULT); + quizApi.getAttempt.mockResolvedValue({} as AttemptDetail); + gamification.fetchGamificationMe + .mockResolvedValueOnce(me(100, 3)) + .mockResolvedValue(me(130, 4)); +}); + +afterEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); +}); + +function mount(entry: EntryRequest = ENTRY) { + return renderHook(() => useQuizSession("u1", entry)); +} + +describe("start → answer → answer → submit", () => { + it("walks the whole loop and lands on results with the XP delta", async () => { + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + expect(quizApi.generateQuiz).toHaveBeenCalledWith({ + userId: "u1", + conceptNodeId: "c1", + numQuestions: 5, + difficulty: "medium", + }); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + expect(quizApi.answerQuestion).toHaveBeenCalledWith("attempt-1", { + questionIndex: 0, + selectedIndex: 1, + questionId: 1, + }); + + act(() => result.current.actions.select(2)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + + await waitFor(() => expect(result.current.session.phase).toBe("results")); + expect(quizApi.submitQuiz).toHaveBeenCalledWith("attempt-1", [ + { question_id: 1, selected_label: "B" }, + { question_id: 2, selected_label: "C" }, + ]); + expect(result.current.session.result).toEqual(SUBMIT_RESULT); + expect(result.current.session.xp).toEqual({ before: 100, after: 130, streak: 4 }); + // The stored record is cleared once the attempt is scored. + expect(loadSession()).toBeNull(); + }); + + it("omits the XP line when a gamification read failed (R-9 — never invented)", async () => { + gamification.fetchGamificationMe.mockReset(); + gamification.fetchGamificationMe.mockRejectedValue(new Error("down")); + + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + quizApi.generateQuiz.mockResolvedValue(generated(1)); + + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + + await waitFor(() => expect(result.current.session.phase).toBe("results")); + expect(result.current.session.xp).toBeNull(); + }); + + it("retries a dropped answer exactly once, and never a rejected one", async () => { + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + quizApi.answerQuestion + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(answerResult(0)); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(2); + + quizApi.answerQuestion.mockReset(); + quizApi.answerQuestion.mockRejectedValue( + new ApiError("bad", 400, { code: "QUIZ_QUESTION_INVALID" }), + ); + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(1); + expect(result.current.session.error?.message).toBe( + "That answer didn't line up with the question. Reload and try again.", + ); + }); +}); + +describe("leave and resume", () => { + it("parks the session, navigates back to the source, and picks it up again", async () => { + const { result, unmount } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + + act(() => result.current.actions.requestLeave()); + expect(result.current.session.phase).toBe("confirm-leave"); + act(() => result.current.actions.confirmLeave()); + + expect(result.current.session.phase).toBe("paused"); + expect(push).toHaveBeenCalledWith("/tree?node=c1"); + expect(loadSession()?.attemptId).toBe("attempt-1"); + unmount(); + + // A fresh mount, arriving via the resume strip. + quizApi.getAttempt.mockResolvedValue({ + quiz_id: "attempt-1", + status: "in_progress", + resumable: true, + difficulty: "medium", + concept_node_id: "c1", + questions: [question(1), question(2)], + responses: [ + { question_index: 0, selected_index: 1, is_correct: true, answered_at: "2026-08-22T10:00:00Z" }, + ], + score: null, + total: null, + created_at: "2026-08-22T09:00:00Z", + } satisfies AttemptDetail); + + const second = mount({ attempt: "attempt-1", source: { kind: "quiz" } }); + await waitFor(() => expect(second.result.current.session.phase).toBe("active")); + expect(second.result.current.session.cursor).toBe(1); + expect(second.result.current.session.attemptId).toBe("attempt-1"); + // The origin survives the round trip — it only exists in the stored record. + expect(second.result.current.session.source).toEqual(ENTRY.source); + }); + + it("resumes an answered-then-unmounted quiz that never reached the leave dialog", async () => { + const { result, unmount } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(3)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + + // No leave dialog, no CONFIRM_LEAVE — the tab simply goes away. + unmount(); + const stored = loadSession(); + expect(stored?.attemptId).toBe("attempt-1"); + expect(stored?.items[0].selectedIndex).toBe(3); + + quizApi.getAttempt.mockResolvedValue({ + quiz_id: "attempt-1", + status: "in_progress", + resumable: true, + difficulty: "medium", + concept_node_id: "c1", + questions: [question(1), question(2)], + responses: [ + { question_index: 0, selected_index: 3, is_correct: true, answered_at: "2026-08-22T10:00:00Z" }, + ], + score: null, + total: null, + created_at: "2026-08-22T09:00:00Z", + } satisfies AttemptDetail); + + const second = mount({ attempt: "attempt-1", source: { kind: "nav" } }); + await waitFor(() => expect(second.result.current.session.phase).toBe("active")); + expect(second.result.current.session.cursor).toBe(1); + expect(second.result.current.session.items[0].selectedIndex).toBe(3); + expect(second.result.current.session.source.kind).toBe("tree"); + }); + + it("explains an expired attempt instead of silently doing nothing", async () => { + quizApi.getAttempt.mockRejectedValue( + new ApiError("gone", 409, { code: "QUIZ_ATTEMPT_ABANDONED" }), + ); + const { result } = mount({ attempt: "attempt-9", source: { kind: "quiz" } }); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + expect(result.current.session.error?.message).toBe( + "That quiz expired after a day. Start a fresh one.", + ); + act(() => result.current.actions.dismissError()); + expect(result.current.session.phase).toBe("home"); + }); +}); + +describe("failures the student reads", () => { + it("maps a generate timeout to its own copy, not a generic 502", async () => { + quizApi.generateQuiz.mockRejectedValue( + new ApiError("timeout", 502, { code: "QUIZ_GENERATION_TIMEOUT" }), + ); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + expect(result.current.session.error).toEqual({ + code: "QUIZ_GENERATION_TIMEOUT", + message: "Writing this quiz took too long. Try again — it usually works the second time.", + retryable: true, + }); + + act(() => result.current.actions.dismissError()); + expect(result.current.session.phase).toBe("home"); + }); + + it("interpolates Retry-After into the rate-limit copy", async () => { + quizApi.generateQuiz.mockRejectedValue( + new ApiError("slow", 429, { code: "QUIZ_RATE_LIMITED", retryAfterSec: 12 }), + ); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + expect(result.current.session.error?.message).toBe( + "You're quizzing fast — give it 12 seconds and try again.", + ); + }); + + it("shows the 409 copy when the attempt was already scored, and retries the submit", async () => { + quizApi.generateQuiz.mockResolvedValue(generated(1)); + quizApi.submitQuiz.mockRejectedValueOnce( + new ApiError("done", 409, { code: "QUIZ_ATTEMPT_ALREADY_COMPLETED" }), + ); + + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + + await waitFor(() => expect(result.current.session.phase).toBe("error")); + expect(result.current.session.error?.message).toBe( + "This quiz was already scored. Your results are on your tree.", + ); + + // Retry goes back through the submit path, not back to home. + quizApi.submitQuiz.mockResolvedValue(SUBMIT_RESULT); + await act(async () => { + result.current.actions.retry(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("results")); + }); +}); + +describe("exits and persistence", () => { + it("refuses to exit a live quiz", async () => { + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.exit()); + expect(push).not.toHaveBeenCalled(); + expect(result.current.session.phase).toBe("active"); + }); + + it("clears the stored session on exit", async () => { + quizApi.generateQuiz.mockResolvedValue(generated(1)); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("results")); + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(result.current.session)); + act(() => result.current.actions.exit("/quiz")); + expect(push).toHaveBeenCalledWith("/quiz"); + expect(loadSession()).toBeNull(); + }); + + it("persists an Adjust-dialog choice to prefs", async () => { + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => + result.current.actions.setConfig({ count: 10, difficulty: "hard", feedback: "as-you-go" }), + ); + expect(result.current.session.config).toEqual({ + count: 10, + difficulty: "hard", + feedback: "as-you-go", + }); + const { loadPrefs } = await import("./prefs"); + expect(loadPrefs()).toEqual({ count: 10, difficulty: "hard", feedback: "as-you-go" }); + }); + + it("holds on the verdict in as-you-go mode instead of advancing", async () => { + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => + result.current.actions.setConfig({ count: 5, difficulty: "medium", feedback: "as-you-go" }), + ); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("answered")); + expect(result.current.session.cursor).toBe(0); + expect(result.current.session.items[0].verdict?.explanation).toBe("because 0"); + expect(quizApi.submitQuiz).not.toHaveBeenCalled(); + + act(() => result.current.actions.next()); + expect(result.current.session.phase).toBe("active"); + expect(result.current.session.cursor).toBe(1); + }); +}); diff --git a/frontend/src/lib/quiz/useQuizSession.ts b/frontend/src/lib/quiz/useQuizSession.ts new file mode 100644 index 00000000..dc70d840 --- /dev/null +++ b/frontend/src/lib/quiz/useQuizSession.ts @@ -0,0 +1,384 @@ +"use client"; + +/** + * The only place quiz effects happen: generate, answer, submit, navigate, + * persist. + * + * `machine.ts` decides what the session looks like after an event and nothing + * else. This hook drives it — it applies an event, reads the state that came + * back, and fires whatever that state calls for. Chaining off the RETURNED + * session rather than off a phase-watching `useEffect` is deliberate: an effect + * keyed on `phase === "submitting"` re-fires whenever that phase is re-entered + * (a dismissed submit error does exactly that), and double-submitting an attempt + * is a 409 the student would have to read. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { answerQuestion, generateQuiz, getAttempt, submitQuiz } from "./api"; +import { describeQuizError, type QuizError } from "./errors"; +import { returnToSource } from "./exits"; +import { + canExit, + canSubmitAnswer, + defaultConfigFor, + errorReturnPhase, + initialSession, + reduce, + type QuizEvent, + type SessionConfig, + type StartRequest, +} from "./machine"; +import { loadPrefs, savePrefs } from "./prefs"; +import { clearSession, persistSession, loadSession } from "./session"; +import type { EntryRequest } from "./source"; +import { useGamificationDelta } from "./useGamificationDelta"; +import { useQuizConfig } from "./useQuizConfig"; +import type { QuizConfig, QuizSession } from "./types"; + +export interface QuizActions { + configure(open: boolean): void; + setConfig(config: SessionConfig): void; + start(request: StartRequest, config?: SessionConfig): void; + select(index: number): void; + submitAnswer(): void; + next(): void; + finish(): void; + requestLeave(): void; + cancelLeave(): void; + confirmLeave(): void; + resume(attemptId: string): void; + practiseMissed(): void; + nextInQueue(courseId?: string | null): void; + exit(target?: string): void; + flag(): void; + dismissError(): void; + retry(): void; +} + +export interface QuizSessionHandle { + session: QuizSession; + /** A quiz call is in flight. The phase alone can't say so — `active` covers + * both "waiting for you" and "waiting for the server". */ + pending: boolean; + config: QuizConfig | null; + actions: QuizActions; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +/** + * Retries exactly once, and only for a transport failure. + * + * `/answer` is idempotent on `(attempt_id, question_index)`, so a retry after a + * dropped connection is safe: if the first call actually landed, the second + * returns the recorded response with `recorded: false`, which the machine + * advances on regardless (invariant 6). A 4xx is never retried — repeating a + * rejected request just spends the student's rate limit. + */ +async function withNetworkRetry(call: () => Promise): Promise { + try { + return await call(); + } catch (err) { + if (describeQuizError(err).code !== "NETWORK") throw err; + return call(); + } +} + +export function useQuizSession(userId: string, entry: EntryRequest): QuizSessionHandle { + const router = useRouter(); + const { config } = useQuizConfig(); + const gamification = useGamificationDelta(userId); + + const [session, setSession] = useState(() => + initialSession(entry, null, loadPrefs()), + ); + const [pending, setPending] = useState(false); + + // The async chains read the session through this ref: a closure captured at + // render time is one event behind by the time an await resolves. + const sessionRef = useRef(session); + const submittingRef = useRef(null); + const generationRef = useRef(0); + const configAppliedRef = useRef(false); + const autoResumedRef = useRef(false); + + /** Apply one event, persist the outcome, and hand it back so the caller can + * decide what the new phase requires. */ + const apply = useCallback((event: QuizEvent): QuizSession => { + const next = reduce(sessionRef.current, event); + if (next !== sessionRef.current) { + sessionRef.current = next; + setSession(next); + persistSession(next); + } + return next; + }, []); + + // Once `/config` lands, adopt its defaults — but only while nothing is in + // flight and only if the student hasn't already chosen. The first paint uses + // the pre-config scalar so the "5 questions, medium" line isn't blank. + useEffect(() => { + if (!config || configAppliedRef.current) return; + configAppliedRef.current = true; + apply({ type: "SET_CONFIG", config: defaultConfigFor(config, loadPrefs(config)) }); + }, [config, apply]); + + // Persist on unmount and on a tab close, so "answered then navigated away" is + // resumable even though no transition fired on the way out. + useEffect(() => { + const flush = () => persistSession(sessionRef.current); + window.addEventListener("beforeunload", flush); + return () => { + window.removeEventListener("beforeunload", flush); + flush(); + }; + }, []); + + const runGenerate = useCallback( + async (from: QuizSession) => { + if (!from.conceptId) { + apply({ + type: "GENERATE_FAILED", + error: describeQuizError(new Error("no concept selected")), + }); + return; + } + const token = generationRef.current + 1; + generationRef.current = token; + // Snapshot XP now so the results screen has a "before" to subtract from. + void gamification.snapshotBefore(); + setPending(true); + try { + const result = await generateQuiz({ + userId, + conceptNodeId: from.conceptId, + numQuestions: from.config.count, + difficulty: from.config.difficulty, + }); + if (generationRef.current !== token) return; + apply({ type: "GENERATED", result }); + } catch (err) { + if (generationRef.current !== token) return; + apply({ type: "GENERATE_FAILED", error: describeQuizError(err) }); + } finally { + if (generationRef.current === token) setPending(false); + } + }, + [apply, gamification, userId], + ); + + const runSubmit = useCallback( + async (from: QuizSession) => { + const attemptId = from.attemptId; + if (!attemptId || submittingRef.current === attemptId) return; + submittingRef.current = attemptId; + setPending(true); + try { + // Belt and braces: the server reconciles against `quiz_responses` and a + // recorded row always wins, so this payload only covers questions whose + // `/answer` call was lost. + const answers = from.items + .filter(i => i.selectedIndex !== null) + .map(i => ({ + question_id: i.question.id, + selected_label: i.question.options[i.selectedIndex as number]?.label ?? "", + })); + const result = await submitQuiz(attemptId, answers); + const xp = await gamification.deltaAfterSubmit(); + apply({ type: "SUBMITTED", result, xp }); + clearSession(); + } catch (err) { + apply({ type: "SUBMIT_FAILED", error: describeQuizError(err) }); + } finally { + submittingRef.current = null; + setPending(false); + } + }, + [apply, gamification], + ); + + const runResume = useCallback( + async (attemptId: string) => { + setPending(true); + try { + const detail = await getAttempt(attemptId); + if (!detail.resumable) { + apply({ + type: "FAILED", + error: { + code: "QUIZ_ATTEMPT_NOT_RESUMABLE", + message: "This quiz can't be resumed. Start a new one.", + retryable: false, + } satisfies QuizError, + }); + return; + } + const stored = loadSession(); + apply({ type: "RESUME", detail, stored }); + void gamification.snapshotBefore(); + } catch (err) { + apply({ type: "FAILED", error: describeQuizError(err) }); + } finally { + setPending(false); + } + }, + [apply, gamification], + ); + + const submitAnswer = useCallback(async () => { + const current = sessionRef.current; + if (!canSubmitAnswer(current) || !current.attemptId) return; + const item = current.items[current.cursor]; + const attemptId = current.attemptId; + setPending(true); + try { + const result = await withNetworkRetry(() => + answerQuestion(attemptId, { + questionIndex: item.index, + selectedIndex: item.selectedIndex as number, + questionId: item.question.id, + }), + ); + const next = apply({ type: "ANSWER_RECORDED", result }); + if (next.phase === "submitting") await runSubmit(next); + } catch (err) { + apply({ type: "ANSWER_FAILED", error: describeQuizError(err) }); + } finally { + setPending(false); + } + }, [apply, runSubmit]); + + // A `?attempt=` entry (the resume strip, or a leave-and-return link) picks + // the quiz back up without a stop on home. + useEffect(() => { + if (autoResumedRef.current || !entry.attempt || !userId) return; + autoResumedRef.current = true; + void runResume(entry.attempt); + }, [entry.attempt, userId, runResume]); + + const actions = useMemo(() => { + const start = (request: StartRequest, override?: SessionConfig) => { + const next = apply({ + type: "START", + start: request, + config: override ?? sessionRef.current.config, + }); + if (next.phase === "generating") void runGenerate(next); + }; + + return { + configure: open => { + apply({ type: "CONFIGURE", open }); + }, + + setConfig: next => { + const applied = apply({ type: "SET_CONFIG", config: next }); + if (applied.config === next) { + savePrefs({ count: next.count, difficulty: next.difficulty, feedback: next.feedback }); + } + }, + + start, + + select: index => { + apply({ type: "SELECT", index }); + }, + + submitAnswer: () => { + void submitAnswer(); + }, + + next: () => { + const next = apply({ type: "NEXT" }); + if (next.phase === "submitting") void runSubmit(next); + }, + + finish: () => { + const next = apply({ type: "FINISH" }); + if (next.phase === "submitting") void runSubmit(next); + }, + + requestLeave: () => { + apply({ type: "REQUEST_LEAVE" }); + }, + + cancelLeave: () => { + apply({ type: "CANCEL_LEAVE" }); + }, + + confirmLeave: () => { + const next = apply({ type: "CONFIRM_LEAVE" }); + if (next.phase !== "paused") return; + // `apply` already persisted it; the push is what makes the answers + // recoverable from anywhere the student lands next. + router.push(returnToSource(next)); + }, + + resume: attemptId => { + void runResume(attemptId); + }, + + practiseMissed: () => { + const current = sessionRef.current; + const result = current.result; + if (!result) return; + const missed = Math.max(result.total - result.score, 1); + const min = config?.num_questions.min ?? 1; + const max = config?.num_questions.max ?? missed; + const next = apply({ + type: "PRACTISE_MISSED", + missedCount: result.total - result.score, + numQuestions: clamp(missed, min, max), + }); + if (next.phase === "generating") void runGenerate(next); + }, + + nextInQueue: courseId => { + const next = apply({ type: "NEXT_IN_QUEUE", courseId }); + if (next.phase === "generating") void runGenerate(next); + }, + + exit: target => { + const current = sessionRef.current; + if (!canExit(current)) return; + const next = apply({ type: "EXIT" }); + clearSession(); + router.push(target ?? returnToSource(next)); + }, + + flag: () => { + apply({ type: "FLAG" }); + }, + + dismissError: () => { + apply({ type: "DISMISS_ERROR" }); + }, + + retry: () => { + const failed = sessionRef.current; + if (failed.phase !== "error") return; + const back = errorReturnPhase(failed); + const next = apply({ type: "DISMISS_ERROR" }); + if (back === "submitting") void runSubmit(next); + else if (back === "home" && next.conceptId) { + const generating = apply({ + type: "START", + start: { + intent: next.intent, + scope: next.scope, + conceptId: next.conceptId, + courseId: next.courseId, + }, + config: next.config, + }); + if (generating.phase === "generating") void runGenerate(generating); + } else if (back === "active") void submitAnswer(); + }, + }; + }, [apply, config, router, runGenerate, runResume, runSubmit, submitAnswer]); + + return { session, pending, config, actions }; +} From 1feefec1da728c683783eb999c6d042745b7dde6 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:09:53 -0400 Subject: [PATCH 11/60] test(ui): a gallery of every primitive in every state (#537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit components/ui/__fixtures__/QuizPrimitivesGallery — a harness, not a route: every primitive in every state, the four node sizes, both growth marks and all three neighbourhood presets, at the shell's real content width with --quiz-accent bound the way QuizScreen will bind it. Light mode only; the app has no dark mode. Rendered and reviewed in a browser off a static dump. Two fixes came out of it: the href form of EmptyState's action is an , which the app underlines, and ConceptNode's optional caption is wider than its mark and spills sideways (documented on the prop — the tree's own labels do the same). Co-Authored-By: Claude Fable 5 --- frontend/src/app/globals.css | 46 +++ frontend/src/components/graph/ConceptNode.tsx | 8 +- .../QuizPrimitivesGallery.test.tsx | 47 +++ .../ui/__fixtures__/QuizPrimitivesGallery.tsx | 385 ++++++++++++++++++ 4 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx create mode 100644 frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 684e3821..d4347738 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -916,6 +916,8 @@ body { font-size: 14px; line-height: 1.5; } color: var(--text-dim); } .empty-state__action { display: flex; flex-wrap: wrap; gap: 12px; } +/* The href form renders an , and the app underlines anchors. */ +.empty-state__action .btn { text-decoration: none; } .empty-state--hero .empty-state__title { margin-bottom: 18px; font-size: var(--empty-hero-fs); @@ -966,6 +968,50 @@ body { font-size: 14px; line-height: 1.5; } transition: transform var(--dur-slow) var(--ease); } +/* ── QuizPrimitivesGallery ─────────────────────────────────────────── + The harness at components/ui/__fixtures__ that shows every primitive in + every state at the shell's real content width. Not shipped in any route; + it lives here rather than inline so the fixture obeys the same + classes-and-tokens-only rule as the primitives it displays. */ +.quiz-gallery { + max-width: 900px; + padding: var(--pad-xl); + display: flex; + flex-direction: column; + gap: 44px; +} +.quiz-gallery__title { margin: 0; font-size: var(--fs-4xl); } +.quiz-gallery__section { + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 20px; + border-top: 1px solid var(--border); +} +.quiz-gallery__heading { margin: 0; } +.quiz-gallery__row { + display: flex; + align-items: flex-start; + gap: var(--pad-lg); +} +.quiz-gallery__caption { + width: 200px; + flex-shrink: 0; + font-size: var(--fs-sm); + color: var(--text-muted); +} +.quiz-gallery__specimens { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 18px; + min-width: 0; +} +.quiz-gallery__answers { + width: 100%; + border-top: 1px solid var(--border); +} + /* ══════════════════════════════════════════════════════════════════ LANDING PAGE STYLES (ported from main branch) ══════════════════════════════════════════════════════════════════ */ diff --git a/frontend/src/components/graph/ConceptNode.tsx b/frontend/src/components/graph/ConceptNode.tsx index c71cf750..61b3cf2e 100644 --- a/frontend/src/components/graph/ConceptNode.tsx +++ b/frontend/src/components/graph/ConceptNode.tsx @@ -204,7 +204,13 @@ export interface ConceptNodeProps { courseColor: string; /** The shade seed. MUST be the graph node id, not the concept name. */ nodeId: string; - /** Optional caption under the mark, truncated like the tree's. */ + /** + * Optional caption under the mark, truncated like the tree's. The box grows + * to fit it vertically, but the text can be wider than the mark and spills + * sideways (the SVG is `overflow: visible` for the glow) — exactly as the + * tree's own labels do inside the graph canvas. Marks laid out in a tight + * row should label themselves in HTML instead. + */ label?: string; variant?: ConceptNodeVariant; isRoot?: boolean; diff --git a/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx b/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx new file mode 100644 index 00000000..a85454e0 --- /dev/null +++ b/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { describe, it, expect, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import React from "react"; +import { __resetScrollLocksForTests } from "@/lib/useScrollLock"; +import { QuizPrimitivesGallery } from "./QuizPrimitivesGallery"; + +afterEach(() => { + cleanup(); + __resetScrollLocksForTests(); +}); + +/** + * A smoke test for the harness itself. Its job is to keep the gallery from + * quietly rotting the next time a primitive's props change — a screenshot + * target nobody can mount is worse than none. + */ +describe("QuizPrimitivesGallery", () => { + it("mounts every primitive, with the course accent bound on the root", () => { + const { container } = render(); + const root = container.querySelector(".quiz-gallery")!; + expect(root.style.getPropertyValue("--quiz-accent")).toBe("#7b4b99"); + + expect(container.querySelectorAll(".btn").length).toBeGreaterThan(6); + expect(container.querySelectorAll(".seg").length).toBe(4); + expect(container.querySelectorAll(".answer-option").length).toBeGreaterThan(6); + expect(container.querySelectorAll(".progress-dots").length).toBe(5); + expect(container.querySelectorAll(".inline-banner").length).toBe(2); + expect(container.querySelectorAll(".empty-state").length).toBe(2); + expect(container.querySelectorAll(".concept-node").length).toBeGreaterThan(9); + expect(container.querySelectorAll(".concept-neighbourhood").length).toBe(5); + }); + + it("shows all five AnswerOption states side by side", () => { + const { container } = render(); + for (const state of ["default", "selected", "correct", "chosen-wrong", "muted"]) { + expect(container.querySelector(`.answer-option--${state}`)).not.toBeNull(); + } + }); + + it("opens the sheet, which portals out of the gallery", () => { + render(); + expect(screen.queryByTestId("gallery-sheet")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Ask about this" })); + expect(screen.getByTestId("gallery-sheet")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx b/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx new file mode 100644 index 00000000..f0bc17db --- /dev/null +++ b/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx @@ -0,0 +1,385 @@ +"use client"; + +/** + * QuizPrimitivesGallery — every primitive, every state, on one page (#537). + * + * A harness, not a route and not a test: it exists so the shared primitives + * can be looked at in isolation at the shell's real content width before any + * screen consumes them, and so a design review has something to screenshot. + * Mount it anywhere (a scratch route, a story, a Playwright fixture page) — + * it takes no data and touches nothing. + * + * Light mode only: the app has no dark mode, deliberately (globals.css). + * + * `--quiz-accent` is set on the root here exactly as `QuizScreen` will set it + * from the active concept's course colour, so the accent-derived states + * (segmented underline, selection bar, progress dots, banner tint) render the + * way they will in the real screen rather than in the app's default green. + */ + +import React from "react"; +import { ConceptNode } from "@/components/graph/ConceptNode"; +import { ConceptNeighbourhood } from "@/components/graph/ConceptNeighbourhood"; +import type { NeighbourNode } from "@/lib/graph/neighbourhood"; +import { AnswerOption, type AnswerState } from "../AnswerOption"; +import { Button } from "../Button"; +import { EmptyState } from "../EmptyState"; +import { InlineBanner } from "../InlineBanner"; +import { ProgressDots } from "../ProgressDots"; +import { SegmentedControl } from "../SegmentedControl"; +import { Sheet } from "../Sheet"; + +/** The prototype's CS101 purple, so the gallery matches the design's screens. */ +const COURSE_COLOR = "#7b4b99"; + +const CENTRE = { id: "recursion", name: "Recursion", mastery: 0.29, tier: "struggling" }; + +const SIBLINGS: NeighbourNode[] = [ + { id: "base-cases", name: "Base cases", mastery: 0.52, tier: "learning", strength: 0.9 }, + { id: "stack-frames", name: "Stack frames", mastery: 0.3, tier: "struggling", strength: 0.7 }, + { id: "tail-recursion", name: "Tail recursion", mastery: 0.12, tier: "struggling", strength: 0.4 }, +]; + +const ANSWER_STATES: AnswerState[] = [ + "default", + "selected", + "correct", + "chosen-wrong", + "muted", +]; + +const ANSWER_TEXT: Record = { + default: "It makes the function run faster by caching the results of earlier calls", + selected: "It stops the recursion by returning a result without another recursive call", + correct: "It stops the recursion by returning a result without another recursive call", + "chosen-wrong": "It increases the recursion depth available on the call stack", + muted: "It converts the recursion into an iterative loop at compile time", +}; + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +export function QuizPrimitivesGallery() { + const [count, setCount] = React.useState(5); + const [difficulty, setDifficulty] = React.useState("medium"); + const [feedback, setFeedback] = React.useState("at-end"); + const [picked, setPicked] = React.useState("B"); + const [sheetOpen, setSheetOpen] = React.useState(false); + + return ( +
+

Quiz primitives

+ +
+ + + + + + + + + + + + + + + + + +
+ +
+ + ({ value: v, label: `${v} questions` }))} + value={count} + onChange={setCount} + ariaLabel="Length" + testid="gallery-seg-count" + /> + + + ({ value: v, label: v }))} + value={difficulty} + onChange={setDifficulty} + ariaLabel="Difficulty" + testid="gallery-seg-difficulty" + /> + + + + + + {}} + ariaLabel="Difficulty with a disabled option" + /> + +
+ +
+
+ {ANSWER_STATES.map((state, i) => ( + {}} + /> + ))} + +
+ +
+ {["A", "B", "C"].map((letter, i) => ( + setPicked(letter)} + /> + ))} +
+
+
+ +
+ + + + + + + + + + + +
+ +
+ + + + + } + > + You left a quiz on Recursion — 2 of 5 answered + + + Only 3 questions were ready for this concept. + +
+ +
+ + + + setSheetOpen(false)} + title="Ask about this" + testid="gallery-sheet" + > +

+ What is the purpose of a base case in a recursive function? +

+

You chose B · The answer is B.

+ +
+
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + +
+
+ ); +} + +/** Four masteries, one per tier, so the opacity ramp is visible side by side. */ +const marks = { + recursion: { nodeId: "recursion", mastery: 0.29, tier: "struggling" }, + baseCases: { nodeId: "base-cases", mastery: 0.52, tier: "learning" }, + mastered: { nodeId: "determinants", mastery: 0.85, tier: "mastered" }, + unexplored: { nodeId: "tail-recursion", mastery: 0.05, tier: "unexplored" }, +} as const; From 100a3c8eaa6e0a6801d7ff68ab031effdf884943 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:12:41 -0400 Subject: [PATCH 12/60] docs(quiz): contract amendments from A1 (home scale 2, primitive prop additions) (#537) Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-22-quiz-frontend-contract.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md b/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md index 43283696..7388d2c4 100644 --- a/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md +++ b/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md @@ -145,7 +145,7 @@ Proof: extend `KnowledgeGraph2D.testmode.test.tsx`'s `snapshot()` with `fill`/`o BEFORE the refactor, assert equality AFTER; KG3D's existing `nodeColor`/`nodeVal` tests keep passing; add a frozen `shadeFor` golden table (≥5 ids). -### `lib/graph/neighbourhood.ts` +### `lib/graph/neighbourhood.ts` — takes `lib/data.ts`'s view-model `GraphNode` (post `apiToGraphNode`), not the wire type (amended A1) ```ts export interface NeighbourNode { id: string; name: string; mastery: number; tier: string; strength: number } export function siblingsFor(centreId: string, nodes: GraphNode[], edges: GraphEdge[], n?: number): NeighbourNode[] @@ -171,11 +171,12 @@ interface ConceptNeighbourhoodProps { centre: { id: string; name: string; master ``` Layout: centre at (w/2 − small offset, h/2) per the prototype's three fixed sibling positions (top-left, top-right, bottom-left); edges `stroke: var(--text-muted)` at opacity .2 and width `edgeWidthFor(strength)`; labels `font-size: var(--fs-xs)`, -`fill: var(--text-dim)`, truncated. Presets used: home 320×204 (scale 2.5), concept dialog 300×200 (scale 2), results 640×212 (scale 2.5, `centreVariant` growth). +`fill: var(--text-dim)`, truncated. Presets used: home 320×204 (scale 2 — the prototype's r=23 for nodeR(0.29); amended A1), concept dialog 300×200 (scale 2), results 640×212 (scale 2.5, `centreVariant` growth). ### ` + )} + +
+ {session.error.requestId && ( + + Reference {session.error.requestId} + + )} + + ); + } + + if (QUESTION_PHASES.has(session.phase)) { + return ( + + ); + } + + if (session.phase === "results") { + return ( + + ); + } + + return ( + + ); + }; + + const layout = session.phase === "results" + ? "results" + : QUESTION_PHASES.has(session.phase) || session.phase === "error" + ? "question" + : "home"; + + return ( + + + } /> +
+
{body()}
+
+
+ ); +} diff --git a/frontend/src/components/quiz/home/QuizHome.tsx b/frontend/src/components/quiz/home/QuizHome.tsx new file mode 100644 index 00000000..4c155b2d --- /dev/null +++ b/frontend/src/components/quiz/home/QuizHome.tsx @@ -0,0 +1,123 @@ +"use client"; + +/** + * STUB — Wave 3 (B1) replaces the body. The PROPS are the seam and must not + * change: `QuizScreen` composes exactly this shape from `useQuizHome`, + * `useQuizConfig` and `useQuizSession`, and A2's tests pin it. + * + * What this placeholder is for: it renders enough to drive the machine by hand + * end to end (pick the proposal, press Start) so the data layer can be exercised + * before a single pixel of the real screen exists. + * + * The real screen is §5 B1: resume strip, "Ready for you" proposal with its + * neighbourhood, two alternatives, the review-everything-due row, the grouped + * pick list, the concept and adjust dialogs, and the empty states. + */ + +import React from "react"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { QuizHome as QuizHomeData } from "@/lib/quiz/useQuizHome"; +import type { EntryRequest } from "@/lib/quiz/source"; +import type { QuizConfig, QuizPrefs, QuizSession } from "@/lib/quiz/types"; +import { queueFor } from "@/lib/quiz/proposals"; +import { QUEUE_COUNT } from "@/lib/quiz/session"; + +export interface QuizHomeProps { + userId: string; + home: QuizHomeData; + config: QuizConfig | null; + prefs: QuizPrefs; + entry: EntryRequest; + session: QuizSession; + actions: QuizActions; +} + +export function QuizHome({ home, session, actions, entry }: QuizHomeProps) { + const primary = home.primary; + + const startPrimary = () => { + if (!primary) return; + actions.start({ + intent: "practice", + scope: { kind: "concept", conceptId: primary.node.id }, + conceptId: primary.node.id, + courseId: primary.node.course_id ?? null, + }); + }; + + const startDue = () => { + const queue = queueFor("due", home.nodes); + if (queue.length === 0) return; + actions.start( + { + intent: "review", + scope: { kind: "due", queue }, + conceptId: queue[0], + courseId: home.nodes.find(n => n.id === queue[0])?.course_id ?? null, + }, + { ...session.config, count: QUEUE_COUNT }, + ); + }; + + return ( +
+

Quiz

+

+ {session.phase} · {home.status} + {entry.scope === "due" ? " · due" : ""} +

+ + {home.resumable && ( +

+ You left a quiz on {home.resumable.attempt.concept_node_id} —{" "} + {home.resumable.answered} answered +

+ )} + +

+ {primary ? primary.node.concept_name : "Nothing to propose yet"} + {primary ? ` · ${primary.rationale}` : ""} +

+

{home.primaryDescription ?? ""}

+

+ {session.config.count} questions, {session.config.difficulty} + {session.config.feedback === "as-you-go" ? " · answers as you go" : ""} +

+ +
+ + {home.resumable && ( + + )} + {home.due.count > 0 && ( + + )} + +
+
+ ); +} diff --git a/frontend/src/components/quiz/index.ts b/frontend/src/components/quiz/index.ts new file mode 100644 index 00000000..a0a4499c --- /dev/null +++ b/frontend/src/components/quiz/index.ts @@ -0,0 +1,8 @@ +export { QuizScreen } from "./QuizScreen"; +export { QuizHome, type QuizHomeProps } from "./home/QuizHome"; +export { + QuizQuestion, + type QuizQuestionProps, + type QuizConceptSummary, +} from "./question/QuizQuestion"; +export { QuizResults, type QuizResultsProps } from "./results/QuizResults"; diff --git a/frontend/src/components/quiz/question/QuizQuestion.tsx b/frontend/src/components/quiz/question/QuizQuestion.tsx new file mode 100644 index 00000000..91298e28 --- /dev/null +++ b/frontend/src/components/quiz/question/QuizQuestion.tsx @@ -0,0 +1,140 @@ +"use client"; + +/** + * STUB — Wave 3 (B2) replaces the body. The PROPS are the seam and must not + * change. + * + * Enough of the flow is wired to drive the machine by hand: pick an option, + * Submit, then Next / See results, and Leave with its confirmation. That is the + * whole loop the data layer has to survive. + * + * The real screen is §5 B2: the progress rail, the concept header, the stem, + * the `AnswerOption` radiogroup, the feedback line, flag, "Ask about this", the + * leave dialog, the AskPanel sheet and the keyboard map. + */ + +import React from "react"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { QuizConfig, QuizSession } from "@/lib/quiz/types"; + +export interface QuizConceptSummary { + id: string; + name: string; + courseCode: string; + color: string; + tier: string; + mastery: number; +} + +export interface QuizQuestionProps { + session: QuizSession; + actions: QuizActions; + config: QuizConfig | null; + concept: QuizConceptSummary; + userId: string; + courseId: string | null; +} + +export function QuizQuestion({ session, actions, concept }: QuizQuestionProps) { + const item = session.items[session.cursor]; + const total = session.items.length; + const isLast = session.cursor >= total - 1; + const revealed = session.phase === "answered"; + + if (session.phase === "generating") { + return ( +
+

Writing your quiz…

+
+ ); + } + + return ( +
+

+ {concept.name} · question {session.cursor + 1} of {total} · {session.phase} +

+

{item?.question.question ?? ""}

+ +
+ {(item?.question.options ?? []).map((option, index) => ( + + ))} +
+ +

+ {revealed && item?.verdict + ? `${item.verdict.isCorrect ? "Correct." : "Not quite."} ${item.verdict.explanation}` + : ""} +

+ +
+ + + {revealed ? ( + + ) : ( + + )} +
+ + {session.phase === "confirm-leave" && ( +
+

Leave this quiz? Your answers so far are saved.

+ + +
+ )} +
+ ); +} diff --git a/frontend/src/components/quiz/quiz.css b/frontend/src/components/quiz/quiz.css new file mode 100644 index 00000000..54e62e56 --- /dev/null +++ b/frontend/src/components/quiz/quiz.css @@ -0,0 +1,126 @@ +/* Shared layout for every quiz screen (§5 "Common"). + * + * Per-screen rules live beside their screen (`home/home.css`, `question/ + * question.css`, `results/results.css`); this file holds only what all three + * share — the three content-column widths, the page paddings, and the root that + * binds the course accent. + * + * Every value is a token. The design's own geometry constants (the column + * widths and the three page paddings) are declared ONCE here as tokens and + * referenced everywhere else, so no screen carries a bare px measurement. + * + * `--quiz-accent` is the one thing bound at runtime: the screen root sets it + * from the active concept's course colour with an inline custom property (the + * single inline-style exemption in R-1). Everything downstream reads + * `var(--quiz-accent, var(--accent))`, so an unset accent degrades to the app's + * own, never to a hardcoded colour. + */ + +.quiz-root { + --quiz-col-home: 780px; + --quiz-col-question: 680px; + --quiz-col-results: 640px; + + --quiz-pad-home: 52px var(--pad-xl) 32px; + --quiz-pad-question: 56px var(--pad-xl) 36px; + --quiz-pad-results: 28px var(--pad-xl) 32px; + + /* The rail the question screen's progress dots sit in, mirrored on the right + so the content column stays optically centred. */ + --quiz-rail: 64px; + + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +/* The scrolling body under the TopBar. Each phase supplies its own padding + token; the column width is set by the screen inside. */ +.quiz-body { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.quiz-body--home { padding: var(--quiz-pad-home); } +.quiz-body--question { padding: var(--quiz-pad-question); } +.quiz-body--results { padding: var(--quiz-pad-results); } + +.quiz-col { + width: 100%; + margin: 0 auto; +} + +.quiz-col--home { max-width: var(--quiz-col-home); } +.quiz-col--question { max-width: var(--quiz-col-question); } +.quiz-col--results { max-width: var(--quiz-col-results); } + +/* Section rule used between blocks on every screen. */ +.quiz-divider { + border: 0; + border-top: 1px solid var(--border); + margin: var(--pad-lg) 0; +} + +.quiz-eyebrow { + color: var(--text-muted); + margin-bottom: var(--pad-sm); +} + +/* Nothing is signalled by colour alone, so the error card carries a heading as + well as its tint. */ +.quiz-error { + max-width: var(--quiz-col-question); + margin: 0 auto; + padding: var(--pad-lg); + border: 1px solid var(--border); + border-left: 2px solid var(--state-struggle); + border-radius: var(--r-lg); + background: var(--bg-panel); +} + +.quiz-error__title { + font-size: var(--fs-lg); + margin: 0 0 var(--pad-sm); +} + +.quiz-error__body { + font-size: var(--fs-md); + color: var(--text-dim); + margin: 0 0 var(--pad-md); +} + +.quiz-error__actions { + display: flex; + gap: var(--pad-sm); +} + +.quiz-error__request-id { + display: block; + margin-top: var(--pad-sm); + font-size: var(--fs-2xs); + color: var(--text-muted); +} + +/* Placeholder scaffolding for the Wave 3 screens. These three rules exist so + the stubs are legible while the real screens are built; the screen CSS files + replace them. */ +.quiz-stub { + display: flex; + flex-direction: column; + gap: var(--pad-md); +} + +.quiz-stub__phase { + color: var(--text-muted); +} + +.quiz-stub__actions { + display: flex; + gap: var(--pad-sm); + flex-wrap: wrap; +} diff --git a/frontend/src/components/quiz/results/QuizResults.tsx b/frontend/src/components/quiz/results/QuizResults.tsx new file mode 100644 index 00000000..f61b4631 --- /dev/null +++ b/frontend/src/components/quiz/results/QuizResults.tsx @@ -0,0 +1,106 @@ +"use client"; + +/** + * STUB — Wave 3 (B3) replaces the body. The PROPS are the seam and must not + * change. + * + * The real screen is §5 B3: the growth neighbourhood, the mastery delta line, + * the score and XP rule, the missed list with its disclosures and per-item + * "Ask about this", the perfect-run line, and the three exits. + */ + +import React from "react"; +import type { NeighbourNode } from "@/lib/graph/neighbourhood"; +import { queueOf } from "@/lib/quiz/machine"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { QuizSession } from "@/lib/quiz/types"; +import { sourceLabel } from "@/lib/quiz/exits"; +import type { QuizConceptSummary } from "../question/QuizQuestion"; + +export interface QuizResultsProps { + session: QuizSession; + actions: QuizActions; + concept: QuizConceptSummary; + neighbourhood: { siblings: NeighbourNode[] }; + prefersReducedMotion: boolean; +} + +export function QuizResults({ session, actions, concept }: QuizResultsProps) { + const result = session.result; + const queue = queueOf(session.scope); + const hasNext = session.queueIndex + 1 < queue.length; + const missed = result ? result.total - result.score : 0; + + return ( +
+

{concept.name}

+

+ {result ? `${result.score} of ${result.total} correct` : ""} +

+

+ {result + ? `${Math.round(result.mastery_before * 100)}% → ${Math.round(result.mastery_after * 100)}%` + : ""} +

+ {session.xp && ( +

+ +{session.xp.after - session.xp.before} XP · {session.xp.streak}-day streak +

+ )} + +
+ {hasNext ? ( + + ) : missed > 0 ? ( + + ) : ( + + )} + + +
+
+ ); +} diff --git a/frontend/src/lib/quiz/useQuizHome.ts b/frontend/src/lib/quiz/useQuizHome.ts index dc86d590479f8b863c7fe6b84528df309c05194a..4e1b652a0a26d4162ddc0957323057684e4ef754 100644 GIT binary patch delta 1897 zcmZ8i-EJF26ebZw$Px%Cv~fzM9Gkec(Xx@aKw^zq0WlCMw6s)Jk(JW%?yCYNChj@u7op6AyfFZW*R?qru^nX_1SLYli6lzZFtJk3>FhuvcsNHKDfpW-hnC2Qhb-H3>kz<`!xA_!w|Ol4+|?{I=YtEs2ZISJI%bm5M%7souzK5 zQ!`ghRs}c(1CyGGV5VJx_s9@H;r8uv*hBW1eWgS87eHMii>LMcpM$qvO$N|3=y?c@ zzGh|xhc%l{MlW$e``|Xu~Mf0ZF)bdBDhMnbawMFiM2<{gq z2t!)R&9lX(=8e6^?xwxi(){sWo$}HY*cAR>4ES2YmM=s2uKdKI`up*ni;*UH7Cwj vcYEdVt&A5-?&w#bxga)^ph&Baap)(p)t^;NT5p#$d3_t+DTep#?Q8!7R`Y2P delta 1174 zcmah|&ubGw6sD1i2p+VxX48n1k?O35pf^R+Mrx`;DcW9yn8SAWr7rHygqc~P63D?G zybqpw^$$>j|AL;qh@PZ>L$4m3ADhZ{i(ZyD@4fH6Z{PRc_x1N{AKz6@ZNgykwz7Y2 zvVVRsGZr$CnP?&ba?6^1)JB;A^ML!$viQwomO>A^JltOeexYl$lf$~kD#MJs z3=3Jtg#(V|s(Tc$UCLRKK;&ZO0)do}4y9xKAb%7pQ zIWvFS4MlyJOBSa?P2$&}#d3ORgWU4fCw$|DfE0uTj@s?E9jK2u$c$5?#}q8tE1(*h z7ionV=RhU8iEg{JO8I6(Q%V?xzt@YpBi+9|d^fHG$-Uf3>&I6qLCsWc9 z#<4oAZFu?s`IE@plasoMD5D|t{mGZb=d-K%(G7WQw(FtoF_bZ*_5z=l+=bN?lI!bL zhmD_gEk!vwzfScPf9}KsL>ghBTbpz6jdmvh+)r8pEnPe%z8R%C^2x!~gPCg6YF}on II@cEd0AYrbNdN!< From ee9cc13953dfaeaf55b519254a2a33838d83dd32 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:14:08 -0400 Subject: [PATCH 14/60] fix(ui): fade a disabled primary button instead of leaving it indistinguishable (#537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5 B2 wants the quiz's Submit disabled-but-never-hidden until an answer is selected, and a disabled .btn--primary rendered identical to an enabled one. Scoped to the primary variant only: the bordered and ghost variants already fade legibly, and repainting every disabled button in the app is not this PR's change to make. Both forms are covered — the DOM attribute, and aria-disabled for controls that must stay focusable and announced while inert. Gallery gains the three-way specimen (disabled / aria-disabled / enabled) and Button.test.tsx the assertion that both forms reach the rule and stay visible. Co-Authored-By: Claude Fable 5 --- frontend/src/app/globals.css | 13 ++++++++++ frontend/src/components/ui/Button.test.tsx | 24 +++++++++++++++++++ .../ui/__fixtures__/QuizPrimitivesGallery.tsx | 12 +++++++--- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d4347738..0a3a06db 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -682,6 +682,19 @@ body { font-size: 14px; line-height: 1.5; } border-bottom-color: var(--quiz-accent, var(--accent)); } +/* A disabled primary reads as unavailable rather than vanishing: the quiz's + Submit stays in place, at reduced contrast, until an answer is selected + (§5 B2 — "never hidden"). Scoped to `--primary` only; the bordered and + ghost variants already fade legibly on their own, and repainting every + disabled button in the app is not this PR's change to make. Both forms are + covered: the DOM attribute, and `aria-disabled` for the controls that must + stay focusable and announced while inert. There is no opacity token. */ +.btn--primary:disabled, +.btn--primary[aria-disabled="true"] { + opacity: 0.55; + cursor: not-allowed; +} + /* ── SegmentedControl ──────────────────────────────────────────────── The underline mechanic four screens already re-implement, with the `.label-micro` type treatment none of them use. Not : that is the diff --git a/frontend/src/components/ui/Button.test.tsx b/frontend/src/components/ui/Button.test.tsx index c1ad8dcb..3f016841 100644 --- a/frontend/src/components/ui/Button.test.tsx +++ b/frontend/src/components/ui/Button.test.tsx @@ -30,6 +30,30 @@ describe("Button — the link variant (#537)", () => { expect(screen.getByRole("button", { name: "ghost" })).toHaveClass("btn--ghost", "btn--sm"); }); + it("keeps a disabled primary in place, not hidden — the .btn--primary rule fades it", () => { + render( + <> + + + , + ); + // Both forms must reach the CSS: :disabled for the DOM attribute, and + // [aria-disabled] for the controls that stay focusable while inert. + expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Score" })).toHaveAttribute( + "aria-disabled", + "true", + ); + for (const name of ["Submit", "Score"]) { + expect(screen.getByRole("button", { name })).toHaveClass("btn", "btn--primary"); + expect(screen.getByRole("button", { name })).toBeVisible(); + } + }); + it("exposes the open-dialog state the quiz's `adjust` link needs", () => { render( - - + + From 37e2bd5f28933292943e847c842cba6a1035739a Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:16:26 -0400 Subject: [PATCH 15/60] feat(quiz): resolve ?concept= / ?topic= deep links against the scoped graph (#537 A2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `?topic=` is the fuzzy legacy form every old tree and dashboard link still uses; `?concept=` is the precise one. Both now resolve through `entrySelection`, which reuses `quizSelection.resolveInitialSelection` for the id path rather than re-implementing "unknown id → nothing selected", and matches names case-insensitively for the topic path. Resolution runs against the SCOPED node list, so a link into a term the student isn't looking at comes back `unresolved: true` — §6 wants a toast and an ordinary home there, not a quiz on something off-screen. Subject roots are never a resolution target, so a course node sharing a concept's name can't win. Co-Authored-By: Claude Fable 5 --- frontend/src/components/quiz/QuizScreen.tsx | 21 +++++--- frontend/src/lib/quiz/proposals.test.ts | 44 ++++++++++++++++ frontend/src/lib/quiz/proposals.ts | 56 +++++++++++++++++++++ 3 files changed, 114 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/quiz/QuizScreen.tsx b/frontend/src/components/quiz/QuizScreen.tsx index c3b60813..ee66f483 100644 --- a/frontend/src/components/quiz/QuizScreen.tsx +++ b/frontend/src/components/quiz/QuizScreen.tsx @@ -28,7 +28,7 @@ import { siblingsFor } from "@/lib/graph/neighbourhood"; import { apiToGraphNode } from "@/lib/data"; import { parseEntry } from "@/lib/quiz/source"; import { loadPrefs } from "@/lib/quiz/prefs"; -import { colorFor } from "@/lib/quiz/proposals"; +import { colorFor, entrySelection } from "@/lib/quiz/proposals"; import { useQuizHome } from "@/lib/quiz/useQuizHome"; import { useQuizSession } from "@/lib/quiz/useQuizSession"; import { QuizHome } from "./home/QuizHome"; @@ -53,14 +53,21 @@ export function QuizScreen() { const home = useQuizHome(userId ?? "", semesterHydrated ? activeSemester : null); const { session, config, actions } = useQuizSession(userId ?? "", entry); - // The concept the screens are about: whatever the session is running, falling - // back to the proposal on offer. + // `?concept=` / `?topic=` resolved against the SCOPED graph, so a + // link into a term the student isn't looking at reads as unresolved rather + // than quietly quizzing something off-screen (§6). + const selection = useMemo( + () => entrySelection(entry, home.nodes, home.courses), + [entry, home.nodes, home.courses], + ); + + // The concept the screens are about: whatever the session is running, else the + // deep link, else the proposal on offer. const activeNode = useMemo(() => { - const byId = session.conceptId - ? home.nodes.find(n => n.id === session.conceptId) - : undefined; + const wanted = session.conceptId || selection.conceptId; + const byId = wanted ? home.nodes.find(n => n.id === wanted) : undefined; return byId ?? home.primary?.node ?? null; - }, [home.nodes, home.primary, session.conceptId]); + }, [home.nodes, home.primary, session.conceptId, selection.conceptId]); const activeCourse = useMemo( () => home.courses.find(c => c.course_id === activeNode?.course_id) ?? null, diff --git a/frontend/src/lib/quiz/proposals.test.ts b/frontend/src/lib/quiz/proposals.test.ts index c55623fa..1b8ddd1c 100644 --- a/frontend/src/lib/quiz/proposals.test.ts +++ b/frontend/src/lib/quiz/proposals.test.ts @@ -6,6 +6,7 @@ import { alternativesOf, colorFor, dueSet, + entrySelection, groupByCourse, isDue, latestCompletedAttempt, @@ -316,6 +317,49 @@ describe("groupByCourse", () => { }); }); +describe("entrySelection", () => { + const nodes = [ + node({ id: "n1", concept_name: "Recursion", course_id: "course-a" }), + node({ id: "n2", concept_name: "Big-O", course_id: "course-b" }), + node({ id: "root", concept_name: "Recursion", course_id: "course-a", is_subject_root: true }), + ]; + const courses = [course({ course_id: "course-a" }), course({ course_id: "course-b" })]; + + it("resolves a concept id and its course", () => { + expect(entrySelection({ concept: "n2" }, nodes, courses)) + .toEqual({ conceptId: "n2", courseId: "course-b", unresolved: false }); + }); + + it("resolves the legacy topic form by name, case-insensitively", () => { + expect(entrySelection({ topic: " recursion " }, nodes, courses)) + .toEqual({ conceptId: "n1", courseId: "course-a", unresolved: false }); + }); + + it("flags a link pointing outside the current scope rather than ignoring it", () => { + expect(entrySelection({ concept: "gone" }, nodes, courses)) + .toEqual({ conceptId: null, courseId: null, unresolved: true }); + expect(entrySelection({ topic: "Monads" }, nodes, courses).unresolved).toBe(true); + }); + + it("prefers the precise concept id over the fuzzy topic", () => { + expect(entrySelection({ concept: "n2", topic: "Recursion" }, nodes, courses).conceptId) + .toBe("n2"); + }); + + it("passes a course-only entry straight through", () => { + expect(entrySelection({ course: "course-b" }, nodes, courses)) + .toEqual({ conceptId: null, courseId: "course-b", unresolved: false }); + expect(entrySelection({}, nodes, courses)) + .toEqual({ conceptId: null, courseId: null, unresolved: false }); + }); + + it("never resolves to a subject root", () => { + // Both the root and a real concept are called "Recursion"; the leaf wins. + expect(entrySelection({ topic: "Recursion" }, nodes, courses).conceptId).toBe("n1"); + expect(entrySelection({ concept: "root" }, nodes, courses).unresolved).toBe(true); + }); +}); + describe("colorFor", () => { it("prefers the node's own course colour, then the course record", () => { expect(colorFor(node({ id: "n", course_color: "#111111" }), course({ course_id: "course-a", color: "#222222" }))) diff --git a/frontend/src/lib/quiz/proposals.ts b/frontend/src/lib/quiz/proposals.ts index eb9b8b61..89ff7d60 100644 --- a/frontend/src/lib/quiz/proposals.ts +++ b/frontend/src/lib/quiz/proposals.ts @@ -29,6 +29,7 @@ */ import { paletteFor } from "@/lib/data"; +import { resolveInitialSelection, type QuizConcept } from "@/lib/quizSelection"; import type { EnrolledCourse } from "@/lib/api"; import type { GraphNode } from "@/lib/types"; import { daysAgo, relativeStudied } from "./relativeTime"; @@ -210,6 +211,61 @@ export function queueFor( return scoped.filter(isDue).sort(byMasteryAsc).slice(0, QUEUE_MAX).map(n => n.id); } +/** What a deep link actually points at, once resolved against the loaded graph. */ +export interface EntrySelection { + conceptId: string | null; + courseId: string | null; + /** The link named a concept or topic that isn't in the current scope. §6 wants + * a toast and an ordinary home, not a silently ignored link. */ + unresolved: boolean; +} + +/** + * Resolves `?concept=` / `?topic=` against the scoped graph. + * + * The id path goes through `quizSelection.resolveInitialSelection` — the same + * resolver the old picker used, which already answers "unknown id → nothing + * selected" rather than pre-selecting a node that isn't there. `topic` is the + * fuzzy legacy form (a concept NAME, from the tree's and dashboard's old links), + * matched case-insensitively; `concept` wins when both are present. + */ +export function entrySelection( + entry: { concept?: string; topic?: string; course?: string }, + nodes: GraphNode[], + courses: EnrolledCourse[] = [], +): EntrySelection { + const byCourseId = new Map(courses.map(c => [c.course_id, c])); + const concepts: QuizConcept[] = nodes + .filter(n => !n.is_subject_root) + .map(n => ({ + id: n.id, + name: n.concept_name, + course_id: n.course_id ?? null, + course_code: n.course_id ? byCourseId.get(n.course_id)?.course_code ?? null : null, + })); + + if (entry.concept) { + const resolved = resolveInitialSelection(concepts, entry.concept); + return { + conceptId: resolved.conceptId, + courseId: resolved.courseId ?? entry.course ?? null, + unresolved: resolved.conceptId === null, + }; + } + + if (entry.topic) { + const wanted = entry.topic.trim().toLowerCase(); + const match = concepts.find(c => c.name.trim().toLowerCase() === wanted); + return { + conceptId: match?.id ?? null, + courseId: match?.course_id ?? entry.course ?? null, + unresolved: !match, + }; + } + + return { conceptId: null, courseId: entry.course ?? null, unresolved: false }; +} + /** * The "pick something specific" list: every concept, grouped under its course. * From b02923815fa59a030122c25cd671f1ffbd0946cd Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:17:46 -0400 Subject: [PATCH 16/60] fix(quiz): honest copy for a concept-less start, and dispatch SUBMIT_ANSWER (#537 A2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small ones. A start with no concept fell through to `describeQuizError` on a bare Error, which reads "something went wrong on our side" — untrue, and it tells the student nothing to do. It now uses the concept-not-found copy, whose advice ("pick another one") is the actual move. `submitAnswer` also dispatches SUBMIT_ANSWER before the call. The event is state-neutral by design — it is the machine's guard against submitting with nothing selected — but going through it keeps the transition table exercised rather than documented-only. Co-Authored-By: Claude Fable 5 --- frontend/src/lib/quiz/useQuizSession.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/quiz/useQuizSession.ts b/frontend/src/lib/quiz/useQuizSession.ts index dc70d840..70f29bfe 100644 --- a/frontend/src/lib/quiz/useQuizSession.ts +++ b/frontend/src/lib/quiz/useQuizSession.ts @@ -16,7 +16,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { answerQuestion, generateQuiz, getAttempt, submitQuiz } from "./api"; -import { describeQuizError, type QuizError } from "./errors"; +import { QUIZ_ERROR_COPY, describeQuizError, type QuizError } from "./errors"; import { returnToSource } from "./exits"; import { canExit, @@ -140,9 +140,16 @@ export function useQuizSession(userId: string, entry: EntryRequest): QuizSession const runGenerate = useCallback( async (from: QuizSession) => { if (!from.conceptId) { + // Defensive: every start affordance names a concept. If one ever + // doesn't, say something true rather than "something went wrong on our + // side" — the student's move is to pick a different concept. apply({ type: "GENERATE_FAILED", - error: describeQuizError(new Error("no concept selected")), + error: { + code: "QUIZ_CONCEPT_NOT_FOUND", + message: QUIZ_ERROR_COPY.QUIZ_CONCEPT_NOT_FOUND, + retryable: false, + }, }); return; } @@ -229,7 +236,11 @@ export function useQuizSession(userId: string, entry: EntryRequest): QuizSession ); const submitAnswer = useCallback(async () => { - const current = sessionRef.current; + // SUBMIT_ANSWER is the machine's own guard against a submit with nothing + // selected; dispatching it keeps the transition table honest even though it + // is state-neutral, and `canSubmitAnswer` is the same predicate the footer + // button disables on. + const current = apply({ type: "SUBMIT_ANSWER" }); if (!canSubmitAnswer(current) || !current.attemptId) return; const item = current.items[current.cursor]; const attemptId = current.attemptId; From c2a083b3bfe74dae46d6b29e251ffbcea3304c5a Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:21:13 -0400 Subject: [PATCH 17/60] docs(quiz): contract amendments from A2 (events, hook return, entrySelection, cancelTarget) (#537) Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-22-quiz-frontend-contract.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md b/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md index 7388d2c4..e202d8ee 100644 --- a/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md +++ b/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md @@ -366,6 +366,14 @@ QuizQuestion({ session, actions, config, concept: { id; name; courseCode; color; QuizResults({ session, actions, concept, neighbourhood: { siblings }, prefersReducedMotion }) ``` +### §4 amendments accepted from A2 (binding) +- Machine events also include `FAILED(err)` (a resume/answer failure with nowhere else to land) and `SET_CONFIG(config)` (Adjust "Done" changes settings without starting). `error(from)` is derived by `errorReturnPhase(session)`, not stored. +- `useQuizSession` returns `{ session, actions, pending, config }` — `pending` is true while a client call is in flight (B2 uses it for "Scoring…"/disabled Submit). +- `describeConcept(userId, conceptName, courseLabel?)` — third arg is the course LABEL the backend's `concept-description` route expects, not an id. +- `lib/quiz/exits.ts` also exports `cancelTarget(session)` (returnTo → `/dashboard`); B1's Cancel calls `actions.exit(cancelTarget(session))`. +- `lib/quiz/proposals.ts` also exports `entrySelection(entry, nodes, courses)` which resolves `?concept=`/`?topic=` against the scoped graph (reusing `quizSelection.resolveInitialSelection`) and returns `{ conceptId | null, unresolved: boolean }` — B1 shows the §6 toast when `unresolved`. +- The `sapling:graph-changed` CustomEvent (§5 B3) is dispatched by `useQuizSession` on SUBMITTED (`detail: { conceptId, masteryBefore, masteryAfter }`), not by the results screen. + --- ## 5. Screen specs (B1–B3) — the prototype is the visual authority; this is the behavioural one From b5e153b32491cdf2eed84e465e83a65ab9319ea7 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:21:42 -0400 Subject: [PATCH 18/60] feat(quiz): announce a mastery move on the submit path (#537 A2, fix round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refreshing the tree after a quiz has always been purely navigational — leave, land on /tree, and its mount-time getGraph picks up the new score (R5 §C). That does nothing for a graph already on screen, so a completed submit now dispatches `sapling:graph-changed` with `{conceptId, masteryBefore, masteryAfter}`. Dispatched from `useQuizSession`, not from the results screen (§5 B3 assigned it there): this is the one place that knows a submit actually LANDED, whereas a component firing it on render would repeat it for every re-render of the same result. Guarded on `typeof window`. Two tests: it fires exactly once per submit with the mastery move in `detail`, and it stays silent when the submit 409s. Co-Authored-By: Claude Fable 5 --- frontend/src/lib/quiz/useQuizSession.test.ts | 58 ++++++++++++++++++++ frontend/src/lib/quiz/useQuizSession.ts | 29 +++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/quiz/useQuizSession.test.ts b/frontend/src/lib/quiz/useQuizSession.test.ts index 9c498bf9..dcc1fdf0 100644 --- a/frontend/src/lib/quiz/useQuizSession.test.ts +++ b/frontend/src/lib/quiz/useQuizSession.test.ts @@ -228,6 +228,64 @@ describe("start → answer → answer → submit", () => { }); }); +describe('the "sapling:graph-changed" announcement', () => { + it("fires exactly once per submit, carrying the mastery move", async () => { + const seen: CustomEvent[] = []; + const listener = (e: Event) => seen.push(e as CustomEvent); + window.addEventListener("sapling:graph-changed", listener); + + try { + quizApi.generateQuiz.mockResolvedValue(generated(1)); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("results")); + + expect(seen).toHaveLength(1); + expect(seen[0].detail).toEqual({ + conceptId: "c1", + masteryBefore: 0.25, + masteryAfter: 0.31, + }); + } finally { + window.removeEventListener("sapling:graph-changed", listener); + } + }); + + it("stays silent when the submit failed", async () => { + const seen: Event[] = []; + const listener = (e: Event) => seen.push(e); + window.addEventListener("sapling:graph-changed", listener); + + try { + quizApi.generateQuiz.mockResolvedValue(generated(1)); + quizApi.submitQuiz.mockRejectedValue( + new ApiError("done", 409, { code: "QUIZ_ATTEMPT_ALREADY_COMPLETED" }), + ); + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + + expect(seen).toHaveLength(0); + } finally { + window.removeEventListener("sapling:graph-changed", listener); + } + }); +}); + describe("leave and resume", () => { it("parks the session, navigates back to the source, and picks it up again", async () => { const { result, unmount } = mount(); diff --git a/frontend/src/lib/quiz/useQuizSession.ts b/frontend/src/lib/quiz/useQuizSession.ts index 70f29bfe..c2699f32 100644 --- a/frontend/src/lib/quiz/useQuizSession.ts +++ b/frontend/src/lib/quiz/useQuizSession.ts @@ -34,7 +34,7 @@ import { clearSession, persistSession, loadSession } from "./session"; import type { EntryRequest } from "./source"; import { useGamificationDelta } from "./useGamificationDelta"; import { useQuizConfig } from "./useQuizConfig"; -import type { QuizConfig, QuizSession } from "./types"; +import type { QuizConfig, QuizSession, SubmitResult } from "./types"; export interface QuizActions { configure(open: boolean): void; @@ -69,6 +69,32 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } +/** + * Tell any mounted graph that mastery just moved. + * + * Refreshing the tree after a quiz has always been purely navigational — leave + * the quiz, land on `/tree`, and its own mount-time `getGraph` picks up the new + * score (R5 §C). That still works, but it is nothing for a graph already on + * screen, so submit announces itself. Cheap and advisory: nothing listens today + * and nothing has to. + * + * Dispatched here rather than from the results screen because this is the one + * place that knows a submit actually LANDED — a component firing it on render + * would repeat it on every re-render of the same result. + */ +function announceGraphChanged(conceptId: string, result: SubmitResult): void { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent("sapling:graph-changed", { + detail: { + conceptId, + masteryBefore: result.mastery_before, + masteryAfter: result.mastery_after, + }, + }), + ); +} + /** * Retries exactly once, and only for a transport failure. * @@ -197,6 +223,7 @@ export function useQuizSession(userId: string, entry: EntryRequest): QuizSession const xp = await gamification.deltaAfterSubmit(); apply({ type: "SUBMITTED", result, xp }); clearSession(); + announceGraphChanged(from.conceptId, result); } catch (err) { apply({ type: "SUBMIT_FAILED", error: describeQuizError(err) }); } finally { From f64e816f3f3d2eb84df2d00d9963a95878fe820c Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:32:12 -0400 Subject: [PATCH 19/60] fix(ui): the promoted EmptyState renders Gradebook unchanged, and answers are 15px (#537, review I-1 + M-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1 — the promotion had three near-misses on a screen A1 doesn't own: the eyebrow shrank 11px -> 10px and lost `ss01` (`.mono` carries it, `.label-micro` does not), the body 17px -> 16px, and `btn--lg` made the CTA 1px shorter and 100 heavier than the inline `10px 18px` / weight 500 it replaced. The four values Gradebook was drawn at are now tokens beside `--empty-hero-fs`, spent by three `--hero` rules, and `Landing.tsx` drops `btn--lg`. Pinned by three tests in EmptyState.test.tsx: the markup carries the classes the rules hang off, the CTA is a plain primary and not `btn--lg`, and the tokens plus the three rules are asserted against globals.css itself — jsdom applies no stylesheet, so the values need pinning where they live. M-4 — the answer row's text was `--fs-md` (13px) per §3; the design draws 15px and every other number in that row already matches the design exactly. The `--fs-*` ramp has no 15px step and documents itself as derived-from-an-audit rather than invented, so this is `--answer-text-fs` beside the row's other pinned geometry, asserted in AnswerOption.test.tsx. Co-Authored-By: Claude Fable 5 --- frontend/src/app/globals.css | 72 +++++++------------ .../components/screens/Gradebook/Landing.tsx | 8 ++- .../src/components/ui/AnswerOption.test.tsx | 10 +++ .../src/components/ui/EmptyState.test.tsx | 67 +++++++++++++++++ 4 files changed, 108 insertions(+), 49 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 0a3a06db..7c2e3539 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -652,7 +652,17 @@ body { font-size: 14px; line-height: 1.5; } --progress-dot: 9px; --progress-dot-todo: 7px; --sheet-width: 480px; /* default; overrides per instance */ - --empty-hero-fs: 56px; /* promoted from Gradebook's blank-semester title */ + /* The answer row's body size. The --fs-* scale has no 15px step and is + documented as derived-from-an-audit, not invented, so the design's own + value is pinned here rather than bolted onto the global type ramp. */ + --answer-text-fs: 15px; + /* Gradebook's blank-semester screen, promoted verbatim with . These four are the values that screen was drawn at; the + hero rules below exist so the promotion renders it unchanged. */ + --empty-hero-fs: 56px; + --empty-hero-eyebrow-fs: 11px; + --empty-hero-body-fs: 17px; + --empty-hero-cta-pad: 10px 18px; } /* ── Button, `link` variant ────────────────────────────────────────── @@ -754,7 +764,7 @@ body { font-size: 14px; line-height: 1.5; } .answer-option__text { flex: 1; min-width: 0; - font-size: var(--fs-md); + font-size: var(--answer-text-fs); line-height: 1.55; color: var(--text-dim); } @@ -939,7 +949,19 @@ body { font-size: 14px; line-height: 1.5; } } .empty-state--hero .empty-state__body { margin-bottom: 32px; - font-size: var(--fs-lg); + font-size: var(--empty-hero-body-fs); +} +/* `.label-micro` is 10px and drops ss01; the screen this was promoted from + used `.mono` at 11px, which keeps it. Restore both for the hero size. */ +.empty-state--hero .empty-state__eyebrow { + font-size: var(--empty-hero-eyebrow-fs); + font-feature-settings: "ss01"; +} +/* The hero CTA is a plain `.btn--primary` at the promoted padding and size — + NOT `.btn--lg`, which is 1px shorter and 100 heavier. */ +.empty-state--hero .empty-state__action .btn { + padding: var(--empty-hero-cta-pad); + font-size: var(--fs-base); } /* ── ConceptNode / ConceptNeighbourhood ────────────────────────────── @@ -981,50 +1003,6 @@ body { font-size: 14px; line-height: 1.5; } transition: transform var(--dur-slow) var(--ease); } -/* ── QuizPrimitivesGallery ─────────────────────────────────────────── - The harness at components/ui/__fixtures__ that shows every primitive in - every state at the shell's real content width. Not shipped in any route; - it lives here rather than inline so the fixture obeys the same - classes-and-tokens-only rule as the primitives it displays. */ -.quiz-gallery { - max-width: 900px; - padding: var(--pad-xl); - display: flex; - flex-direction: column; - gap: 44px; -} -.quiz-gallery__title { margin: 0; font-size: var(--fs-4xl); } -.quiz-gallery__section { - display: flex; - flex-direction: column; - gap: 18px; - padding-top: 20px; - border-top: 1px solid var(--border); -} -.quiz-gallery__heading { margin: 0; } -.quiz-gallery__row { - display: flex; - align-items: flex-start; - gap: var(--pad-lg); -} -.quiz-gallery__caption { - width: 200px; - flex-shrink: 0; - font-size: var(--fs-sm); - color: var(--text-muted); -} -.quiz-gallery__specimens { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 18px; - min-width: 0; -} -.quiz-gallery__answers { - width: 100%; - border-top: 1px solid var(--border); -} - /* ══════════════════════════════════════════════════════════════════ LANDING PAGE STYLES (ported from main branch) ══════════════════════════════════════════════════════════════════ */ diff --git a/frontend/src/components/screens/Gradebook/Landing.tsx b/frontend/src/components/screens/Gradebook/Landing.tsx index fc8c2e5b..406c47b9 100644 --- a/frontend/src/components/screens/Gradebook/Landing.tsx +++ b/frontend/src/components/screens/Gradebook/Landing.tsx @@ -365,7 +365,11 @@ function LoadingSkeleton() { // The empty state itself is now the shared `ui/EmptyState` (#537) — this // screen's private copy was the only one in the app, so the quiz's two would -// have made three. `size="hero"` is this screen's display-scale treatment. +// have made three. `size="hero"` is this screen's display-scale treatment, +// and it reproduces what this screen used to draw inline exactly: 56px title, +// 11px ss01 eyebrow, 17px body, and a plain `.btn--primary` at 10px/18px — +// deliberately NOT `btn--lg`, which is 1px shorter and 100 heavier. The +// values live as tokens in globals.css and are pinned in EmptyState.test.tsx. function GradebookEmptyState({ semesterLabel, onUpload, @@ -382,7 +386,7 @@ function GradebookEmptyState({ action={ + } + /> + ); + + it("hangs every hero rule off the classes the old inline styles carried", () => { + const { container } = render(GRADEBOOK); + const root = container.querySelector(".empty-state")!; + expect(root).toHaveClass("empty-state--hero"); + // Eyebrow: `.label-micro` inside `--hero`, which is what restores 11px+ss01. + expect(root.querySelector(".empty-state__eyebrow")).toHaveClass("label-micro"); + // Title/body keep the type classes the old inline font-families named. + expect(root.querySelector(".empty-state__title")).toHaveClass("h-serif"); + expect(root.querySelector(".empty-state__body")).toHaveClass("body-serif"); + }); + + it("keeps the CTA a plain primary — btn--lg is 1px shorter and 100 heavier", () => { + render(GRADEBOOK); + const cta = screen.getByTestId("gradebook-upload-syllabus"); + expect(cta).toHaveClass("btn", "btn--primary"); + expect(cta).not.toHaveClass("btn--lg"); + expect(cta).not.toHaveClass("btn--sm"); + }); + + it("pins the four promoted values in globals.css", () => { + const css = fs.readFileSync( + path.resolve(__dirname, "../../app/globals.css"), + "utf8", + ); + // The tokens, at the values the screen was drawn at… + expect(css).toMatch(/--empty-hero-fs:\s*56px;/); + expect(css).toMatch(/--empty-hero-eyebrow-fs:\s*11px;/); + expect(css).toMatch(/--empty-hero-body-fs:\s*17px;/); + expect(css).toMatch(/--empty-hero-cta-pad:\s*10px 18px;/); + // …and the rules that actually spend them, including the ss01 `.mono` + // carried and `.label-micro` does not. + expect(css).toMatch( + /\.empty-state--hero \.empty-state__eyebrow \{[^}]*font-size:\s*var\(--empty-hero-eyebrow-fs\)[^}]*font-feature-settings:\s*"ss01"[^}]*\}/, + ); + expect(css).toMatch( + /\.empty-state--hero \.empty-state__body \{[^}]*font-size:\s*var\(--empty-hero-body-fs\)[^}]*\}/, + ); + expect(css).toMatch( + /\.empty-state--hero \.empty-state__action \.btn \{[^}]*padding:\s*var\(--empty-hero-cta-pad\)[^}]*\}/, + ); + }); +}); From 8bcb03d69af507b3eb1190abb1e4a0a803a8df42 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:32:22 -0400 Subject: [PATCH 20/60] refactor(ui): the gallery's dev-only CSS leaves globals.css (#537, review I-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~45 lines of `.quiz-gallery*` rules for a component no route mounts were being parsed on every page load, inside the block Waves 3-5 are told not to touch. They move to quizPrimitivesGallery.css, co-located with the fixture and imported by it — the same App-Router mechanism R-1 gives the quiz's per-screen CSS. globals.css now holds primitives only. Co-Authored-By: Claude Fable 5 --- .../ui/__fixtures__/QuizPrimitivesGallery.tsx | 12 +++-- .../ui/__fixtures__/quizPrimitivesGallery.css | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/ui/__fixtures__/quizPrimitivesGallery.css diff --git a/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx b/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx index 21a27c3a..ab0c0780 100644 --- a/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx +++ b/frontend/src/components/ui/__fixtures__/QuizPrimitivesGallery.tsx @@ -28,6 +28,8 @@ import { InlineBanner } from "../InlineBanner"; import { ProgressDots } from "../ProgressDots"; import { SegmentedControl } from "../SegmentedControl"; import { Sheet } from "../Sheet"; +// The harness's own layout, co-located rather than shipped in globals.css. +import "./quizPrimitivesGallery.css"; /** The prototype's CS101 purple, so the gallery matches the design's screens. */ const COURSE_COLOR = "#7b4b99"; @@ -322,8 +324,10 @@ export function QuizPrimitivesGallery() { + {/* The composition (`compact` +8px nudge vs `wide` dead-centre) is picked + from the canvas width, so these four rows show both without asking. */}
- + - + - + - + Date: Sat, 22 Aug 2026 16:32:37 -0400 Subject: [PATCH 21/60] fix(graph): the results neighbourhood is drawn where the design draws it (#537, review I-3 + M-6..M-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-3 — one fractional slot table can't serve all three canvases: forcing the small presets' fractions onto the 640-wide results set piece left its centre 8px right of true centre and its top-left sibling 19px adrift. There are now two compositions, because the design has two — `compact` (+8px nudge, the average of home and the concept dialog, within ~6px on both) and `wide` (dead-centre, the results canvas read off directly). The default is picked from `width`, so the three documented presets need never pass it, and an explicit `composition` prop is the override. Results is now pixel-true: centre (320,106), siblings (96,34) / (628,48) / (86,208) — asserted. Also from the review: `NODE_REF_RADIUS` un-exported (M-6, no consumer outside its module); Button's doc comment no longer claims `size` is ignored by `link` when `btn--${size}` is emitted regardless (M-7); Sheet's inherited scroll-lock gets its own assertion — engaged on open, released on unmount (M-8). The gallery names each canvas's composition. Co-Authored-By: Claude Fable 5 --- .../graph/ConceptNeighbourhood.test.tsx | 43 +++++++++++ .../components/graph/ConceptNeighbourhood.tsx | 73 ++++++++++++++----- frontend/src/components/graph/ConceptNode.tsx | 5 +- frontend/src/components/ui/Button.tsx | 3 +- frontend/src/components/ui/Sheet.test.tsx | 21 ++++++ 5 files changed, 125 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/graph/ConceptNeighbourhood.test.tsx b/frontend/src/components/graph/ConceptNeighbourhood.test.tsx index e029650c..43906dab 100644 --- a/frontend/src/components/graph/ConceptNeighbourhood.test.tsx +++ b/frontend/src/components/graph/ConceptNeighbourhood.test.tsx @@ -150,6 +150,49 @@ describe("ConceptNeighbourhood", () => { expect(container.querySelectorAll(".concept-neighbourhood__edge")).toHaveLength(3); }); + it("draws the wide results canvas exactly where the design draws it", () => { + const { container } = render( + , + ); + const bodies = Array.from(container.querySelectorAll(".concept-node__body")); + const at = (el: Element) => [num(el, "cx"), num(el, "cy")]; + // The design's own numbers: centre (320,106) with NO rightward nudge, and + // siblings at (96,34) / (628,48) / (86,208). Within a pixel on each. + expect(at(bodies[3])).toEqual([320, 106]); + const expected = [ + [96, 34], + [628, 48], + [86, 208], + ]; + expected.forEach(([x, y], i) => { + expect(at(bodies[i])[0]).toBeCloseTo(x, 0); + expect(at(bodies[i])[1]).toBeCloseTo(y, 0); + }); + }); + + it("picks the composition from the width, and lets a caller override it", () => { + // 640 wide → wide → centred. + const wide = renderHome({ width: 640, height: 212, scale: 2.5 }); + expect(num(wide.container.querySelectorAll(".concept-node__body")[3], "cx")).toBe(320); + cleanup(); + // The same canvas, forced compact → the 8px nudge comes back. + const forced = renderHome({ + width: 640, + height: 212, + scale: 2.5, + composition: "compact", + }); + expect(num(forced.container.querySelectorAll(".concept-node__body")[3], "cx")).toBe(328); + }); + it("renders the results preset's growth centre at the after-radius with reduced motion", () => { const { container } = render( = { + compact: { + centreOffsetX: 8, + slots: [ + [0.12, 0.14], // top-left + [0.975, 0.18], // top-right — on the edge, so never captioned + [0.15, 0.96], // bottom-left + ], + }, + wide: { + centreOffsetX: 0, + slots: [ + [0.15, 0.16], + [0.98125, 0.22642], + [0.134375, 0.98113], + ], + }, +}; -/** Rightward nudge of the centre, in px, balancing the two left-hand slots. */ -const CENTRE_OFFSET_X = 8; +/** At or above this width the canvas is drawn as the results set piece. */ +const WIDE_CANVAS_MIN_WIDTH = 480; /** Edge opacity — the tree's resting value for the `organism` variant. */ const EDGE_OPACITY = 0.2; @@ -60,6 +92,11 @@ export interface ConceptNeighbourhoodProps { ariaLabel: string; /** Growth only. `prefers-reduced-motion` overrides a `true` here. */ animate?: boolean; + /** + * Which of the design's two arrangements to draw. Defaults from `width` + * (>= 480 → `wide`), so the three documented presets need never pass it. + */ + composition?: NeighbourhoodComposition; testid?: string; } @@ -74,18 +111,20 @@ export function ConceptNeighbourhood({ showLabels = true, ariaLabel, animate = true, + composition = width >= WIDE_CANVAS_MIN_WIDTH ? "wide" : "compact", testid, }: ConceptNeighbourhoodProps) { const filterId = `concept-neighbourhood-glow-${React.useId()}`; const [grown] = useGrowth(centreVariant, animate); - const cx = width / 2 + CENTRE_OFFSET_X; + const { centreOffsetX, slots } = COMPOSITIONS[composition]; + const cx = width / 2 + centreOffsetX; const cy = height / 2; - const placed = siblings.slice(0, SLOTS.length).map((sibling, i) => ({ + const placed = siblings.slice(0, slots.length).map((sibling, i) => ({ sibling, - x: SLOTS[i][0] * width, - y: SLOTS[i][1] * height, + x: slots[i][0] * width, + y: slots[i][1] * height, // Only the two left-hand slots are captioned (see the header note). captioned: showLabels && i !== 1, })); diff --git a/frontend/src/components/graph/ConceptNode.tsx b/frontend/src/components/graph/ConceptNode.tsx index 61b3cf2e..c446171e 100644 --- a/frontend/src/components/graph/ConceptNode.tsx +++ b/frontend/src/components/graph/ConceptNode.tsx @@ -36,8 +36,9 @@ export type ConceptNodeVariant = | { kind: "node" } | { kind: "growth"; before: number; after: number }; -/** Half the reference box. `radiusFor()`'s outputs are in these units. */ -export const NODE_REF_RADIUS = 15; +/** Half the reference box. `radiusFor()`'s outputs are in these units. + * Module-local: consumers want `NODE_REF_BOX` or `markRadius()`. */ +const NODE_REF_RADIUS = 15; /** The reference viewBox is square at twice the reference radius. */ export const NODE_REF_BOX = NODE_REF_RADIUS * 2; /** Reference-unit gap between the mark and its caption — the tree's `r + 13`. */ diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index e4dda3df..a01eb94e 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -16,7 +16,8 @@ type Size = "sm" | "md" | "lg" | "xl"; // and "Done" are text, not chrome, and `ghost` keeps button padding so it // still reads as a control (#537). `aria-pressed` / `data-active="true"` // underlines it in the accent, which is how the quiz shows "this link's dialog -// is open". `size` is ignored by `link`: it has no padding to scale. +// is open". Leave `size` at its default on a link: `.btn--sm`/`--lg`/`--xl` +// still apply their padding, which is the shape `link` exists to shed. export function Button({ variant = "secondary", size = "md", diff --git a/frontend/src/components/ui/Sheet.test.tsx b/frontend/src/components/ui/Sheet.test.tsx index 9c935833..478c129e 100644 --- a/frontend/src/components/ui/Sheet.test.tsx +++ b/frontend/src/components/ui/Sheet.test.tsx @@ -117,6 +117,27 @@ describe("Sheet", () => { vi.useRealTimers(); }); + it("locks the scrolling container while open and releases it on close", () => { + // The lock is Dialog's, inherited through useOverlayBehaviour — but §3 + // names it as a Sheet requirement, so it gets its own assertion. + const scroller = document.createElement("div"); + scroller.setAttribute("data-scroll-container", ""); + document.body.appendChild(scroller); + expect(scroller.style.getPropertyValue("overflow-y")).toBe(""); + + const { unmount } = render( + {}} title="Ask about this"> +

body

+
, + ); + expect(scroller.style.getPropertyValue("overflow-y")).toBe("hidden"); + expect(scroller.style.getPropertyValue("overflow-x")).toBe("hidden"); + + unmount(); + expect(scroller.style.getPropertyValue("overflow-y")).toBe(""); + scroller.remove(); + }); + it("takes its width as a custom property so the rule stays in the stylesheet", () => { open({ width: 560 }); expect(screen.getByRole("dialog").style.getPropertyValue("--sheet-width")).toBe("560px"); From 48d8577050f45e13a053f2e109cf0e60bf4fd4c6 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:38:05 -0400 Subject: [PATCH 22/60] fix(quiz): guard a double-pressed Submit, and stop hiding two files from git (#537 A2, fix round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I-1 — `submitAnswer` had no re-entrancy guard, and the phase stays `active` for the whole round trip, so neither `canSubmitAnswer` nor a `phase !== "active"` disabled check ruled out a second press. Two `/answer` calls went out, and because `advanceAfterAnswer` positions the cursor from the RESPONDED index, the late duplicate dragged the quiz backwards under the student — re-revealing an old verdict in as-you-go, re-asking an answered question in at-end. Fixed at both ends: an in-flight ref keyed `attemptId:index` in the hook (released in the `finally`, so a failure can still be retried), and the reducer now drops an `ANSWER_RECORDED` that is behind the cursor or lands on an item that already has a verdict. `/answer` is idempotent server-side, so there is nothing to reconcile. Invariant 6 is untouched: a FIRST response with `recorded: false` still advances, and has its own test saying so. I-2 — `useQuizHome.ts` and `source.test.ts` each carried a literal NUL byte, so git called both binary: the hook that owns resume discovery and the test that pins open-redirect rejection were invisible in every diff, blame hunk and review. Identical bytes at runtime, written as unicode escapes now, with a comment on the cache key saying why it must stay an escape. I-3 — `runResume` hardcoded the not-resumable sentence instead of reading `QUIZ_ERROR_COPY`. `errors.ts` is the one module and its table is pinned string-for-string; a second unpinned copy is free to drift. Minors: the dead `canSubmitAnswer ? session : session` ternary (and the test it made vacuous now asserts state-neutrality in both directions, plus a new case for a cursor pointing at no item); `configAppliedRef` set only once the machine ACCEPTS the config, with the phase in the deps, so a `/config` that resolves during a fast start no longer marks itself applied while being dropped; `nextInQueue`'s uncomputed `courseId` parameter dropped, and a cross-course `due` hop now reports `null` rather than the previous concept's course; dead `clearPrefs` and the dead `.quiz-root` display rules removed; `START`, `NEXT` and `FINISH` added to invariant 1's leavers table. Co-Authored-By: Claude Fable 5 --- frontend/src/components/quiz/quiz.css | 5 +- frontend/src/lib/quiz/machine.test.ts | 54 +++++++++++++++- frontend/src/lib/quiz/machine.ts | 31 ++++++--- frontend/src/lib/quiz/prefs.ts | 8 --- frontend/src/lib/quiz/source.test.ts | Bin 5604 -> 5609 bytes frontend/src/lib/quiz/useQuizHome.ts | Bin 9814 -> 10063 bytes frontend/src/lib/quiz/useQuizSession.test.ts | 63 +++++++++++++++++++ frontend/src/lib/quiz/useQuizSession.ts | 30 ++++++--- 8 files changed, 163 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/quiz/quiz.css b/frontend/src/components/quiz/quiz.css index 54e62e56..2f948e34 100644 --- a/frontend/src/components/quiz/quiz.css +++ b/frontend/src/components/quiz/quiz.css @@ -29,8 +29,9 @@ so the content column stays optically centred. */ --quiz-rail: 64px; - display: flex; - flex-direction: column; + /* `display` and `flex-direction` are NOT set here: `FullHeightScreen` writes + both inline and inline wins, so restating them would be a rule that never + applies. This class carries the tokens and the sizing only. */ flex: 1; min-height: 0; } diff --git a/frontend/src/lib/quiz/machine.test.ts b/frontend/src/lib/quiz/machine.test.ts index c0065f61..0bff6fe1 100644 --- a/frontend/src/lib/quiz/machine.test.ts +++ b/frontend/src/lib/quiz/machine.test.ts @@ -571,6 +571,9 @@ describe("invariant 1 — nothing walks out of a live quiz by accident", () => { { type: "CANCEL_LEAVE" }, { type: "FAILED", error: { code: "NETWORK", message: "offline", retryable: true } }, { type: "SET_CONFIG", config: { count: 10, difficulty: "hard", feedback: "as-you-go" } }, + { type: "START", start: startOf("c9"), config: { count: 3, difficulty: "easy", feedback: "at-end" } }, + { type: "NEXT" }, + { type: "FINISH" }, ]; it("no event takes active to home or to an exit", () => { @@ -586,7 +589,9 @@ describe("invariant 1 — nothing walks out of a live quiz by accident", () => { s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); expect(s.phase).toBe("answered"); for (const event of LEAVERS) { - if (event.type === "CANCEL_LEAVE") continue; + // NEXT is the submit path out of `answered`, not a way to walk out — its + // own transitions are pinned in "answering — as-you-go" above. + if (event.type === "NEXT") continue; expect(reduce(s, event).phase, `${event.type} from answered`).toBe("answered"); } }); @@ -705,13 +710,56 @@ describe("invariant 4 — SELECT is ignored once the verdict is showing", () => expect(after.items[0].selectedIndex).toBe(1); }); - it("SUBMIT_ANSWER without a selection is ignored", () => { + it("SUBMIT_ANSWER is gated by canSubmitAnswer, and never moves the session", () => { const s = activeSession(3); expect(canSubmitAnswer(s)).toBe(false); - expect(reduce(s, { type: "SUBMIT_ANSWER" })).toBe(s); const selected = reduce(s, { type: "SELECT", index: 0 }); expect(canSubmitAnswer(selected)).toBe(true); + + // SUBMIT_ANSWER is state-neutral BY DESIGN (the request is the hook's job), + // so it must not move the session in either direction. + expect(reduce(s, { type: "SUBMIT_ANSWER" })).toBe(s); + expect(reduce(selected, { type: "SUBMIT_ANSWER" })).toBe(selected); + }); + + it("canSubmitAnswer is false when the cursor points at no item at all", () => { + const empty = { ...activeSession(1), items: [], cursor: 0 }; + expect(canSubmitAnswer(empty)).toBe(false); + const past = { ...activeSession(1), cursor: 9 }; + expect(canSubmitAnswer(past)).toBe(false); + }); +}); + +describe("a stale or duplicate ANSWER_RECORDED never drags the cursor backwards", () => { + it("ignores a response for an item the student has already passed", () => { + let s = reduce(activeSession(3, "at-end"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + expect(s.cursor).toBe(1); + + // The duplicate from a double-click lands late. At-end would otherwise put + // the cursor back to index 0 + 1, re-asking a question already answered. + const after = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + expect(after).toBe(s); + expect(after.cursor).toBe(1); + }); + + it("ignores a second response for the CURRENT item once it has a verdict", () => { + let s = reduce(activeSession(3, "at-end"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0) }); + s = reduce(s, { type: "SELECT", index: 2 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(1) }); + expect(s.cursor).toBe(2); + + const replay = reduce(s, { type: "ANSWER_RECORDED", result: answered(1, false) }); + expect(replay).toBe(s); + expect(s.items[1].verdict?.isCorrect).toBe(true); + }); + + it("still advances a first response with recorded:false (invariant 6 intact)", () => { + let s = reduce(activeSession(3, "at-end"), { type: "SELECT", index: 1 }); + s = reduce(s, { type: "ANSWER_RECORDED", result: answered(0, true, false) }); + expect(s.cursor).toBe(1); }); }); diff --git a/frontend/src/lib/quiz/machine.ts b/frontend/src/lib/quiz/machine.ts index 15ac5a87..8eab1864 100644 --- a/frontend/src/lib/quiz/machine.ts +++ b/frontend/src/lib/quiz/machine.ts @@ -59,7 +59,7 @@ export type QuizEvent = /** `numQuestions` is already clamped to `/config`'s min/max by the caller — * the reducer must never know those bounds. */ | { type: "PRACTISE_MISSED"; missedCount: number; numQuestions: number } - | { type: "NEXT_IN_QUEUE"; courseId?: string | null } + | { type: "NEXT_IN_QUEUE" } | { type: "EXIT" } | { type: "FLAG" } | { type: "DISMISS_ERROR" } @@ -163,8 +163,9 @@ export function isLastItem(session: QuizSession, index = session.cursor): boolea /** Submitting an answer needs a live question with a chosen option. */ export function canSubmitAnswer(session: QuizSession): boolean { - return session.phase === "active" && session.items[session.cursor]?.selectedIndex !== null - && session.items[session.cursor] !== undefined; + if (session.phase !== "active") return false; + const item = session.items[session.cursor]; + return item !== undefined && item.selectedIndex !== null; } /** Leaving for another screen. False for every phase where an attempt is live — @@ -306,15 +307,24 @@ export function reduce(session: QuizSession, event: QuizEvent): QuizSession { } case "SUBMIT_ANSWER": { - // A guard, not a transition: the network call is the hook's job and the - // "pending" look is local to the footer button. - return canSubmitAnswer(session) ? session : session; + // State-neutral by design: the network call is the hook's job and the + // "pending" look is local to the footer button. `canSubmitAnswer` is the + // predicate that actually gates it, in the hook and on the button alike. + return session; } case "ANSWER_RECORDED": { if (session.phase !== "active") return session; const index = event.result.question_index; - if (!session.items[index]) return session; + const target = session.items[index]; + if (!target) return session; + // A response for an item already left behind can only be a duplicate or a + // late arrival. `advanceAfterAnswer` positions the cursor from the + // RESPONDED index, so honouring one would drag the quiz backwards under + // the student — re-revealing an old verdict in as-you-go, or re-asking a + // question in at-end. `/answer` is idempotent server-side, so there is + // nothing to reconcile: drop it. + if (index < session.cursor || target.verdict !== null) return session; // `recorded: false` is an idempotent replay or a lost race — the answer // still stands, so it advances exactly like a fresh record (invariant 6). const scored = withItem(session, index, { @@ -402,7 +412,12 @@ export function reduce(session: QuizSession, event: QuizEvent): QuizSession { return generatingFrom(session, { queueIndex: next, conceptId: queue[next], - courseId: event.courseId !== undefined ? event.courseId : session.courseId, + // A `course` queue stays inside its course. A `due` queue spans them, + // and the reducer has no node list to look the new one up in — so the + // honest answer is "unknown" rather than the previous concept's course. + // Nothing on screen reads this: the screens resolve the course (and the + // accent) from the graph by concept id. + courseId: session.scope.kind === "course" ? session.courseId : null, }); } diff --git a/frontend/src/lib/quiz/prefs.ts b/frontend/src/lib/quiz/prefs.ts index 9b7ed32d..41bb74fa 100644 --- a/frontend/src/lib/quiz/prefs.ts +++ b/frontend/src/lib/quiz/prefs.ts @@ -70,11 +70,3 @@ export function savePrefs(prefs: QuizPrefs): void { // A forgotten preference is a downgrade, never a failure. } } - -export function clearPrefs(): void { - try { - if (typeof window !== "undefined") window.localStorage.removeItem(PREFS_KEY); - } catch { - // See above. - } -} diff --git a/frontend/src/lib/quiz/source.test.ts b/frontend/src/lib/quiz/source.test.ts index 6abc7ff68236e3675a4dd31d1d8f66ee4c4c96e4..05652dc71b3bb27c05628b2c0ddac4726070f0ae 100644 GIT binary patch delta 23 dcmaE&{Ze~FD=%A2sR0m7R^$=hJd@X(0|01n2Ydhk delta 18 ZcmaE<{X~01D=#C%WJMm~&GUG@IRHY@1?vC+ diff --git a/frontend/src/lib/quiz/useQuizHome.ts b/frontend/src/lib/quiz/useQuizHome.ts index 4e1b652a0a26d4162ddc0957323057684e4ef754..44dbd335010b9f4c331a9e06fd8b328e351ef6cd 100644 GIT binary patch delta 292 zcmXYr!Ab)`5Jc~uyzIRQIc^e<5(Ej7gAjrs;>ClrJH6Q^v(wNsYgmHlKlmepAL1?F zW3SqWq6>;wb=rJyKd*|n#RA)h&CFBajx%{AK@_luC^Bh>;3^8JT|^_gBRJF&0wZcth#_|$h0)C?l4xOb9+i2JI=^T&a@>QS_y=P zO%BOg{{WYNVATKs delta 45 zcmX@_cg<&mjp$@$F%207dj%yW1uKOFmFm3wyyVnc29@g4;?yF~lv;+(m15Tf08SVV A%>V!Z diff --git a/frontend/src/lib/quiz/useQuizSession.test.ts b/frontend/src/lib/quiz/useQuizSession.test.ts index dcc1fdf0..ff9468e2 100644 --- a/frontend/src/lib/quiz/useQuizSession.test.ts +++ b/frontend/src/lib/quiz/useQuizSession.test.ts @@ -228,6 +228,69 @@ describe("start → answer → answer → submit", () => { }); }); +describe("a double-pressed Submit only answers once", () => { + it("fires exactly one /answer for the item while the first is in flight", async () => { + // The phase stays `active` for the whole round trip, so neither + // canSubmitAnswer nor a `phase !== "active"` disabled check rules the second + // press out — only the hook's in-flight guard does. + let release: (value: AnswerResult) => void = () => {}; + quizApi.answerQuestion.mockImplementationOnce( + () => new Promise(resolve => { + release = resolve; + }), + ); + + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + act(() => { + result.current.actions.submitAnswer(); + result.current.actions.submitAnswer(); + result.current.actions.submitAnswer(); + }); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(1); + + await act(async () => { + release(answerResult(0)); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(1); + + // The guard is per item, not a one-shot latch: the next question submits. + act(() => result.current.actions.select(2)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(2); + }); + + it("releases the guard after a failure, so retrying is possible", async () => { + quizApi.answerQuestion.mockRejectedValueOnce( + new ApiError("bad", 400, { code: "QUIZ_QUESTION_INVALID" }), + ); + + const { result } = mount(); + await waitFor(() => expect(result.current.config).not.toBeNull()); + act(() => result.current.actions.start(START)); + await waitFor(() => expect(result.current.session.phase).toBe("active")); + + act(() => result.current.actions.select(1)); + await act(async () => { + result.current.actions.submitAnswer(); + }); + await waitFor(() => expect(result.current.session.phase).toBe("error")); + + await act(async () => { + result.current.actions.retry(); + }); + await waitFor(() => expect(result.current.session.cursor).toBe(1)); + expect(quizApi.answerQuestion).toHaveBeenCalledTimes(2); + }); +}); + describe('the "sapling:graph-changed" announcement', () => { it("fires exactly once per submit, carrying the mastery move", async () => { const seen: CustomEvent[] = []; diff --git a/frontend/src/lib/quiz/useQuizSession.ts b/frontend/src/lib/quiz/useQuizSession.ts index c2699f32..c9a83dd1 100644 --- a/frontend/src/lib/quiz/useQuizSession.ts +++ b/frontend/src/lib/quiz/useQuizSession.ts @@ -49,7 +49,7 @@ export interface QuizActions { confirmLeave(): void; resume(attemptId: string): void; practiseMissed(): void; - nextInQueue(courseId?: string | null): void; + nextInQueue(): void; exit(target?: string): void; flag(): void; dismissError(): void; @@ -127,6 +127,11 @@ export function useQuizSession(userId: string, entry: EntryRequest): QuizSession // render time is one event behind by the time an await resolves. const sessionRef = useRef(session); const submittingRef = useRef(null); + // The item whose `/answer` is in flight, as `attemptId:index`. Without it a + // double-click fires the request twice: the phase stays `active` for the whole + // round trip, so neither `canSubmitAnswer` nor a `phase !== "active"` disabled + // check rules the second press out. + const answeringRef = useRef(null); const generationRef = useRef(0); const configAppliedRef = useRef(false); const autoResumedRef = useRef(false); @@ -146,11 +151,18 @@ export function useQuizSession(userId: string, entry: EntryRequest): QuizSession // Once `/config` lands, adopt its defaults — but only while nothing is in // flight and only if the student hasn't already chosen. The first paint uses // the pre-config scalar so the "5 questions, medium" line isn't blank. + // + // `SET_CONFIG` is refused mid-quiz, so the flag is set only once the machine + // ACCEPTED it and the effect re-runs on the phase: a `/config` that resolves + // during a fast start or a `?attempt=` auto-resume would otherwise mark itself + // applied while being dropped, and the defaults would never land. useEffect(() => { if (!config || configAppliedRef.current) return; - configAppliedRef.current = true; - apply({ type: "SET_CONFIG", config: defaultConfigFor(config, loadPrefs(config)) }); - }, [config, apply]); + const desired = defaultConfigFor(config, loadPrefs(config)); + if (apply({ type: "SET_CONFIG", config: desired }).config === desired) { + configAppliedRef.current = true; + } + }, [config, apply, session.phase]); // Persist on unmount and on a tab close, so "answered then navigated away" is // resumable even though no transition fired on the way out. @@ -244,7 +256,7 @@ export function useQuizSession(userId: string, entry: EntryRequest): QuizSession type: "FAILED", error: { code: "QUIZ_ATTEMPT_NOT_RESUMABLE", - message: "This quiz can't be resumed. Start a new one.", + message: QUIZ_ERROR_COPY.QUIZ_ATTEMPT_NOT_RESUMABLE, retryable: false, } satisfies QuizError, }); @@ -271,6 +283,9 @@ export function useQuizSession(userId: string, entry: EntryRequest): QuizSession if (!canSubmitAnswer(current) || !current.attemptId) return; const item = current.items[current.cursor]; const attemptId = current.attemptId; + const inFlight = `${attemptId}:${item.index}`; + if (answeringRef.current === inFlight) return; + answeringRef.current = inFlight; setPending(true); try { const result = await withNetworkRetry(() => @@ -285,6 +300,7 @@ export function useQuizSession(userId: string, entry: EntryRequest): QuizSession } catch (err) { apply({ type: "ANSWER_FAILED", error: describeQuizError(err) }); } finally { + answeringRef.current = null; setPending(false); } }, [apply, runSubmit]); @@ -374,8 +390,8 @@ export function useQuizSession(userId: string, entry: EntryRequest): QuizSession if (next.phase === "generating") void runGenerate(next); }, - nextInQueue: courseId => { - const next = apply({ type: "NEXT_IN_QUEUE", courseId }); + nextInQueue: () => { + const next = apply({ type: "NEXT_IN_QUEUE" }); if (next.phase === "generating") void runGenerate(next); }, From f4fa5612a4870542182392730ca8fcea05abcaf2 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:44:21 -0400 Subject: [PATCH 23/60] =?UTF-8?q?docs(quiz):=20contract=20=E2=80=94=20Wave?= =?UTF-8?q?=203=20seam=20(AskPanel=20props,=20testids)=20(#537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../specs/2026-08-22-quiz-frontend-contract.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md b/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md index e202d8ee..af7ec161 100644 --- a/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md +++ b/docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md @@ -374,6 +374,20 @@ QuizResults({ session, actions, concept, neighbourhood: { siblings }, prefersRed - `lib/quiz/proposals.ts` also exports `entrySelection(entry, nodes, courses)` which resolves `?concept=`/`?topic=` against the scoped graph (reusing `quizSelection.resolveInitialSelection`) and returns `{ conceptId | null, unresolved: boolean }` — B1 shows the §6 toast when `unresolved`. - The `sapling:graph-changed` CustomEvent (§5 B3) is dispatched by `useQuizSession` on SUBMITTED (`detail: { conceptId, masteryBefore, masteryAfter }`), not by the results screen. +### Wave 3 seam additions (binding) +- `components/quiz/question/AskPanel.tsx` (B2 builds it; B3 imports it from `../question/AskPanel`): +```ts +export interface AskSeed { stem: string; chosenLabel: string; chosenText: string; correctLabel: string; correctText: string; explanation: string } +export interface AskPanelProps { open: boolean; onClose: () => void; userId: string; conceptName: string; courseId: string | null; + courseLabel?: string; seed: AskSeed; returnFocusTo?: RefObject; testid?: string } // default testid "quiz-ask-panel" +``` + B3 may mock it in unit tests until B2 lands; the prop shape above is fixed. +- `EmptyState` size values are `"md" | "hero"` (A1 built `md`, not `default`). +- `QuizScreen`'s error card carries `quiz-error`, `quiz-error-retry`, `quiz-error-back` (A2). +- Wave 3 screens import primitives from `@/components/ui` and node marks from `@/components/graph/ConceptNode` / + `ConceptNeighbourhood`; hooks/types from `@/lib/quiz/*`. Screens own ONLY their directory; new primitives or CSS outside it go + to the lead. + --- ## 5. Screen specs (B1–B3) — the prototype is the visual authority; this is the behavioural one From 4c9fa61ab227a4bc016ced43b8b01e8739ead6e3 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:53:57 -0400 Subject: [PATCH 24/60] feat(notetaker): wire Generate quiz through buildQuizHref + ?note= reopen (#537 C3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Generate quiz" action pushed a bare /quiz?concept=, so the quiz screen's Done/Cancel/Leave always fell through to the hardcoded /learn exit (R5 §C) with no way back to the note it came from. It now builds its href via lib/quiz/source.ts::buildQuizHref (kind: "notes", returnTo: /notetaker ?note=), and the page understands ?note= on mount to reopen that note — the arrival half of the same round trip. Disabled-until-linked and busy gating on the button are unchanged. Verified the four deep-link contract cases from lib/quiz/source.test.ts and lib/quiz/proposals.test.ts are already covered (no gaps to report to the lead). Co-Authored-By: Claude Fable 5 --- .../notetaker/page.generateQuiz.test.tsx | 194 ++++++++++++++++++ .../(shell)/notetaker/page.testmode.test.tsx | 1 + frontend/src/app/(shell)/notetaker/page.tsx | 49 ++++- 3 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx diff --git a/frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx b/frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx new file mode 100644 index 00000000..a13cf818 --- /dev/null +++ b/frontend/src/app/(shell)/notetaker/page.generateQuiz.test.tsx @@ -0,0 +1,194 @@ +// @vitest-environment jsdom +/** + * "Generate quiz" deep link + `?note=` reopen (#537 §6, C3). + * + * Before this change the button pushed a bare `/quiz?concept=` — the quiz + * screen's Done/Cancel/Leave always landed on the hardcoded `/learn` (R5 §C), + * because nothing on the notetaker side carried a source or a way back to a + * specific note. Now the button builds its href through + * `lib/quiz/source.ts::buildQuizHref` (`quizHrefForNote`, exported at the + * bottom of `page.tsx`), and the page itself understands `?note=` as the + * arrival half of that round trip. + * + * Renders the real page (mirrors `page.testmode.test.tsx`'s mocking strategy) + * rather than only unit-testing `quizHrefForNote` in isolation, since the + * disabled-until-linked gating and the `?note=` mount behaviour both live in + * the component itself. + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import type { EnrolledCourse } from "@/lib/api"; +import type { Note as ApiNote, LinkedConcept as ApiLinkedConcept } from "@/lib/types"; + +const push = vi.fn(); +let searchParamsValue = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace: vi.fn() }), + useSearchParams: () => searchParamsValue, +})); + +vi.mock("@/context/UserContext", () => ({ + useUser: () => ({ userId: "u1", userReady: true }), +})); + +vi.mock("@/components/ToastProvider", () => ({ + useToast: () => ({ success: vi.fn(), error: vi.fn(), warn: vi.fn() }), +})); + +vi.mock("@/components/Icon", () => ({ + Icon: ({ name }: { name: string }) => , +})); + +vi.mock("@/lib/api", () => ({ + listNotes: vi.fn(), + createNote: vi.fn(), + patchNote: vi.fn(), + deleteNote: vi.fn(), + listNoteConcepts: vi.fn(), + linkNoteConcept: vi.fn(), + unlinkNoteConcept: vi.fn(), + summarizeNote: vi.fn(), + extractNoteConcepts: vi.fn(), + generateQuizFromNote: vi.fn(), + sendNoteToTutor: vi.fn(), + noteChat: vi.fn(), + getCourses: vi.fn(), + getGraph: vi.fn(), +})); + +import NotetakerPage, { quizHrefForNote } from "./page"; +import { + listNotes, + listNoteConcepts, + getCourses, + generateQuizFromNote, +} from "@/lib/api"; + +const mockedListNotes = vi.mocked(listNotes); +const mockedListConcepts = vi.mocked(listNoteConcepts); +const mockedGetCourses = vi.mocked(getCourses); +const mockedGenerateQuiz = vi.mocked(generateQuizFromNote); + +const COURSE: EnrolledCourse = { + enrollment_id: "e-cs", + course_id: "c1", + course_code: "CS-201", + course_name: "Data Structures", + school: "BU", + department: "CS", + color: null, + nickname: null, + node_count: 0, + enrolled_at: "2026-01-01", + term: "Spring 2026", +}; + +// Active by default: `notes[0]` (per apiNoteToNote + the initial-load effect). +const NOTE_LINKED: ApiNote = { + id: "n1", + user_id: "u1", + course_id: "c1", + title: "Recursion", + body: "Base case + recursive case.", + tags: [], + last_summary: null, + last_summary_at: null, + created_at: "2026-08-01T00:00:00Z", + updated_at: "2026-08-01T00:00:00Z", +}; + +const NOTE_UNLINKED: ApiNote = { + ...NOTE_LINKED, + id: "n2", + title: "Loops", +}; + +const LINKED_CONCEPT: ApiLinkedConcept = { + id: "concept-1", + concept_name: "Recursion", + mastery_tier: "learning", + mastery_score: 0.4, + course_id: "c1", +}; + +beforeEach(() => { + Element.prototype.scrollTo = (() => {}) as typeof Element.prototype.scrollTo; + searchParamsValue = new URLSearchParams(); + mockedGetCourses.mockResolvedValue({ courses: [COURSE] }); + mockedListNotes.mockResolvedValue({ notes: [NOTE_LINKED, NOTE_UNLINKED] }); + mockedListConcepts.mockImplementation(async (noteId: string) => ({ + concepts: noteId === "n1" ? [LINKED_CONCEPT] : [], + })); + mockedGenerateQuiz.mockResolvedValue({ concept_node_id: "concept-1", concept_name: "Recursion" }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("quizHrefForNote (pure)", () => { + it("carries the concept, the notes source and a return path that reopens the note", () => { + const href = quizHrefForNote("n1", "concept-1"); + expect(href).toBe("/quiz?concept=concept-1&from=notes&return=%2Fnotetaker%3Fnote%3Dn1¬e=n1"); + }); +}); + +describe("Generate quiz button", () => { + it("stays disabled until the active note has a linked concept", async () => { + render(); + await screen.findByPlaceholderText("Untitled note"); + + // n1 ("Recursion") is active by default and has LINKED_CONCEPT. + const button = await screen.findByRole("button", { name: /generate quiz/i }); + await waitFor(() => expect(button).not.toBeDisabled()); + + // Switch to n2 ("Loops"), which has no linked concepts. + fireEvent.click(screen.getByText("Loops")); + await waitFor(() => expect(button).toBeDisabled()); + }); + + it("pushes a deep link with concept=, from=notes, note= and an encoded return= reopening the note", async () => { + render(); + await screen.findByPlaceholderText("Untitled note"); + const button = await screen.findByRole("button", { name: /generate quiz/i }); + await waitFor(() => expect(button).not.toBeDisabled()); + + fireEvent.click(button); + + await waitFor(() => expect(push).toHaveBeenCalledTimes(1)); + const href = push.mock.calls[0][0] as string; + expect(href.startsWith("/quiz?")).toBe(true); + + const params = new URLSearchParams(href.slice(href.indexOf("?") + 1)); + expect(params.get("concept")).toBe("concept-1"); + expect(params.get("from")).toBe("notes"); + expect(params.get("note")).toBe("n1"); + expect(params.get("return")).toBe("/notetaker?note=n1"); + }); +}); + +describe("?note= reopens a specific note on mount", () => { + it("makes the named note active when it exists in the loaded list", async () => { + searchParamsValue = new URLSearchParams("note=n2"); + render(); + + await waitFor(async () => { + const title = await screen.findByPlaceholderText("Untitled note"); + expect((title as HTMLInputElement).value).toBe("Loops"); + }); + }); + + it("ignores an unknown note id and keeps the default active note", async () => { + searchParamsValue = new URLSearchParams("note=does-not-exist"); + render(); + + await waitFor(async () => { + const title = await screen.findByPlaceholderText("Untitled note"); + expect((title as HTMLInputElement).value).toBe("Recursion"); + }); + }); +}); diff --git a/frontend/src/app/(shell)/notetaker/page.testmode.test.tsx b/frontend/src/app/(shell)/notetaker/page.testmode.test.tsx index aa73f73d..091af9c6 100644 --- a/frontend/src/app/(shell)/notetaker/page.testmode.test.tsx +++ b/frontend/src/app/(shell)/notetaker/page.testmode.test.tsx @@ -28,6 +28,7 @@ vi.stubEnv("NEXT_PUBLIC_TEST_MODE", "1"); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), })); vi.mock("@/context/UserContext", () => ({ diff --git a/frontend/src/app/(shell)/notetaker/page.tsx b/frontend/src/app/(shell)/notetaker/page.tsx index 54713a02..d97f983f 100644 --- a/frontend/src/app/(shell)/notetaker/page.tsx +++ b/frontend/src/app/(shell)/notetaker/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; -import { useRouter } from "next/navigation"; +import React, { Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { Icon } from "@/components/Icon"; import { useUser } from "@/context/UserContext"; import { @@ -28,6 +28,7 @@ import type { import { now } from "@/lib/testMode"; import { useToast } from "@/components/ToastProvider"; import { humanizeError } from "@/lib/errorMessage"; +import { buildQuizHref } from "@/lib/quiz/source"; type Mastery = "mastered" | "learning" | "struggling" | "unexplored"; @@ -112,9 +113,23 @@ function apiConceptToConcept(c: ApiLinkedConcept, courseCode: string): Concept { }; } +/** + * `NotetakerScreen` reads `?note=` off `useSearchParams`, which App Router + * requires to sit under a Suspense boundary (the same convention `/quiz` and + * `/dashboard` use — see their `page.tsx`). + */ export default function NotetakerPage() { + return ( + + + + ); +} + +function NotetakerScreen() { const { userId, userReady } = useUser(); const router = useRouter(); + const searchParams = useSearchParams(); const toast = useToast(); const [courses, setCourses] = React.useState([]); @@ -167,6 +182,20 @@ export default function NotetakerPage() { }; }, [userReady, userId]); + // `?note=` reopens a specific note by URL — the arrival half of the + // "Generate quiz" round trip (returnTo: `/notetaker?note=`, §6). Applied + // once per distinct `note` value, and only once notes have loaded: an id + // that matches a loaded note becomes active; an id that matches nothing is + // silently ignored (never traps the page on a blank state). + const appliedNoteParamRef = React.useRef(null); + React.useEffect(() => { + const noteParam = searchParams.get("note"); + if (!noteParam || notes.length === 0) return; + if (appliedNoteParamRef.current === noteParam) return; + appliedNoteParamRef.current = noteParam; + if (notes.some((n) => n.id === noteParam)) setActiveId(noteParam); + }, [searchParams, notes]); + // Load linked concepts when the active note changes. const activeIdForConcepts = active?.id ?? null; React.useEffect(() => { @@ -403,7 +432,7 @@ export default function NotetakerPage() { setBusy("quiz"); try { const { concept_node_id } = await generateQuizFromNote(active.id, userId); - router.push(`/quiz?concept=${encodeURIComponent(concept_node_id)}`); + router.push(quizHrefForNote(active.id, concept_node_id)); } catch (e) { console.error("Quiz failed", e); toast.error(humanizeError(e, "Couldn't generate a quiz from this note.")); @@ -1913,3 +1942,17 @@ function AIChatPanel({ noteId, userId }: { noteId: string; userId: string }) { ); } + +/** + * The "Generate quiz" deep link (#537 §6): a concept deep link sourced from + * this note, with a `returnTo` that reopens THIS note via the `?note=` + * support above. Exported as a pure helper — rather than extracting the + * ~1900-line page's render tree — so the href-building logic is directly + * unit-testable without mounting the whole component. + */ +export function quizHrefForNote(noteId: string, conceptNodeId: string): string { + return buildQuizHref( + { concept: conceptNodeId }, + { kind: "notes", returnTo: `/notetaker?note=${encodeURIComponent(noteId)}`, noteId }, + ); +} From 2ea3b351a4afded959d7aeefc5936746ef8fe00c Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:55:00 -0400 Subject: [PATCH 25/60] fix(quiz): wire the dashboard's quiz entry points into the #537 URL API - "Try this next" suggest card resolves the suggested concept NAME to its node id and opens the quiz on that concept via buildQuizHref, carrying {kind:"dashboard", returnTo:"/dashboard"} instead of the old bare ?topic= link. - The Learn-next panel's "Quick quiz" button becomes "Review what's due", scoping the quiz to scope=due and showing the due count in the label; falls back to a plain "Quiz" button/home href when nothing is due. - The default sidenav layout has no Learn-next panel of its own, so the same due CTA (testid dashboard-review-due) is added to its quick-action row. SideNav/TopNav are unchanged: usePathname() never carries the query string, so /quiz?scope=due still active-matches /quiz correctly. Co-Authored-By: Claude Fable 5 --- .../screens/Dashboard.quiz.test.tsx | 172 ++++++++++++++++++ frontend/src/components/screens/Dashboard.tsx | 49 ++++- 2 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/screens/Dashboard.quiz.test.tsx diff --git a/frontend/src/components/screens/Dashboard.quiz.test.tsx b/frontend/src/components/screens/Dashboard.quiz.test.tsx new file mode 100644 index 00000000..4e805dc6 --- /dev/null +++ b/frontend/src/components/screens/Dashboard.quiz.test.tsx @@ -0,0 +1,172 @@ +// @vitest-environment jsdom +/** + * Quiz entry-point wiring on the dashboard (#537 C2, contract §6): + * - The "Try this next" suggest card resolves `?suggest=` to its node + * id (same case-insensitive match `suggestNode` already does) and opens + * the quiz on that concept, carrying `{kind:"dashboard", returnTo:"/dashboard"}`. + * - The Learn-next panel's quiz CTA (legacy topnav layout) becomes "Review + * what's due", showing the due count and scoping the quiz to `scope=due`; + * with nothing due it falls back to a plain "Quiz" button/home href. + * - The default (sidenav) layout has no "Learn next" panel of its own, so + * the same due CTA is also surfaced there (`dashboard-review-due`). + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import type { EnrolledCourse } from "@/lib/api"; + +const push = vi.fn(); +let searchParams = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push, replace: vi.fn() }), + useSearchParams: () => searchParams, +})); + +vi.mock("@/context/UserContext", () => ({ + useUser: () => ({ userId: "u1", userReady: true, userName: "Ada" }), +})); + +vi.mock("@/lib/useIsMobile", () => ({ useIsMobile: () => false })); + +// "topnav" renders the legacy Learn-next panel with the quiz CTA; the +// third describe block flips this to "sidebar" to exercise the layout with +// no Learn-next panel at all. +const layoutState = vi.hoisted(() => ({ pref: "topnav" })); +vi.mock("@/lib/useLayoutPref", () => ({ useLayoutPref: () => [layoutState.pref, vi.fn()] })); + +vi.mock("../graph/KnowledgeGraph", () => ({ KnowledgeGraph: () => null })); +vi.mock("../ManageCoursesModal", () => ({ ManageCoursesModal: () => null })); +vi.mock("../Skeleton", () => ({ DashboardSkeleton: () => null })); +vi.mock("../MiniStat", () => ({ MiniStat: () => null })); +vi.mock("../Icon", () => ({ Icon: () => null })); + +vi.mock("@/lib/api", () => ({ + getGraph: vi.fn(), + getCourses: vi.fn(), + getUpcomingAssignments: vi.fn(), + getSessions: vi.fn(), + getRecommendations: vi.fn(), +})); + +import { Dashboard } from "./Dashboard"; +import { + getCourses, + getGraph, + getRecommendations, + getSessions, + getUpcomingAssignments, +} from "@/lib/api"; + +function course(code: string): EnrolledCourse { + return { + enrollment_id: `e-${code}`, + course_id: `c-${code}`, + course_code: code, + course_name: code, + school: "BU", + department: "CS", + color: null, + nickname: null, + node_count: 0, + enrolled_at: "2025-08-25", + term: "Spring 2026", + }; +} + +// Raw (pre-apiToGraphNode) node shape, matching `getGraph`'s wire response. +function apiNode(overrides: Record = {}) { + return { + id: "node-recursion", + concept_name: "Recursion", + mastery_score: 0.3, + mastery_tier: "struggling", + times_studied: 2, + last_studied_at: "2026-08-01T00:00:00Z", + subject: "CS-101", + course_id: "c-CS-101", + is_subject_root: false, + ...overrides, + }; +} + +beforeEach(() => { + layoutState.pref = "topnav"; + searchParams = new URLSearchParams(); + window.localStorage.clear(); + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + + vi.mocked(getUpcomingAssignments).mockResolvedValue({ assignments: [] }); + vi.mocked(getSessions).mockResolvedValue({ sessions: [] }); + vi.mocked(getRecommendations).mockResolvedValue({ recommendations: [] }); + vi.mocked(getCourses).mockResolvedValue({ courses: [course("CS-101")] }); + vi.mocked(getGraph).mockResolvedValue({ nodes: [apiNode()], edges: [], stats: {} }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("Dashboard — suggest card entry (?suggest=)", () => { + it("resolves the concept name to its node id and opens the quiz on that concept", async () => { + searchParams = new URLSearchParams("suggest=recursion"); // case-insensitive match + + render(); + + const startQuiz = await screen.findByText("Start quiz"); + fireEvent.click(startQuiz); + + expect(push).toHaveBeenCalledWith( + "/quiz?concept=node-recursion&from=dashboard&return=%2Fdashboard", + ); + }); +}); + +describe("Dashboard — review-what's-due CTA (legacy topnav layout)", () => { + it("shows the due count and scopes the quiz to scope=due", async () => { + render(); + + const cta = await screen.findByTestId("dashboard-review-due"); + expect(cta.textContent).toContain("Review what's due"); + expect(cta.textContent).toContain("1"); + + fireEvent.click(cta); + expect(push).toHaveBeenCalledWith("/quiz?scope=due&from=dashboard&return=%2Fdashboard"); + }); + + it("falls back to a plain 'Quiz' button/home href when nothing is due", async () => { + vi.mocked(getGraph).mockResolvedValue({ + nodes: [apiNode({ mastery_tier: "mastered" })], + edges: [], + stats: {}, + }); + + render(); + + const cta = await screen.findByTestId("dashboard-review-due"); + expect(cta.textContent?.trim()).toBe("Quiz"); + + fireEvent.click(cta); + expect(push).toHaveBeenCalledWith("/quiz?from=dashboard&return=%2Fdashboard"); + }); +}); + +describe("Dashboard — review-what's-due CTA (default sidenav layout)", () => { + it("is also surfaced with no Learn-next panel present", async () => { + layoutState.pref = "sidebar"; + + render(); + + const cta = await screen.findByTestId("dashboard-review-due"); + expect(cta.textContent).toContain("Review what's due"); + + fireEvent.click(cta); + expect(push).toHaveBeenCalledWith("/quiz?scope=due&from=dashboard&return=%2Fdashboard"); + }); +}); diff --git a/frontend/src/components/screens/Dashboard.tsx b/frontend/src/components/screens/Dashboard.tsx index eda4d920..6c17cc36 100644 --- a/frontend/src/components/screens/Dashboard.tsx +++ b/frontend/src/components/screens/Dashboard.tsx @@ -25,6 +25,8 @@ import { import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; import { apiToGraphNode, learnHrefForNode, type GraphNode, type GraphEdge } from "@/lib/data"; import { IS_TEST_MODE, now } from "@/lib/testMode"; +import { buildQuizHref } from "@/lib/quiz/source"; +import { dueSet } from "@/lib/quiz/proposals"; const QUOTES = [ "Learning is the only thing the mind never exhausts, never fears, and never regrets. — da Vinci", @@ -210,6 +212,12 @@ export function Dashboard() { const [sessions, setSessions] = React.useState([]); const [assignments, setAssignments] = React.useState([]); const [recommendations, setRecommendations] = React.useState<{ concept_name: string; reason?: string }[]>([]); + // Mirrors the same tier-membership filter `/quiz`'s useQuizHome runs (R-7) — + // computed over the RAW (pre-apiToGraphNode) nodes because `dueSet` is typed + // against the wire `GraphNode` (concept_name/times_studied included), while + // this screen's own `nodes` state has already been through `apiToGraphNode` + // and dropped those fields. + const [due, setDue] = React.useState>({ conceptIds: [], count: 0, courseCount: 0 }); const [loading, setLoading] = React.useState(true); const [loadError, setLoadError] = React.useState(null); const [activeDays, setActiveDays] = React.useState>(new Set()); @@ -277,9 +285,11 @@ export function Dashboard() { ]); const cs = coursesRes.courses || []; setCourses(cs); - const gNodes: GraphNode[] = (graphRes.nodes || []).map((n: ApiNode) => apiToGraphNode(n, cs)); + const rawNodes: ApiNode[] = graphRes.nodes || []; + const gNodes: GraphNode[] = rawNodes.map((n) => apiToGraphNode(n, cs)); setNodes(gNodes); setEdges((graphRes.edges || []).map(apiToGraphEdge)); + setDue(dueSet(rawNodes)); setStats({ streak: graphRes.stats?.streak ?? 0, mastered: graphRes.stats?.mastered ?? 0, @@ -492,10 +502,28 @@ export function Dashboard() { return c?.course_code || c?.course_name || null; }; + // The Learn-next panel's quiz CTA, and its sidenav-layout counterpart: + // "review everything due" when there's a due set, else a plain quiz home. + const reviewDueHref = due.count > 0 + ? buildQuizHref({ scope: "due" }, { kind: "dashboard", returnTo: "/dashboard" }) + : buildQuizHref({}, { kind: "dashboard", returnTo: "/dashboard" }); + const reviewDueLabel = due.count > 0 ? `Review what's due · ${due.count}` : "Quiz"; + const rightPanel = (
{!isMobile && (
+ {/* Sidenav layout has no "Learn next" panel of its own (that CTA + only lives in the legacy topnav's right column below), so the + same review-what's-due entry point is surfaced here instead. */} +
- @@ -894,8 +930,13 @@ export function Dashboard() { ))}
- + ))} +
+ ), +})); + +vi.mock("../Skeleton", () => ({ GraphPanelSkeleton: () => null })); +vi.mock("../Icon", () => ({ Icon: () => null })); + +vi.mock("@/lib/api", () => ({ + getGraph: vi.fn(), + getCourses: vi.fn(), + getSessions: vi.fn(), + addGraphNode: vi.fn(), + deleteGraphNode: vi.fn(), +})); + +vi.mock("@/lib/quiz/api", () => ({ listAttempts: vi.fn() })); + +import { Tree } from "./Tree"; +import { ToastProvider } from "../ToastProvider"; +import { getCourses, getGraph, getSessions } from "@/lib/api"; +import { listAttempts } from "@/lib/quiz/api"; + +const mockedGraph = vi.mocked(getGraph); +const mockedCourses = vi.mocked(getCourses); +const mockedSessions = vi.mocked(getSessions); +const mockedAttempts = vi.mocked(listAttempts); + +const COURSE_ID = "course-cs101"; +const ROOT_ID = `subject_root__${COURSE_ID}`; +const RECURSION = "node-recursion"; +const POINTERS = "node-pointers"; + +/** The wire shape `getGraph` returns, not the mapped `GraphNode`. */ +function apiNode(over: Partial> & { id: string; concept_name: string }) { + return { + subject: "Intro to CS", + mastery_tier: "learning", + mastery_score: 0.4, + course_id: COURSE_ID, + course_color: "#123456", + times_studied: 2, + last_studied_at: null, + is_subject_root: false, + ...over, + }; +} + +function attempt(over: Partial & { quiz_id: string }): AttemptSummary { + return { + status: "completed", + concept_node_id: RECURSION, + concept_name: "Recursion", + course_id: COURSE_ID, + score: 3, + total: 3, + difficulty: "medium", + mastery_before: 0.4, + mastery_after: 0.44, + mastery_delta: 0.04, + created_at: "2026-08-20T10:00:00Z", + completed_at: "2026-08-20T10:05:00Z", + ...over, + }; +} + +function renderTree() { + return render( + + + , + ); +} + +/** Opens a node's detail panel through the graph, the way a student does. */ +async function selectNode(user: ReturnType, id: string) { + await user.click(await screen.findByTestId(`stub-node-${id}`)); +} + +beforeEach(() => { + vi.clearAllMocks(); + params.value = new URLSearchParams(); + window.localStorage.clear(); + // jsdom has no ResizeObserver; the graph slot measures itself with one. + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + mockedCourses.mockResolvedValue({ + courses: [ + { + enrollment_id: "e1", + course_id: COURSE_ID, + course_code: "CS101", + course_name: "Intro to CS", + color: "#123456", + }, + ], + } as Awaited>); + mockedGraph.mockResolvedValue({ + nodes: [ + apiNode({ id: RECURSION, concept_name: "Recursion" }), + apiNode({ id: POINTERS, concept_name: "Pointers" }), + apiNode({ + id: ROOT_ID, + concept_name: "CS101 - Intro to CS", + mastery_tier: "subject_root", + is_subject_root: true, + }), + ], + edges: [], + } as unknown as Awaited>); + mockedSessions.mockResolvedValue({ sessions: [] } as Awaited>); + mockedAttempts.mockResolvedValue({ total: 0, limit: 100, offset: 0, attempts: [] }); +}); + +afterEach(cleanup); + +describe("Quick quiz links (§6)", () => { + it("sends a concept by id, tagged from=tree, returning to its own node", async () => { + const user = userEvent.setup(); + renderTree(); + await selectNode(user, RECURSION); + + await user.click(screen.getByRole("button", { name: /quick quiz/i })); + + expect(push).toHaveBeenCalledWith( + `/quiz?concept=${RECURSION}&from=tree&return=${encodeURIComponent(`/tree?node=${RECURSION}`)}`, + ); + }); + + it("sends a subject root as its abstract course, returning to the tree", async () => { + const user = userEvent.setup(); + renderTree(); + await selectNode(user, ROOT_ID); + + await user.click(screen.getByRole("button", { name: /quick quiz/i })); + + expect(push).toHaveBeenCalledWith( + `/quiz?course=${COURSE_ID}&from=tree&return=${encodeURIComponent("/tree")}`, + ); + }); +}); + +describe("?node= focus", () => { + it("opens the named node's panel", async () => { + params.value = new URLSearchParams(`node=${POINTERS}`); + renderTree(); + + // The panel's own heading — proof the node is selected, not merely painted. + expect(await screen.findByRole("heading", { name: "Pointers" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Recursion" })).not.toBeInTheDocument(); + }); + + it("ignores an id that is not in the graph", async () => { + params.value = new URLSearchParams("node=node-deleted-last-week"); + renderTree(); + + await screen.findByTestId("stub-graph"); + await waitFor(() => expect(mockedGraph).toHaveBeenCalled()); + expect(screen.queryByTestId("tree-node-recent-quizzes")).not.toBeInTheDocument(); + }); +}); + +describe("Recent quizzes", () => { + it("shows the five newest completed attempts for the selected node only", async () => { + mockedAttempts.mockResolvedValue({ + total: 8, + limit: 100, + offset: 0, + attempts: [ + // Deliberately out of order on the wire, and the fifth-newest first: + // the ordering under test is this component's, not the endpoint's. + attempt({ quiz_id: "q-5th", completed_at: "2026-08-05T10:00:00Z" }), + attempt({ quiz_id: "q-new", completed_at: "2026-08-21T10:00:00Z" }), + attempt({ quiz_id: "q-2", completed_at: "2026-08-19T10:00:00Z" }), + attempt({ quiz_id: "q-3", completed_at: "2026-08-18T10:00:00Z" }), + attempt({ quiz_id: "q-4", completed_at: "2026-08-17T10:00:00Z" }), + // Sixth-newest — over the cap, so it never renders. + attempt({ quiz_id: "q-6th", completed_at: "2026-08-01T10:00:00Z" }), + // Filtered out: another concept, and an unfinished attempt. + attempt({ quiz_id: "q-other-node", concept_node_id: POINTERS }), + attempt({ quiz_id: "q-in-progress", status: "in_progress", completed_at: null }), + ], + }); + const user = userEvent.setup(); + renderTree(); + await selectNode(user, RECURSION); + + const block = await screen.findByTestId("tree-node-recent-quizzes"); + const rows = await within(block).findAllByTestId(/^tree-node-recent-quiz-/); + + expect(rows.map((r) => r.getAttribute("data-testid"))).toEqual([ + "tree-node-recent-quiz-q-new", + "tree-node-recent-quiz-q-2", + "tree-node-recent-quiz-q-3", + "tree-node-recent-quiz-q-4", + "tree-node-recent-quiz-q-5th", + ]); + expect(within(block).queryByTestId("tree-node-recent-quiz-q-6th")).not.toBeInTheDocument(); + expect(within(block).queryByTestId("tree-node-recent-quiz-q-other-node")).not.toBeInTheDocument(); + expect(within(block).queryByTestId("tree-node-recent-quiz-q-in-progress")).not.toBeInTheDocument(); + }); + + it("renders score and a signed whole-percent mastery delta", async () => { + mockedAttempts.mockResolvedValue({ + total: 3, + limit: 100, + offset: 0, + attempts: [ + attempt({ quiz_id: "q-up", score: 3, total: 3, mastery_delta: 0.04, completed_at: "2026-08-21T10:00:00Z" }), + attempt({ quiz_id: "q-down", score: 1, total: 4, mastery_delta: -0.02, completed_at: "2026-08-20T10:00:00Z" }), + attempt({ quiz_id: "q-null", score: 2, total: 3, mastery_delta: null, completed_at: "2026-08-19T10:00:00Z" }), + ], + }); + const user = userEvent.setup(); + renderTree(); + await selectNode(user, RECURSION); + + expect(await screen.findByTestId("tree-node-recent-quiz-q-up")).toHaveTextContent( + "3/3 · +4% mastery", + ); + expect(screen.getByTestId("tree-node-recent-quiz-q-down")).toHaveTextContent( + "1/4 · −2% mastery", + ); + expect(screen.getByTestId("tree-node-recent-quiz-q-null")).toHaveTextContent( + "2/3 · — mastery", + ); + }); + + it("says so when the concept has no attempts", async () => { + const user = userEvent.setup(); + renderTree(); + await selectNode(user, RECURSION); + + const block = await screen.findByTestId("tree-node-recent-quizzes"); + await waitFor(() => expect(block).toHaveTextContent("No quizzes yet")); + // The Quick quiz button above IS the call to action; the empty state must + // not grow a second one. + expect(within(block).queryByRole("button")).not.toBeInTheDocument(); + }); + + it("never offers history on a subject root", async () => { + const user = userEvent.setup(); + renderTree(); + await selectNode(user, ROOT_ID); + + await screen.findByRole("heading", { name: "CS101 - Intro to CS" }); + expect(screen.queryByTestId("tree-node-recent-quizzes")).not.toBeInTheDocument(); + expect(mockedAttempts).not.toHaveBeenCalled(); + }); + + it("refetches when a quiz submit announces a graph change", async () => { + const user = userEvent.setup(); + renderTree(); + await selectNode(user, RECURSION); + await waitFor(() => expect(mockedAttempts).toHaveBeenCalledTimes(1)); + + mockedAttempts.mockResolvedValue({ + total: 1, + limit: 100, + offset: 0, + attempts: [attempt({ quiz_id: "q-just-finished" })], + }); + window.dispatchEvent(new CustomEvent("sapling:graph-changed", { detail: {} })); + + expect(await screen.findByTestId("tree-node-recent-quiz-q-just-finished")).toBeInTheDocument(); + expect(mockedAttempts).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/components/screens/Tree.tsx b/frontend/src/components/screens/Tree.tsx index f972f32b..a9d7ca77 100644 --- a/frontend/src/components/screens/Tree.tsx +++ b/frontend/src/components/screens/Tree.tsx @@ -17,9 +17,37 @@ import { useToast } from "../ToastProvider"; import { useConfirm } from "@/lib/useConfirm"; import type { GraphNode as ApiNode, GraphEdge as ApiEdge } from "@/lib/types"; import { apiToGraphNode, learnHrefForNode, type GraphNode, type GraphEdge } from "@/lib/data"; +import { buildQuizHref } from "@/lib/quiz/source"; +import { listAttempts } from "@/lib/quiz/api"; +import { relativeStudied } from "@/lib/quiz/relativeTime"; +import type { AttemptSummary } from "@/lib/quiz/types"; type Tier = "all" | "mastered" | "learning" | "struggling" | "unexplored"; +/** One page of history is enough to find a single concept's attempts: the + * endpoint has no concept filter (gap G2), so the page is filtered client-side + * and 100 rows covers far more than the five we show. */ +const ATTEMPT_HISTORY_LIMIT = 100; +/** Rows in the node panel's "Recent quizzes" block. */ +const RECENT_QUIZ_ROWS = 5; + +/** `mastery_delta` is a 0–1 fraction (`routes/quiz.py:1780`, + * `mastery_after - mastery_before`); the panel speaks in whole percent, the + * same unit as the Mastery tile above it. `null` is "the attempt recorded no + * move", not zero — say so with an em dash rather than inventing a number. */ +function formatMasteryDelta(delta: number | null | undefined): string { + if (delta === null || delta === undefined || Number.isNaN(delta)) return "—"; + const pct = Math.round(delta * 100); + return `${pct < 0 ? "−" : "+"}${Math.abs(pct)}%`; +} + +/** Completed attempts sort by when they FINISHED; `created_at` is the fallback + * for a row whose `completed_at` never landed. */ +function attemptTime(a: AttemptSummary): number { + const t = new Date(a.completed_at || a.created_at).getTime(); + return Number.isNaN(t) ? 0 : t; +} + const TIER_META: Record, { label: string; color: string }> = { mastered: { label: "Mastered", color: "var(--state-mastery)" }, learning: { label: "Learning", color: "var(--state-progress)" }, @@ -34,6 +62,10 @@ export function Tree() { const [activeSemester, , semesterHydrated] = useActiveSemester(); const isMobile = useIsMobile(); const suggest = search.get("suggest"); + // `?node=` — the quiz's return path (#537 R-10). `?suggest=` is the older, + // fuzzier twin (a concept NAME) and is untouched by this; they are written by + // different callers and are not expected together. + const focusNodeId = search.get("node"); const [courseFilter, setCourseFilter] = React.useState("all"); const [tier, setTier] = React.useState("all"); @@ -50,6 +82,12 @@ export function Tree() { const [sessions, setSessions] = React.useState([]); const [loading, setLoading] = React.useState(true); + // Quiz history for the node panel's "Recent quizzes" block. `null` = not + // fetched yet (the block says "Loading…", never the lie "No quizzes yet"); + // the ref records which user the cached page belongs to. + const [attempts, setAttempts] = React.useState(null); + const attemptsFor = React.useRef(null); + // Manual add-concept composer (#330) — only offered when a single course // is selected: courseFilter is a FILTER whose "all" value gives no course // to attribute the new node to. @@ -91,6 +129,43 @@ export function Tree() { } }, [userId, activeSemester]); + const loadAttempts = React.useCallback(async () => { + if (!userId) return; + attemptsFor.current = userId; + try { + const page = await listAttempts(userId, { limit: ATTEMPT_HISTORY_LIMIT }); + setAttempts(page.attempts || []); + } catch (err) { + // History is decoration on a panel that already works; a failed read + // must never blank the panel. Keep whatever we hold, and settle `null` + // to `[]` so the block stops claiming it is still loading. + console.error("tree attempt history load failed", err); + setAttempts((prev) => prev ?? []); + } + }, [userId]); + + // Fetched LAZILY, on the first concept-node selection: a student who only + // pans the graph never pays for a history page. Once per user for the + // screen's lifetime — the panel opens and closes constantly. + React.useEffect(() => { + if (!userId || !selected || selected.is_subject_root) return; + if (attemptsFor.current === userId) return; + setAttempts(null); + loadAttempts(); + }, [userId, selected, loadAttempts]); + + // A quiz submit announces itself (`lib/quiz/useQuizSession.ts`), which is the + // one moment this page's history goes stale while it is on screen. Only + // refresh a page we already hold; the lazy path above still owns the first + // fetch. + React.useEffect(() => { + const onGraphChanged = () => { + if (attemptsFor.current) loadAttempts(); + }; + window.addEventListener("sapling:graph-changed", onGraphChanged); + return () => window.removeEventListener("sapling:graph-changed", onGraphChanged); + }, [loadAttempts]); + // Manual add-concept (#330): create-or-merge server-side (the backend // dedups case/whitespace-insensitively), then reload so the canonical row // and its anchor edge render from DB truth. The selected node anchors the @@ -130,6 +205,26 @@ export function Tree() { if (target) setSelected(target); }, [suggest, nodes]); + // `?node=` focus (#537 §6): select that node exactly as a tap would, so + // the detail panel opens on desktop and the bottom sheet slides up on mobile + // off the same `selected` state. That IS the whole focus gesture — neither + // graph renderer exposes a camera, only `highlightId` (a paint), so there is + // nothing here to centre on. An unknown id is ignored in silence: a student + // returning from a quiz on a concept they have since deleted should land on + // an ordinary tree, not an error. + // Applied once per param VALUE, unlike `?suggest=`: a graph reload (add or + // delete a concept) rebuilds `nodes`, and re-opening a panel the student + // deliberately closed would be a haunting. + const focusApplied = React.useRef(null); + React.useEffect(() => { + if (!focusNodeId || !nodes.length) return; + if (focusApplied.current === focusNodeId) return; + const target = nodes.find(n => n.id === focusNodeId); + if (!target) return; + focusApplied.current = focusNodeId; + setSelected(target); + }, [focusNodeId, nodes]); + useScrollLock(fullscreen); React.useEffect(() => { @@ -169,6 +264,17 @@ export function Tree() { return sessions.filter(s => (s.topic || "").toLowerCase() === name); }, [selected, sessions]); + // `GET /api/quiz/attempts` is user-scoped and unfiltered (gaps G2/G3), so the + // concept and the completed-status filter both happen here. Subject roots + // have no attempts of their own — a course is never a `concept_node_id`. + const recentQuizzes = React.useMemo(() => { + if (!attempts || !selected || selected.is_subject_root) return []; + return attempts + .filter(a => a.concept_node_id === selected.id && a.status === "completed") + .sort((a, b) => attemptTime(b) - attemptTime(a)) + .slice(0, RECENT_QUIZ_ROWS); + }, [attempts, selected]); + const suggestId = React.useMemo(() => { if (!suggest) return undefined; const n = nodes.find(x => x.name.toLowerCase() === suggest.toLowerCase()); @@ -176,13 +282,32 @@ export function Tree() { }, [suggest, nodes]); const onLearn = (n: GraphNode) => router.push(learnHrefForNode(n)); - // Subject-root (course) nodes have no single quiz topic — open the picker - // rather than seeding the course name (mirrors the tutor fix, #319). The - // Quiz screen only reads `topic`/`concept`, so the old `course_id` param was - // dead and is dropped. - const onQuiz = (n: GraphNode) => router.push( - n.is_subject_root ? "/quiz" : `/quiz?topic=${encodeURIComponent(n.name)}`, - ); + + // Quick quiz (#537 §6). Two things changed from the `?topic=` link this + // replaces. First, a concept is addressed by ID: a name is only unique within + // a course, so the fuzzy link could seed the wrong node. Second, the link now + // carries where it came from, which is what lets every quiz exit come BACK + // here — to this node's open panel — instead of the hardcoded `/learn` that + // #537 found on every exit. + // + // Subject-root (course) nodes still have no single topic (the #319 fix), but + // "open the bare picker" is no longer the only honest answer: the quiz home + // takes an abstract `course` and proposes within it. The root carries that id + // directly (`graph_service.py` synthesizes `subject_root__{course_id}` with + // `course_id` set); the id is only unpicked as a belt-and-braces fallback. + const onQuiz = (n: GraphNode) => { + if (n.is_subject_root) { + const courseId = n.course_id || n.id.replace(/^subject_root__/, ""); + router.push(buildQuizHref({ course: courseId }, { kind: "tree", returnTo: "/tree" })); + return; + } + router.push( + buildQuizHref( + { concept: n.id }, + { kind: "tree", returnTo: `/tree?node=${encodeURIComponent(n.id)}`, conceptId: n.id }, + ), + ); + }; const del = useConfirm(async () => { if (!userId || !selected) return; @@ -248,6 +373,42 @@ export function Tree() { {del.armed ? "Click again to confirm" : "Delete concept"} )} + {!selected.is_subject_root && ( +
+
Recent quizzes
+ {attempts === null ? ( +
Loading…
+ ) : recentQuizzes.length === 0 ? ( + // No second CTA here on purpose: "Quick quiz" is a few pixels up + // and is the action this empty state would ask for. +
No quizzes yet
+ ) : ( + recentQuizzes.map(a => ( +
+ {/* Not a link: there is no finished-attempt review endpoint — + `GET /attempts/{id}` answers `resumable: false, questions: []` + for a completed row (gap G5). A row that navigated nowhere + would be a broken promise. */} + + {relativeStudied(a.completed_at || a.created_at)} + + + {a.score ?? "—"}/{a.total ?? "—"} · {formatMasteryDelta(a.mastery_delta)} mastery + +
+ )) + )} +
+ )} {sessionsForSelected.length > 0 && (
Sessions for this concept
From 761a7d2b07f839efb683b1c1affa442d18854003 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:58:47 -0400 Subject: [PATCH 27/60] chore(lint): prune the Dashboard suppression freed by the quiz rewiring (#537 C2) Co-Authored-By: Claude Fable 5 --- frontend/eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/eslint-suppressions.json b/frontend/eslint-suppressions.json index 28f4bee2..3128dffa 100644 --- a/frontend/eslint-suppressions.json +++ b/frontend/eslint-suppressions.json @@ -92,7 +92,7 @@ }, "src/components/screens/Dashboard.tsx": { "no-restricted-syntax": { - "count": 20 + "count": 19 }, "react/no-unescaped-entities": { "count": 2 From cf51bb324a5dfbd5e9160169433e173a127b809e Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:00:10 -0400 Subject: [PATCH 28/60] =?UTF-8?q?feat(quiz):=20the=20results=20screen=20?= =?UTF-8?q?=E2=80=94=20growth=20node,=20missed=20list,=20three=20exits=20(?= =?UTF-8?q?#537=20B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Wave 2 stub with §5 B3. The props seam (`QuizResultsProps`) is unchanged. - The growth neighbourhood (640x212, scale 2.5) with the centre drawn as `growth {mastery_before -> mastery_after}`, animated once per attempt (keyed on `attemptId`) and rendered at its end state under reduced motion. R-12: the ONLY tier derived from a score is the "after" one; "before" is the wire tier. - The delta line, the score/XP rule (XP omitted, never invented, when the gamification read failed — R-9), and the R-5 "Focused on what you missed" eyebrow on a missed-scope attempt. - `MissedList`: the wrong answers joined to their stems (the wire sends a string `question_id` against a numeric `WireQuestion.id`, so the join coerces), each with a Show-explanation disclosure and "Ask about this" opening B2's `AskPanel` seeded from that row. - The perfect-run sentence in place of the review section, and the three exits (next-in-queue / practise-missed / quiz-again, back-to-source, Done). Class names only over tokens (R-1): the design's vertical rhythm and the missed item's accent bar are declared once at the top of `results.css`. Co-Authored-By: Claude Fable 5 --- .../components/quiz/results/MissedList.tsx | 141 ++++++++ .../quiz/results/QuizResults.test.tsx | 341 ++++++++++++++++++ .../components/quiz/results/QuizResults.tsx | 224 +++++++++--- .../src/components/quiz/results/results.css | 190 ++++++++++ 4 files changed, 847 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/quiz/results/MissedList.tsx create mode 100644 frontend/src/components/quiz/results/QuizResults.test.tsx create mode 100644 frontend/src/components/quiz/results/results.css diff --git a/frontend/src/components/quiz/results/MissedList.tsx b/frontend/src/components/quiz/results/MissedList.tsx new file mode 100644 index 00000000..0f173944 --- /dev/null +++ b/frontend/src/components/quiz/results/MissedList.tsx @@ -0,0 +1,141 @@ +"use client"; + +/** + * The "to look at" list on the results screen (§5 B3). + * + * One row per wrong answer: the stem, what was chosen against what was right, a + * disclosure for the explanation and "Ask about this". It is a separate file + * from `QuizResults` because the JOIN it does is the interesting part — + * `SubmitResult.results[]` carries labels and ids but no stems, and the stems + * only exist on the session's `items`. `buildMissedItems` is that join, exported + * so a test can pin it without rendering. + * + * The explanation disclosure is the one place on this screen that changes + * height. Everywhere else reserves its space; here the expansion IS the + * interaction, and reserving three lines of prose per row for something most + * students won't open would cost more than the shift does. + */ + +import React from "react"; +import { Button } from "@/components/ui"; +import type { QuizSession } from "@/lib/quiz/types"; + +export interface MissedItem { + /** `SubmitResult.results[].question_id` — a string on the wire, and the + * testid suffix (`quiz-missed-{id}`). */ + questionId: string; + /** "" when the question is no longer in the session's items. */ + stem: string; + /** "" when the item was submitted unanswered. */ + chosenLabel: string; + chosenText: string; + correctLabel: string; + correctText: string; + explanation: string; +} + +/** + * The wrong answers, in the order the server scored them, joined to their + * stems. + * + * `SubmitResult.results[].question_id` is `str(q["id"])` server-side + * (`quiz.py:1749`) while `WireQuestion.id` is a number, so the join coerces — + * a `===` on the raw values would silently match nothing and render a list of + * stemless rows. `selected` / `correct_answer` are option LABELS, not texts, + * which is why the option texts are looked up here rather than read off the + * result. + */ +export function buildMissedItems(session: QuizSession): MissedItem[] { + const result = session.result; + if (!result) return []; + + return result.results + .filter(r => !r.correct) + .map(r => { + const question = session.items.find( + item => String(item.question.id) === String(r.question_id), + )?.question; + const textFor = (label: string) => + question?.options.find(o => o.label === label)?.text ?? ""; + + return { + questionId: String(r.question_id), + stem: question?.question ?? "", + chosenLabel: r.selected, + chosenText: textFor(r.selected), + correctLabel: r.correct_answer, + correctText: textFor(r.correct_answer), + explanation: r.explanation, + }; + }); +} + +/** "One to look at" / "3 to look at" — the design's own eyebrow. */ +export function missedLabel(count: number): string { + return count === 1 ? "One to look at" : `${count} to look at`; +} + +export interface MissedListProps { + items: MissedItem[]; + /** Opens the AskPanel seeded from this row. The trigger is handed back so the + * screen can hold it as the panel's `returnFocusTo`. */ + onAsk: (item: MissedItem, trigger: HTMLElement) => void; + testid?: string; +} + +export function MissedList({ items, onAsk, testid = "quiz-missed-list" }: MissedListProps) { + const [expanded, setExpanded] = React.useState>({}); + + if (items.length === 0) return null; + + return ( +
+
{missedLabel(items.length)}
+ {items.map(item => { + const open = expanded[item.questionId] === true; + const panelId = `quiz-missed-explanation-${item.questionId}`; + return ( +
+ {item.stem &&

{item.stem}

} +

+ {item.chosenLabel + ? `You chose ${item.chosenLabel} · the answer is ${item.correctLabel}` + : `No answer · the answer is ${item.correctLabel}`} +

+
+ {item.explanation && ( + + )} + +
+ {item.explanation && open && ( +

+ {item.explanation} +

+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/quiz/results/QuizResults.test.tsx b/frontend/src/components/quiz/results/QuizResults.test.tsx new file mode 100644 index 00000000..7f8ce332 --- /dev/null +++ b/frontend/src/components/quiz/results/QuizResults.test.tsx @@ -0,0 +1,341 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import React from "react"; +import { markRadius } from "@/components/graph/ConceptNode"; +import { __resetReducedMotionStoreForTests } from "@/lib/usePrefersReducedMotion"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { QuizItem, QuizSession, SubmitResult } from "@/lib/quiz/types"; +import { QuizResults } from "./QuizResults"; +import { buildMissedItems } from "./MissedList"; + +// B2 owns `question/AskPanel`; this screen only has to open it with the right +// seed. The mock renders the seed so the assertion is on the props, not on +// whatever the real sheet does with them. +vi.mock("../question/AskPanel", () => ({ + AskPanel: (props: { open: boolean; seed: Record; conceptName: string }) => + props.open ? ( +
+ {JSON.stringify(props.seed)} +
+ ) : null, +})); + +// The reduced-motion MEDIA query is forced off in this file so the +// `prefersReducedMotion` PROP is the only thing under test (the repo's vitest +// setup defaults the query to `matches: true`, which would mask it). +beforeEach(() => { + window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +}); + +afterEach(() => { + cleanup(); + __resetReducedMotionStoreForTests(); + vi.restoreAllMocks(); +}); + +const CONCEPT = { + id: "recursion", + name: "Recursion", + courseCode: "CS101", + color: "#7b4b99", + tier: "struggling", + mastery: 0.29, +}; + +const STEMS: Record = { + 101: "What is a base case?", + 102: "What happens if a recursive function has no base case?", + 103: "Which call pattern is tail recursive?", +}; + +function item(id: number): QuizItem { + return { + index: id - 101, + question: { + id, + question: STEMS[id], + difficulty: "medium", + options: [ + { label: "A", text: "It returns immediately" }, + { label: "B", text: "It recurses forever and overflows the stack" }, + { label: "C", text: "It is optimised away" }, + { label: "D", text: "Nothing at all" }, + ], + }, + selectedIndex: 0, + verdict: null, + flagged: false, + }; +} + +const RESULT: SubmitResult = { + score: 2, + total: 3, + mastery_before: 0.29, + mastery_after: 0.46, + results: [ + { question_id: "101", selected: "B", correct: true, correct_answer: "B", explanation: "Yes." }, + { + question_id: "102", + selected: "A", + correct: false, + correct_answer: "B", + explanation: "Without a base case the recursion never stops.", + }, + { question_id: "103", selected: "C", correct: true, correct_answer: "C", explanation: "Right." }, + ], +}; + +function session(over: Partial = {}): QuizSession { + return { + intent: "practice", + scope: { kind: "concept", conceptId: "recursion" }, + source: { kind: "tree", conceptId: "recursion" }, + config: { count: 3, difficulty: "medium", feedback: "at-end" }, + conceptId: "recursion", + courseId: "cs101", + attemptId: "attempt-1", + items: [item(101), item(102), item(103)], + cursor: 2, + queueIndex: 0, + phase: "results", + error: null, + result: RESULT, + xp: { before: 300, after: 330, streak: 12 }, + deliveredShort: false, + ...over, + }; +} + +function actions(): QuizActions { + return { + configure: vi.fn(), + setConfig: vi.fn(), + start: vi.fn(), + select: vi.fn(), + submitAnswer: vi.fn(), + next: vi.fn(), + finish: vi.fn(), + requestLeave: vi.fn(), + cancelLeave: vi.fn(), + confirmLeave: vi.fn(), + resume: vi.fn(), + practiseMissed: vi.fn(), + nextInQueue: vi.fn(), + exit: vi.fn(), + flag: vi.fn(), + dismissError: vi.fn(), + retry: vi.fn(), + }; +} + +function renderResults( + over: Partial = {}, + opts: { prefersReducedMotion?: boolean; acts?: QuizActions } = {}, +) { + const acts = opts.acts ?? actions(); + const view = render( + , + ); + return { ...view, acts }; +} + +const PERFECT: Partial = { + result: { + ...RESULT, + score: 3, + results: RESULT.results.map(r => ({ ...r, correct: true, selected: r.correct_answer })), + }, +}; + +describe("QuizResults", () => { + it("shows the score, the mastery delta and the XP line", () => { + renderResults(); + expect(screen.getByTestId("quiz-results-score")).toHaveTextContent("2 of 3 correct"); + // tierBefore is the wire tier off the concept; tierAfter is the ONE value + // derived from a score (R-12). + expect(screen.getByTestId("quiz-results-mastery")).toHaveTextContent( + "29% → 46% · struggling → learning", + ); + expect(screen.getByTestId("quiz-results-xp")).toHaveTextContent("+30 XP · 12-day streak"); + }); + + it("omits the XP line entirely when the gamification read failed (R-9)", () => { + renderResults({ xp: null }); + expect(screen.queryByTestId("quiz-results-xp")).toBeNull(); + expect(screen.getByTestId("quiz-results-score")).toBeInTheDocument(); + }); + + it("names the growth in the canvas's accessible label", () => { + renderResults(); + expect(screen.getByTestId("quiz-results-graph")).toHaveAttribute( + "aria-label", + "Recursion node grew from 29% to 46% mastery", + ); + }); + + it("says the node moved, not grew, when mastery went down", () => { + renderResults({ result: { ...RESULT, mastery_after: 0.2 } }); + expect(screen.getByTestId("quiz-results-graph")).toHaveAttribute( + "aria-label", + "Recursion node moved from 29% to 20% mastery", + ); + }); + + it("lists only the wrong answers, joined to their stems", () => { + renderResults(); + const list = screen.getByTestId("quiz-missed-list"); + expect(list).toHaveTextContent("One to look at"); + expect(screen.getByTestId("quiz-missed-102")).toHaveTextContent(STEMS[102]); + expect(screen.getByTestId("quiz-missed-102")).toHaveTextContent( + "You chose A · the answer is B", + ); + expect(screen.queryByTestId("quiz-missed-101")).toBeNull(); + expect(screen.queryByTestId("quiz-missed-103")).toBeNull(); + }); + + it("joins the string question_id off the wire to the numeric one on the item", () => { + // The wire sends `str(q["id"])` while `WireQuestion.id` is a number — a + // strict === would match nothing and render a stemless row. + const missed = buildMissedItems(session()); + expect(missed).toHaveLength(1); + expect(missed[0]).toMatchObject({ + questionId: "102", + stem: STEMS[102], + chosenLabel: "A", + chosenText: "It returns immediately", + correctLabel: "B", + correctText: "It recurses forever and overflows the stack", + }); + }); + + it("toggles the explanation disclosure and reveals the text", () => { + renderResults(); + const toggle = screen.getByTestId("quiz-missed-explain-102"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByText("Without a base case the recursion never stops."), + ).toBeNull(); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect( + screen.getByText("Without a base case the recursion never stops."), + ).toBeInTheDocument(); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect( + screen.queryByText("Without a base case the recursion never stops."), + ).toBeNull(); + }); + + it("opens the AskPanel seeded from the row that asked", () => { + renderResults(); + expect(screen.queryByTestId("quiz-ask-panel")).toBeNull(); + + fireEvent.click(screen.getByTestId("quiz-missed-ask-102")); + const panel = screen.getByTestId("quiz-ask-panel"); + expect(panel).toHaveAttribute("data-concept", "Recursion"); + expect(JSON.parse(panel.textContent ?? "{}")).toEqual({ + stem: STEMS[102], + chosenLabel: "A", + chosenText: "It returns immediately", + correctLabel: "B", + correctText: "It recurses forever and overflows the stack", + explanation: "Without a base case the recursion never stops.", + }); + }); + + it("replaces the review section with the perfect-run line", () => { + renderResults(PERFECT); + expect(screen.getByTestId("quiz-results-perfect")).toHaveTextContent( + "Nothing to review — every answer was right. Recursion keeps growing on your tree.", + ); + expect(screen.queryByTestId("quiz-missed-list")).toBeNull(); + expect(screen.getByTestId("quiz-again")).toBeInTheDocument(); + expect(screen.queryByTestId("quiz-practise-missed")).toBeNull(); + }); + + it("offers the next concept while the queue has more, and calls nextInQueue", () => { + const { acts } = renderResults({ + scope: { kind: "course", courseId: "cs101", queue: ["recursion", "base-cases"] }, + queueIndex: 0, + }); + expect(screen.queryByTestId("quiz-practise-missed")).toBeNull(); + fireEvent.click(screen.getByTestId("quiz-next-concept")); + expect(acts.nextInQueue).toHaveBeenCalledTimes(1); + }); + + it("falls back to practising the missed questions, and calls practiseMissed", () => { + const { acts } = renderResults(); + const button = screen.getByTestId("quiz-practise-missed"); + expect(button).toHaveTextContent("Practise the one you missed"); + fireEvent.click(button); + expect(acts.practiseMissed).toHaveBeenCalledTimes(1); + }); + + it("counts the missed questions in the practise label", () => { + renderResults({ + result: { + ...RESULT, + score: 1, + results: RESULT.results.map((r, i) => (i === 0 ? { ...r, correct: false } : r)), + }, + }); + expect(screen.getByTestId("quiz-practise-missed")).toHaveTextContent( + "Practise the 2 you missed", + ); + }); + + it("labels the secondary exit from the source and calls exit()", () => { + const { acts } = renderResults({ source: { kind: "notes", noteId: "n1" } }); + const back = screen.getByTestId("quiz-back-to-source"); + expect(back).toHaveTextContent("Back to your note"); + fireEvent.click(back); + expect(acts.exit).toHaveBeenCalledWith(); + }); + + it("sends Done to quiz home", () => { + const { acts } = renderResults(); + fireEvent.click(screen.getByTestId("quiz-done")); + expect(acts.exit).toHaveBeenCalledWith("/quiz"); + }); + + it("labels a review attempt as focused on what was missed (R-5)", () => { + renderResults({ + intent: "review", + scope: { kind: "missed", conceptId: "recursion", missedCount: 1 }, + }); + expect(screen.getByText("Focused on what you missed")).toBeInTheDocument(); + }); + + it("renders the grown node at its end state immediately under reduced motion", () => { + const { container } = renderResults({}, { prefersReducedMotion: true }); + const body = container.querySelector(".concept-node__body--growth")!; + // The end state, first paint: the after-radius, at full scale — no + // transition to run. + expect(Number(body.getAttribute("r"))).toBeCloseTo(markRadius(0.46, false, 2.5), 5); + expect(Number(body.getAttribute("style")?.match(/--concept-grow:\s*([\d.]+)/)?.[1])).toBe(1); + }); +}); diff --git a/frontend/src/components/quiz/results/QuizResults.tsx b/frontend/src/components/quiz/results/QuizResults.tsx index f61b4631..2b5974cc 100644 --- a/frontend/src/components/quiz/results/QuizResults.tsx +++ b/frontend/src/components/quiz/results/QuizResults.tsx @@ -1,21 +1,40 @@ "use client"; /** - * STUB — Wave 3 (B3) replaces the body. The PROPS are the seam and must not - * change. + * The results screen (§5 B3). * - * The real screen is §5 B3: the growth neighbourhood, the mastery delta line, - * the score and XP rule, the missed list with its disclosures and per-item - * "Ask about this", the perfect-run line, and the three exits. + * The point of the screen is the first thing on it: the concept's node, grown + * from the mastery it had to the mastery it has, inside a still fragment of the + * tree it lives on. Everything below is the receipt — the score, the XP line + * when we know it, the questions worth another look, and the three ways out. + * + * Two things it deliberately does NOT do: + * - It never recomputes a tier from a score except for the ONE value that has + * no tier on the wire, `mastery_after` (R-12). The "before" tier is the + * server's `mastery_tier`, read off the concept. + * - It never dispatches `sapling:graph-changed`. That belongs to + * `useQuizSession`, which knows a submit actually landed; a component + * firing it on render would repeat it on every re-render of one result + * (§4 amendment). */ import React from "react"; +import { Button } from "@/components/ui"; +import { ConceptNeighbourhood } from "@/components/graph/ConceptNeighbourhood"; +import { tierFor } from "@/lib/graph/nodeStyle"; import type { NeighbourNode } from "@/lib/graph/neighbourhood"; +import { useUser } from "@/context/UserContext"; import { queueOf } from "@/lib/quiz/machine"; import type { QuizActions } from "@/lib/quiz/useQuizSession"; import type { QuizSession } from "@/lib/quiz/types"; import { sourceLabel } from "@/lib/quiz/exits"; +import { AskPanel } from "../question/AskPanel"; import type { QuizConceptSummary } from "../question/QuizQuestion"; +import { MissedList, buildMissedItems, type MissedItem } from "./MissedList"; +import "./results.css"; + +/** The results canvas, §3's third `ConceptNeighbourhood` preset. */ +const CANVAS = { width: 640, height: 212, scale: 2.5 } as const; export interface QuizResultsProps { session: QuizSession; @@ -25,52 +44,143 @@ export interface QuizResultsProps { prefersReducedMotion: boolean; } -export function QuizResults({ session, actions, concept }: QuizResultsProps) { +const pct = (value: number) => Math.round(value * 100); + +export function QuizResults({ + session, + actions, + concept, + neighbourhood, + prefersReducedMotion, +}: QuizResultsProps) { + // AskPanel needs the viewer's id and the seam (`QuizResultsProps`) is fixed, + // so it comes off the same context `QuizScreen` reads. The context has a + // default value, so this is safe outside a provider (an empty id, which the + // panel treats as "not signed in") rather than a throw. + const { userId } = useUser(); + const [asking, setAsking] = React.useState(null); + // The row that opened the panel, so closing it lands back on that button + // rather than on the document (AskPanel's `returnFocusTo`). + const askTrigger = React.useRef(null); + const result = session.result; + // `results` is the only phase that renders this screen and the reducer sets + // `result` on the way in, so this is a guard, not a state. + if (!result) return null; + + const before = result.mastery_before; + const after = result.mastery_after; + const tierBefore = concept.tier; + const tierAfter = tierFor(after); + // "grew" is the whole promise of the screen; a mastery that went down still + // gets an honest verb rather than a cheerful one. + const verb = after >= before ? "grew" : "moved"; + const growthLabel = `${concept.name} node ${verb} from ${pct(before)}% to ${pct(after)}% mastery`; + + const missed = buildMissedItems(session); + const isPerfect = missed.length === 0; + const queue = queueOf(session.scope); const hasNext = session.queueIndex + 1 < queue.length; - const missed = result ? result.total - result.score : 0; + + const xpDelta = session.xp ? session.xp.after - session.xp.before : null; + + // The course accent, already resolved by `QuizScreen`. An unresolved one + // falls through to the app accent inside the mark rather than to a literal. + const courseColor = concept.color || "var(--quiz-accent, var(--accent))"; return ( -
-

{concept.name}

-

- {result ? `${result.score} of ${result.total} correct` : ""} -

-

- {result - ? `${Math.round(result.mastery_before * 100)}% → ${Math.round(result.mastery_after * 100)}%` - : ""} +

+ {/* Keyed on the attempt so the node grows exactly once per result: a + re-render (opening a disclosure, the AskPanel) must not replay it, + and the NEXT attempt's result must. */} +
+ +
+ + {/* R-5: the repetition guard means a "practise what you missed" attempt + asks different questions. Say so rather than let it read as a bug. */} + {session.scope.kind === "missed" && ( +
Focused on what you missed
+ )} + +

{concept.name}

+

+ {pct(before)}% → {pct(after)}% · {tierBefore} → {tierAfter}

- {session.xp && ( -

- +{session.xp.after - session.xp.before} XP · {session.xp.streak}-day streak + +

+ + {result.score} of {result.total} correct + + {/* R-9: submit returns no deltas, so the XP line is a separate read. + If either half of it failed it is omitted — never invented. */} + {xpDelta !== null && session.xp && ( + + +{xpDelta} XP · {session.xp.streak}-day streak + + )} +
+ + {isPerfect ? ( +

+ Nothing to review — every answer was right. {concept.name} keeps growing on your tree.

+ ) : ( + { + askTrigger.current = trigger; + setAsking(item); + }} + /> )} -
+
+ +
{hasNext ? ( - - ) : missed > 0 ? ( - + {missed.length === 1 + ? "Practise the one you missed" + : `Practise the ${missed.length} you missed`} + ) : ( - + )} - - + + + + +
+ + {/* One panel for the whole list — the same sheet the question screen + opens (R-6), seeded from whichever row asked. */} + {asking && ( + setAsking(null)} + userId={userId} + conceptName={concept.name} + courseId={session.courseId} + courseLabel={concept.courseCode || undefined} + returnFocusTo={askTrigger} + seed={{ + stem: asking.stem, + chosenLabel: asking.chosenLabel, + chosenText: asking.chosenText, + correctLabel: asking.correctLabel, + correctText: asking.correctText, + explanation: asking.explanation, + }} + /> + )}
); } diff --git a/frontend/src/components/quiz/results/results.css b/frontend/src/components/quiz/results/results.css new file mode 100644 index 00000000..e4762230 --- /dev/null +++ b/frontend/src/components/quiz/results/results.css @@ -0,0 +1,190 @@ +/* The results screen (§5 B3). + * + * Class names only (R-1): the ONE inline style anywhere under + * `components/quiz/**` is `QuizScreen`'s `--quiz-accent` binding, and every + * colour below reads `var(--quiz-accent, var(--accent))` so an unset accent + * degrades to the app's own rather than to a literal. + * + * The design's own geometry — the vertical rhythm of the column, the missed + * item's accent bar and inset, the stem's 17px/1.45 — is declared ONCE here as + * tokens on `.quiz-results` and referenced below, so no rule carries a bare px + * measurement. Everything else is a `--fs-*` / `--pad-*` / colour token. + */ + +.quiz-results { + /* Vertical rhythm, read off the prototype's RESULTS section. */ + --quiz-results-gap-name: 18px; + --quiz-results-gap-delta: 8px; + --quiz-results-gap-rule: 26px; + --quiz-results-rule-pad-y: 13px; + --quiz-results-rule-pad-x: 4px; + --quiz-results-gap-block: 26px; + --quiz-results-gap-perfect: 34px; + --quiz-results-gap-divider: 30px; + --quiz-results-gap-exits: 18px; + --quiz-results-exits-gap: 10px; + + /* The missed item: a 2px accent bar, its text inset past it. */ + --quiz-results-bar: 2px; + --quiz-results-item-inset: 16px; + --quiz-results-item-pad-y: 2px; + --quiz-results-item-gap: 12px; + --quiz-results-line-gap: 6px; + + /* The missed stem sits between --fs-xl (18) and --fs-lg (16); the design's + 17px/1.45 is its own step, so it is a token rather than a rounded token. */ + --quiz-results-stem: 17px; + --quiz-results-stem-lh: 1.45; + + /* The perfect-run sentence is set narrow so it reads as a sentence, centred + under a 640px column. */ + --quiz-results-perfect-w: 400px; + + display: flex; + flex-direction: column; + align-items: center; + width: 100%; +} + +/* The neighbourhood canvas is 640px wide by preset — the same width as the + column — so it only ever needs to shrink, never to be scrolled. */ +.quiz-results__graph { + max-width: 100%; +} + +.quiz-results__graph > svg { + max-width: 100%; + height: auto; +} + +/* R-5: a review attempt says so, above the name it is about. */ +.quiz-results__eyebrow { + margin-top: var(--quiz-results-gap-name); +} + +.quiz-results__name { + font-size: var(--fs-3xl); + color: var(--text); + margin: var(--quiz-results-gap-name) 0 0; + text-align: center; +} + +.quiz-results__eyebrow + .quiz-results__name { + margin-top: var(--quiz-results-gap-delta); +} + +.quiz-results__delta { + font-size: var(--fs-md); + color: var(--text-muted); + margin: var(--quiz-results-gap-delta) 0 0; +} + +/* The score rule: two plain text spans between two hairlines. Nothing is + signalled by colour alone — this is a sentence, not a badge. */ +.quiz-results__rule { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--pad-md); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + padding: var(--quiz-results-rule-pad-y) var(--quiz-results-rule-pad-x); + margin-top: var(--quiz-results-gap-rule); +} + +.quiz-results__score { + font-size: var(--fs-md); + color: var(--text-dim); +} + +.quiz-results__xp { + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-results__perfect { + font-size: var(--fs-md); + color: var(--text-muted); + margin-top: var(--quiz-results-gap-perfect); + max-width: var(--quiz-results-perfect-w); + text-align: center; + text-wrap: pretty; +} + +.quiz-results__divider { + width: 100%; + height: 1px; + background: var(--border); + border: 0; + margin: var(--quiz-results-gap-divider) 0 0; +} + +.quiz-results__exits { + width: 100%; + display: flex; + align-items: center; + gap: var(--quiz-results-exits-gap); + padding-top: var(--quiz-results-gap-exits); +} + +/* Pushes "Done" to the far right without giving it a different alignment + context from the two buttons on the left. */ +.quiz-results__exits-spacer { + flex: 1; +} + +/* ── The missed list ──────────────────────────────────────────────── */ + +.quiz-missed { + width: 100%; + margin-top: var(--quiz-results-gap-block); +} + +.quiz-missed__item { + border-left: var(--quiz-results-bar) solid var(--quiz-accent, var(--accent)); + padding: var(--quiz-results-item-pad-y) 0 var(--quiz-results-item-pad-y) + var(--quiz-results-item-inset); + margin-top: var(--quiz-results-item-gap); +} + +.quiz-missed__stem { + font-size: var(--quiz-results-stem); + line-height: var(--quiz-results-stem-lh); + color: var(--text); + margin: 0; + text-wrap: pretty; +} + +.quiz-missed__line { + font-size: var(--fs-md); + color: var(--text-muted); + margin: var(--quiz-results-line-gap) 0 0; +} + +.quiz-missed__actions { + display: flex; + align-items: center; + gap: var(--pad-md); + margin-top: var(--quiz-results-item-gap); +} + +/* The one intentional expand on this screen: nothing is reserved for it, so + the list is compact until a student asks for the reasoning. */ +.quiz-missed__explanation { + font-size: var(--fs-md); + color: var(--text-dim); + margin: var(--quiz-results-line-gap) 0 0; + text-wrap: pretty; +} + +/* The design's results canvas captions the SIBLINGS but not the centre — the + concept name is set below it in the display serif, and drawing it twice reads + as a bug. `ConceptNeighbourhood` (A1) has one `showLabels` switch for both, + and turning it off would drop the sibling captions the design does draw, so + the centre caption — always the last on the canvas — is hidden here + instead. TODO(#537-followup: a `showCentreLabel` prop on ConceptNeighbourhood + would retire this rule). */ +.quiz-results__graph .concept-neighbourhood__label:last-of-type { + display: none; +} From 76ad9aa286527d5cead1d650d2af16e1c289215b Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:03:05 -0400 Subject: [PATCH 29/60] fix(quiz): reach the due-review CTA at mobile width in the sidenav layout (#537 C2 fix round 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default (sidenav) layout's "Review what's due" CTA lived only in rightPanel's `!isMobile`-gated quick-action row, so phones had no way to start a due review from that layout. It's now also a full-width secondary button in mobileMetaRow, gated `!useLegacyPanels` so the legacy layout's own copy (in the Learn-next panel's mobile "Stats & More" tab) isn't duplicated — isMobile/useLegacyPanels together always mount exactly one `dashboard-review-due` button per render. Also dropped the dead `suggestNode ? {...} : {}` fallback in the suggest card's onClick: the ternary branch it sat in is already gated on `suggestNode &&`, and the sibling `suggestNode.name` access two lines up proves TS already narrows it non-null there. Co-Authored-By: Claude Fable 5 --- .../screens/Dashboard.quiz.test.tsx | 20 +++++- frontend/src/components/screens/Dashboard.tsx | 65 ++++++++++++------- 2 files changed, 61 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/screens/Dashboard.quiz.test.tsx b/frontend/src/components/screens/Dashboard.quiz.test.tsx index 4e805dc6..d6b64c4e 100644 --- a/frontend/src/components/screens/Dashboard.quiz.test.tsx +++ b/frontend/src/components/screens/Dashboard.quiz.test.tsx @@ -28,7 +28,7 @@ vi.mock("@/context/UserContext", () => ({ useUser: () => ({ userId: "u1", userReady: true, userName: "Ada" }), })); -vi.mock("@/lib/useIsMobile", () => ({ useIsMobile: () => false })); +vi.mock("@/lib/useIsMobile", () => ({ useIsMobile: vi.fn(() => false) })); // "topnav" renders the legacy Learn-next panel with the quiz CTA; the // third describe block flips this to "sidebar" to exercise the layout with @@ -58,6 +58,7 @@ import { getSessions, getUpcomingAssignments, } from "@/lib/api"; +import { useIsMobile } from "@/lib/useIsMobile"; function course(code: string): EnrolledCourse { return { @@ -101,6 +102,7 @@ beforeEach(() => { disconnect() {} }; + vi.mocked(useIsMobile).mockReturnValue(false); vi.mocked(getUpcomingAssignments).mockResolvedValue({ assignments: [] }); vi.mocked(getSessions).mockResolvedValue({ sessions: [] }); vi.mocked(getRecommendations).mockResolvedValue({ recommendations: [] }); @@ -169,4 +171,20 @@ describe("Dashboard — review-what's-due CTA (default sidenav layout)", () => { fireEvent.click(cta); expect(push).toHaveBeenCalledWith("/quiz?scope=due&from=dashboard&return=%2Fdashboard"); }); + + it("is reachable at mobile width too, as the single mounted instance", async () => { + layoutState.pref = "sidebar"; + vi.mocked(useIsMobile).mockReturnValue(true); + + render(); + + // Exactly one CTA in the DOM — the mobile row, not the (hidden) + // desktop quick-action row — so the shared testid never collides. + const ctas = await screen.findAllByTestId("dashboard-review-due"); + expect(ctas).toHaveLength(1); + expect(ctas[0].textContent).toContain("Review what's due"); + + fireEvent.click(ctas[0]); + expect(push).toHaveBeenCalledWith("/quiz?scope=due&from=dashboard&return=%2Fdashboard"); + }); }); diff --git a/frontend/src/components/screens/Dashboard.tsx b/frontend/src/components/screens/Dashboard.tsx index 6c17cc36..43ad74c9 100644 --- a/frontend/src/components/screens/Dashboard.tsx +++ b/frontend/src/components/screens/Dashboard.tsx @@ -709,29 +709,48 @@ export function Dashboard() { ); const mobileMetaRow = isMobile ? ( -
- - + )} +
- Start learning - + + +
) : null; @@ -746,7 +765,7 @@ export function Dashboard() { className="btn btn--sm btn--primary" onClick={() => router.push( buildQuizHref( - suggestNode ? { concept: suggestNode.id } : {}, + { concept: suggestNode.id }, { kind: "dashboard", returnTo: "/dashboard" }, ), )} From 66b19c2b7c07b9f21a87892b30fb8fd4e94e9754 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:03:52 -0400 Subject: [PATCH 30/60] feat(graph): let a neighbourhood drop its centre caption (#537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every screen the design draws already prints the concept's name in HTML below the canvas, so the SVG's own centre caption says it twice. `showCentreLabel` defaults to `true`, so nothing existing changes; passing `false` omits the centre and leaves the siblings captioned. `showLabels={false}` still wins over it — that switch drops the lot. The gallery's results preset now passes it, which is how B3 will mount it. Co-Authored-By: Claude Fable 5 --- .../graph/ConceptNeighbourhood.test.tsx | 27 +++++++++++++++++++ .../components/graph/ConceptNeighbourhood.tsx | 13 ++++++++- .../ui/__fixtures__/QuizPrimitivesGallery.tsx | 5 +++- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/graph/ConceptNeighbourhood.test.tsx b/frontend/src/components/graph/ConceptNeighbourhood.test.tsx index 43906dab..7c6c9098 100644 --- a/frontend/src/components/graph/ConceptNeighbourhood.test.tsx +++ b/frontend/src/components/graph/ConceptNeighbourhood.test.tsx @@ -125,6 +125,33 @@ describe("ConceptNeighbourhood", () => { expect(num(topLeft, "y")).toBeGreaterThan(num(bodies[0], "cy")); }); + it("drops only the centre caption under showCentreLabel={false}", () => { + // Every screen prints the concept's name in HTML below the canvas, so the + // SVG copy would say it twice — the siblings still need theirs. + const { container } = renderHome({ showCentreLabel: false }); + const labels = Array.from(container.querySelectorAll(".concept-neighbourhood__label")).map( + (t) => t.textContent, + ); + expect(labels).toEqual(["Base cases", "Tail recursion"]); + expect(labels).not.toContain("Recursion"); + // …and nothing else moves: the marks and edges are untouched. + expect(container.querySelectorAll(".concept-node__body")).toHaveLength(4); + expect(container.querySelectorAll(".concept-neighbourhood__edge")).toHaveLength(3); + }); + + it("captions the centre by default, so no existing caller changes", () => { + const { container } = renderHome(); + const labels = Array.from(container.querySelectorAll(".concept-neighbourhood__label")).map( + (t) => t.textContent, + ); + expect(labels).toContain("Recursion"); + }); + + it("still drops the centre caption when showLabels is false, whatever showCentreLabel says", () => { + const { container } = renderHome({ showLabels: false, showCentreLabel: true }); + expect(container.querySelectorAll(".concept-neighbourhood__label")).toHaveLength(0); + }); + it("drops every caption when showLabels is false", () => { const { container } = renderHome({ showLabels: false }); expect(container.querySelectorAll(".concept-neighbourhood__label")).toHaveLength(0); diff --git a/frontend/src/components/graph/ConceptNeighbourhood.tsx b/frontend/src/components/graph/ConceptNeighbourhood.tsx index 5e8392e7..84e647e9 100644 --- a/frontend/src/components/graph/ConceptNeighbourhood.tsx +++ b/frontend/src/components/graph/ConceptNeighbourhood.tsx @@ -24,6 +24,11 @@ * * The top-right slot sits on the canvas edge, so it never gets a caption — it * would clip. That matches the design, which drops that label in every preset. + * + * The CENTRE's caption is optional for a different reason: every screen the + * design draws already sets the concept's name in HTML below the canvas, so + * repeating it inside the SVG says it twice. `showCentreLabel={false}` drops + * the SVG copy and leaves the siblings captioned. */ import React from "react"; @@ -88,6 +93,11 @@ export interface ConceptNeighbourhoodProps { scale: number; centreVariant?: ConceptNodeVariant; showLabels?: boolean; + /** + * Caption the centre mark. Default `true`. Screens that already print the + * concept's name below the canvas pass `false` — the siblings stay labelled. + */ + showCentreLabel?: boolean; /** Required: the whole fragment is one image to a screen reader. */ ariaLabel: string; /** Growth only. `prefers-reduced-motion` overrides a `true` here. */ @@ -109,6 +119,7 @@ export function ConceptNeighbourhood({ scale, centreVariant = { kind: "node" }, showLabels = true, + showCentreLabel = true, ariaLabel, animate = true, composition = width >= WIDE_CANVAS_MIN_WIDTH ? "wide" : "compact", @@ -203,7 +214,7 @@ export function ConceptNeighbourhood({ glowFilterId={filterId} grown={grown} /> - {showLabels && ( + {showLabels && showCentreLabel && ( - + {/* The results screen prints the concept's name below the canvas, so + the centre caption is dropped rather than said twice. */} + From 1c2c6f52165e473ab7e4d78895d17525552098ff Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:06:14 -0400 Subject: [PATCH 31/60] feat(quiz): name the next concept in a queued session (#537 A2, fix round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The results screen's primary exit should read "Next: {concept}" while the scope queue has more, but the queue holds ids and `QuizResultsProps` carried none of the graph — B3 left a TODO saying exactly that. This widens the seam. `nextConceptInQueue(session, nodes)` resolves `queue[queueIndex + 1]` against the loaded graph; `QuizScreen` is the one place that has that graph, so it computes the prop and hands it down. `null` covers both "the queue is finished" and "the next id isn't in the scoped graph, so it can't be named", and the doc comment says so: this is a LABEL, and whether the exit renders at all stays a queue-length check that needs no graph. An unnamed button beats an invented name. `nextConcept` is optional, so B3's current render keeps compiling untouched — the only change to `QuizResults.tsx` is the interface member. Co-Authored-By: Claude Fable 5 --- frontend/src/components/quiz/QuizScreen.tsx | 10 +++- .../components/quiz/results/QuizResults.tsx | 11 +++++ frontend/src/lib/quiz/proposals.test.ts | 47 ++++++++++++++++++- frontend/src/lib/quiz/proposals.ts | 26 +++++++++- 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/quiz/QuizScreen.tsx b/frontend/src/components/quiz/QuizScreen.tsx index ee66f483..45a1b90d 100644 --- a/frontend/src/components/quiz/QuizScreen.tsx +++ b/frontend/src/components/quiz/QuizScreen.tsx @@ -28,7 +28,7 @@ import { siblingsFor } from "@/lib/graph/neighbourhood"; import { apiToGraphNode } from "@/lib/data"; import { parseEntry } from "@/lib/quiz/source"; import { loadPrefs } from "@/lib/quiz/prefs"; -import { colorFor, entrySelection } from "@/lib/quiz/proposals"; +import { colorFor, entrySelection, nextConceptInQueue } from "@/lib/quiz/proposals"; import { useQuizHome } from "@/lib/quiz/useQuizHome"; import { useQuizSession } from "@/lib/quiz/useQuizSession"; import { QuizHome } from "./home/QuizHome"; @@ -98,6 +98,13 @@ export function QuizScreen() { return siblingsFor(activeNode.id, adapted, home.edges); }, [activeNode, home.nodes, home.courses, home.edges]); + // The queue holds ids; only the loaded graph can turn the next one into a name + // for the results screen's "Next: {concept}" exit. + const nextConcept = useMemo( + () => nextConceptInQueue(session, home.nodes), + [session, home.nodes], + ); + const prefersReducedMotion = usePrefersReducedMotion(); const prefs = useMemo(() => loadPrefs(config), [config]); @@ -164,6 +171,7 @@ export function QuizScreen() { concept={concept} neighbourhood={{ siblings }} prefersReducedMotion={prefersReducedMotion} + nextConcept={nextConcept} /> ); } diff --git a/frontend/src/components/quiz/results/QuizResults.tsx b/frontend/src/components/quiz/results/QuizResults.tsx index 2b5974cc..8ac18460 100644 --- a/frontend/src/components/quiz/results/QuizResults.tsx +++ b/frontend/src/components/quiz/results/QuizResults.tsx @@ -42,6 +42,17 @@ export interface QuizResultsProps { concept: QuizConceptSummary; neighbourhood: { siblings: NeighbourNode[] }; prefersReducedMotion: boolean; + /** + * The next concept in the scope queue, named — for the "Next: {concept}" + * primary exit. `QuizScreen` resolves it from the loaded graph + * (`proposals.nextConceptInQueue`), which is the only place that has one. + * + * `null` means "no name available", which covers a finished queue AND a next + * id that isn't in the scoped graph. It is a LABEL only: whether that exit + * renders at all stays `queueOf(session.scope).length > session.queueIndex + 1`. + * Optional so the current render keeps compiling until it reads this. + */ + nextConcept?: { id: string; name: string } | null; } const pct = (value: number) => Math.round(value * 100); diff --git a/frontend/src/lib/quiz/proposals.test.ts b/frontend/src/lib/quiz/proposals.test.ts index 1b8ddd1c..54bd8742 100644 --- a/frontend/src/lib/quiz/proposals.test.ts +++ b/frontend/src/lib/quiz/proposals.test.ts @@ -11,13 +11,14 @@ import { isDue, latestCompletedAttempt, metaLine, + nextConceptInQueue, primaryOf, queueFor, rankCandidates, rationaleFor, } from "./proposals"; import { QUEUE_MAX } from "./session"; -import type { AttemptSummary } from "./types"; +import type { AttemptSummary, QuizScope, QuizSession } from "./types"; const NOW = new Date(2026, 7, 22, 9); // 22 Aug 2026, local @@ -360,6 +361,50 @@ describe("entrySelection", () => { }); }); +describe("nextConceptInQueue", () => { + const nodes = [ + node({ id: "n1", concept_name: "Recursion" }), + node({ id: "n2", concept_name: "Big-O" }), + node({ id: "n3", concept_name: " Tail calls " }), + ]; + + function session(scope: QuizScope, queueIndex: number): QuizSession { + return { scope, queueIndex } as QuizSession; + } + + it("names the concept after the current one in a due queue", () => { + expect(nextConceptInQueue(session({ kind: "due", queue: ["n1", "n2"] }, 0), nodes)) + .toEqual({ id: "n2", name: "Big-O" }); + }); + + it("names it in a course queue too, and trims the label", () => { + const scope: QuizScope = { kind: "course", courseId: "course-a", queue: ["n1", "n3"] }; + expect(nextConceptInQueue(session(scope, 0), nodes)).toEqual({ id: "n3", name: "Tail calls" }); + }); + + it("returns null at the end of the queue", () => { + expect(nextConceptInQueue(session({ kind: "due", queue: ["n1", "n2"] }, 1), nodes)).toBeNull(); + expect(nextConceptInQueue(session({ kind: "due", queue: [] }, 0), nodes)).toBeNull(); + }); + + it("returns null for a scope that has no queue at all", () => { + expect(nextConceptInQueue(session({ kind: "concept", conceptId: "n1" }, 0), nodes)).toBeNull(); + expect(nextConceptInQueue(session({ kind: "missed", conceptId: "n1", missedCount: 2 }, 0), nodes)) + .toBeNull(); + }); + + it("returns null rather than guessing when the next id is not in the loaded graph", () => { + // It is a LABEL. The exit's existence is decided from the queue, which needs + // no graph — so an unnamed button beats an invented name. + expect(nextConceptInQueue(session({ kind: "due", queue: ["n1", "off-scope"] }, 0), nodes)) + .toBeNull(); + expect(nextConceptInQueue( + session({ kind: "due", queue: ["n1", "blank"] }, 0), + [...nodes, node({ id: "blank", concept_name: " " })], + )).toBeNull(); + }); +}); + describe("colorFor", () => { it("prefers the node's own course colour, then the course record", () => { expect(colorFor(node({ id: "n", course_color: "#111111" }), course({ course_id: "course-a", color: "#222222" }))) diff --git a/frontend/src/lib/quiz/proposals.ts b/frontend/src/lib/quiz/proposals.ts index 89ff7d60..a842f2af 100644 --- a/frontend/src/lib/quiz/proposals.ts +++ b/frontend/src/lib/quiz/proposals.ts @@ -32,9 +32,10 @@ import { paletteFor } from "@/lib/data"; import { resolveInitialSelection, type QuizConcept } from "@/lib/quizSelection"; import type { EnrolledCourse } from "@/lib/api"; import type { GraphNode } from "@/lib/types"; +import { queueOf } from "./machine"; import { daysAgo, relativeStudied } from "./relativeTime"; import { QUEUE_MAX } from "./session"; -import type { AttemptSummary } from "./types"; +import type { AttemptSummary, QuizSession } from "./types"; /** The tiers `get_recommendations` considers worth suggesting. `mastered` and * `subject_root` are excluded by the same filter. */ @@ -211,6 +212,29 @@ export function queueFor( return scoped.filter(isDue).sort(byMasteryAsc).slice(0, QUEUE_MAX).map(n => n.id); } +/** + * The concept a queued session moves to next, named. + * + * The queue holds ids and the results screen is handed only the concept it just + * quizzed, so "Next: {concept}" cannot be built from the session alone — this + * resolves the id against the loaded graph. + * + * `null` covers both "the queue is finished" and "the next id isn't in the + * loaded (scoped) graph, so we can't name it". That is deliberate: it is a LABEL, + * and a caller must never use it to decide whether the exit exists — that stays + * `queueOf(scope).length > queueIndex + 1`, which needs no graph. Guessing a name + * would be worse than an unnamed button. + */ +export function nextConceptInQueue( + session: QuizSession, + nodes: GraphNode[], +): { id: string; name: string } | null { + const id = queueOf(session.scope)[session.queueIndex + 1]; + if (!id) return null; + const name = nodes.find(n => n.id === id)?.concept_name?.trim(); + return name ? { id, name } : null; +} + /** What a deep link actually points at, once resolved against the loaded graph. */ export interface EntrySelection { conceptId: string | null; From 9ae207a025439214cac0eb4266f80b64ecbb2764 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:08:11 -0400 Subject: [PATCH 32/60] =?UTF-8?q?feat(quiz):=20the=20question=20screen=20?= =?UTF-8?q?=E2=80=94=20Ask=20about=20this=20opens=20over=20the=20attempt?= =?UTF-8?q?=20(#537=20B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the A2 stub with the real §5 B2 screen. The props seam (`QuizQuestionProps`, `QuizConceptSummary`) is unchanged. - 64px progress rail · 680px reading column · 64px spacer; concept mark + difficulty chip header; reserved stem, reserved verdict line, reserved answer marks — nothing reflows between states. - One footer button whose label AND testid swap in place: Submit → Next / See results → Scoring… - Keyboard: A–F / 1–6 select, Enter is the footer action, Escape asks to leave, arrows move within the radiogroup. Documented in a visually-hidden hint and attached to the screen root, so the sheet and the dialog take the keyboard back while they are open. - AskPanel (R-6): the tutor arrives in a Sheet OVER the question via the two-call seeding pattern (R1 §F) — start-session on the concept name with its course id, then the composed context as the first message. No end-session on close; the question underneath is untouched and focus comes back to the trigger. Learn's stream ladder is reused for failures. - LeaveDialog is the only exit from a live attempt. Co-Authored-By: Claude Fable 5 --- .../quiz/question/AskPanel.test.tsx | 228 +++++++ .../src/components/quiz/question/AskPanel.tsx | 345 +++++++++++ .../components/quiz/question/LeaveDialog.tsx | 65 ++ .../quiz/question/QuizQuestion.test.tsx | 558 ++++++++++++++++++ .../components/quiz/question/QuizQuestion.tsx | 534 ++++++++++++++--- .../src/components/quiz/question/question.css | 345 +++++++++++ 6 files changed, 1979 insertions(+), 96 deletions(-) create mode 100644 frontend/src/components/quiz/question/AskPanel.test.tsx create mode 100644 frontend/src/components/quiz/question/AskPanel.tsx create mode 100644 frontend/src/components/quiz/question/LeaveDialog.tsx create mode 100644 frontend/src/components/quiz/question/QuizQuestion.test.tsx create mode 100644 frontend/src/components/quiz/question/question.css diff --git a/frontend/src/components/quiz/question/AskPanel.test.tsx b/frontend/src/components/quiz/question/AskPanel.test.tsx new file mode 100644 index 00000000..c8731ab9 --- /dev/null +++ b/frontend/src/components/quiz/question/AskPanel.test.tsx @@ -0,0 +1,228 @@ +// @vitest-environment jsdom +/** + * The tutor handoff, on its own (R-6 / R1 §F). What matters here is the SEAM: + * the two-call seeding pattern, the follow-ups landing on the same session, + * the failure ladder, and the session being left open on close. + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react"; +import { __resetScrollLocksForTests } from "@/lib/useScrollLock"; +import { AskPanel, composeAskMessage, type AskSeed } from "./AskPanel"; + +const api = vi.hoisted(() => ({ + startSessionStream: vi.fn(), + startSession: vi.fn(), + streamChat: vi.fn(), + sendChat: vi.fn(), + endSession: vi.fn(), + shouldFallBackToJson: vi.fn(() => true), +})); +vi.mock("@/lib/api", () => api); + +vi.mock("next/dynamic", () => ({ + default: () => + function MarkdownStub({ children }: { children: React.ReactNode }) { + return
{children}
; + }, +})); + +const SEED: AskSeed = { + stem: "What is the purpose of a base case?", + chosenLabel: "C", + chosenText: "It increases the recursion depth", + correctLabel: "B", + correctText: "It stops the recursion", + explanation: "Without one the calls never end.", +}; + +function renderPanel(over: Partial> = {}) { + const onClose = vi.fn(); + const view = render( + , + ); + return { ...view, onClose }; +} + +/** The session id the tutor opened with. */ +const SESSION = "tutor-session-1"; + +beforeEach(() => { + vi.clearAllMocks(); + api.shouldFallBackToJson.mockReturnValue(true); + api.startSessionStream.mockResolvedValue({ session_id: SESSION, reply: "Hi." }); + api.startSession.mockResolvedValue({ session_id: SESSION, initial_message: "Hi." }); + api.streamChat.mockResolvedValue({ reply: "The base case is the exit." }); + api.sendChat.mockResolvedValue({ reply: "The base case is the exit (json)." }); +}); + +afterEach(() => { + cleanup(); + __resetScrollLocksForTests(); +}); + +describe("composeAskMessage", () => { + it("says what happened, in the order the tutor needs it", () => { + expect(composeAskMessage(SEED)).toBe( + [ + "I got this quiz question wrong and want to understand why.", + "", + "Question: What is the purpose of a base case?", + "I chose C: It increases the recursion depth", + "The correct answer is B: It stops the recursion", + "Explanation given: Without one the calls never end.", + "", + "Help me understand why.", + ].join("\n"), + ); + }); +}); + +describe("AskPanel", () => { + it("opens a session on the concept, then sends the composed context", async () => { + renderPanel(); + + await waitFor(() => expect(api.startSessionStream).toHaveBeenCalledTimes(1)); + // (userId, topic, mode, useSharedContext, courseId, ...) + expect(api.startSessionStream.mock.calls[0].slice(0, 5)).toEqual([ + "user-1", + "Recursion", + "socratic", + true, + "course-cs101", + ]); + + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(1)); + expect(api.streamChat.mock.calls[0][0]).toBe(SESSION); + expect(api.streamChat.mock.calls[0][2]).toBe(composeAskMessage(SEED)); + + // The greeting from the start call is deliberately not rendered. + expect(screen.queryByText("Hi.")).toBeNull(); + await screen.findByText("The base case is the exit."); + // ...and the seed itself is on screen as static context. + expect(screen.getByTestId("quiz-ask-seed")).toHaveTextContent("You chose C"); + expect(screen.getByTestId("quiz-ask-seed")).toHaveTextContent("The answer is B"); + }); + + it("renders tokens as they stream in", async () => { + api.streamChat.mockImplementation(async (...args: unknown[]) => { + const handlers = args[6] as { onToken?: (d: string) => void }; + handlers.onToken?.("Think about "); + handlers.onToken?.("the exit condition."); + return { reply: "Think about the exit condition." }; + }); + + renderPanel(); + await screen.findByText("Think about the exit condition."); + }); + + it("sends a follow-up on the SAME session and never re-opens one", async () => { + renderPanel(); + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByTestId("quiz-ask-input"), { + target: { value: "So what happens without it?" }, + }); + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask-send")); + }); + + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(2)); + expect(api.startSessionStream).toHaveBeenCalledTimes(1); + expect(api.streamChat.mock.calls[1][0]).toBe(SESSION); + expect(api.streamChat.mock.calls[1][2]).toBe("So what happens without it?"); + expect(screen.getByText("So what happens without it?")).toBeInTheDocument(); + }); + + it("falls back to the JSON route when the stream produced nothing", async () => { + api.streamChat.mockRejectedValueOnce(new Error("stream died")); + renderPanel(); + + await waitFor(() => expect(api.sendChat).toHaveBeenCalledTimes(1)); + expect(api.sendChat.mock.calls[0][0]).toBe(SESSION); + await screen.findByText("The base case is the exit (json)."); + expect(screen.queryByTestId("quiz-ask-retry")).toBeNull(); + }); + + it("surfaces a non-retryable failure inline, and Retry re-sends it", async () => { + api.shouldFallBackToJson.mockReturnValue(false); + api.streamChat.mockRejectedValueOnce(new Error("tool writes already landed")); + renderPanel(); + + const retry = await screen.findByTestId("quiz-ask-retry"); + expect(screen.getByRole("alert")).toHaveTextContent("tool writes already landed"); + expect(api.sendChat).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent.click(retry); + }); + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(2)); + // The session opened for the first attempt is reused, not replaced. + expect(api.startSessionStream).toHaveBeenCalledTimes(1); + expect(api.streamChat.mock.calls[1][2]).toBe(composeAskMessage(SEED)); + }); + + it("uses the JSON start route when the streamed one falls over", async () => { + api.startSessionStream.mockRejectedValueOnce(new Error("no stream")); + renderPanel(); + + await waitFor(() => expect(api.startSession).toHaveBeenCalledTimes(1)); + expect(api.startSession.mock.calls[0].slice(0, 4)).toEqual([ + "user-1", + "Recursion", + "socratic", + "course-cs101", + ]); + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(1)); + }); + + it("leaves the tutor session open on close, and hands focus back", async () => { + const trigger = document.createElement("button"); + trigger.setAttribute("data-testid", "outside-trigger"); + document.body.appendChild(trigger); + const returnFocusTo = { current: trigger }; + + const { rerender, onClose } = renderPanel({ returnFocusTo }); + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(1)); + + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask-panel-close")); + }); + expect(onClose).toHaveBeenCalledTimes(1); + + await act(async () => { + rerender( + , + ); + }); + + expect(api.endSession).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(trigger); + trigger.remove(); + }); + + it("does nothing at all until it is opened", () => { + renderPanel({ open: false }); + expect(api.startSessionStream).not.toHaveBeenCalled(); + expect(screen.queryByTestId("quiz-ask-panel")).toBeNull(); + }); +}); diff --git a/frontend/src/components/quiz/question/AskPanel.tsx b/frontend/src/components/quiz/question/AskPanel.tsx new file mode 100644 index 00000000..08e14b80 --- /dev/null +++ b/frontend/src/components/quiz/question/AskPanel.tsx @@ -0,0 +1,345 @@ +"use client"; + +/** + * "Ask about this" — the tutor, OVER the quiz (R-6). + * + * This is the behavioural point of the redesign. The old screen navigated to + * `/learn?topic=…`, which abandoned the attempt: the questions were gone, the + * answers already given were gone, and there was no way back. Here the tutor + * arrives in a `Sheet` on top of the question; closing it puts the student + * back on the exact same item with the exact same verdict on screen. The panel + * owns all of its own state and dispatches no machine events — the attempt + * cannot notice it happened. + * + * SEEDING (R1 §F). `StartSessionBody` has no context field: `topic` is the + * session's display name, it is encrypted-stored, and it is matched against + * course codes and concept names to find the course grounding — so dumping a + * question stem into it would produce a garbage session title AND lose the + * grounding. The only pattern that exists is two calls: open the session on + * the concept name (with `course_id`, which is what actually grounds the graph + * block and RAG), then send the composed context as the first message. The + * tutor's greeting from that first call is deliberately never rendered — the + * student asked about a question, not for a hello. + * + * The session is LEFT OPEN on close (no `end-session`): it stays in the + * tutor's session list, which is where a student who wants to keep going will + * look for it. The accumulating-sessions cost is recorded as a seam in §8. + * + * Failure handling mirrors `Learn.tsx`'s ladder, minus the parts that only + * make sense in a transcript: a stream that produced nothing falls back to the + * JSON route transparently, a stream that failed AFTER tokens (or one the + * backend marked non-retryable, or a 413) surfaces an inline error with Retry. + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import { Sheet } from "@/components/ui"; +import { + sendChat, + shouldFallBackToJson, + startSession, + startSessionStream, + streamChat, +} from "@/lib/api"; + +// The same renderer the tutor uses, loaded the same way `ChatPanel` loads it: +// `MarkdownChat` statically imports mermaid, katex and highlight.js, and a +// static import here would pull all of that into the quiz route's bundle. +const MarkdownChat = dynamic( + () => import("@/components/chat/MarkdownChat").then(m => m.MarkdownChat), + { ssr: false, loading: () => null }, +); + +/** The tutor mode a "why was I wrong" question wants. */ +const TUTOR_MODE = "socratic"; + +/** Everything the tutor needs to know about the item that was missed. */ +export interface AskSeed { + stem: string; + chosenLabel: string; + chosenText: string; + correctLabel: string; + correctText: string; + explanation: string; +} + +export interface AskPanelProps { + open: boolean; + onClose: () => void; + userId: string; + conceptName: string; + courseId: string | null; + courseLabel?: string; + seed: AskSeed; + /** Focused after the panel closes. `Sheet` restores focus on its own; this + * is the explicit target for a caller whose trigger re-renders (B3's + * missed-list rows), and it runs after Sheet's restore, so it wins. */ + returnFocusTo?: React.RefObject; + testid?: string; +} + +interface AskTurn { + id: number; + role: "user" | "assistant"; + text: string; +} + +/** + * The first message. Exported because it is the actual contract with the + * tutor — a test that pins the wording is pinning what the model is told. + */ +export function composeAskMessage(seed: AskSeed): string { + return [ + "I got this quiz question wrong and want to understand why.", + "", + `Question: ${seed.stem}`, + `I chose ${seed.chosenLabel}: ${seed.chosenText}`, + `The correct answer is ${seed.correctLabel}: ${seed.correctText}`, + `Explanation given: ${seed.explanation}`, + "", + "Help me understand why.", + ].join("\n"); +} + +export function AskPanel({ + open, + onClose, + userId, + conceptName, + courseId, + courseLabel, + seed, + returnFocusTo, + testid = "quiz-ask-panel", +}: AskPanelProps) { + const [turns, setTurns] = useState([]); + const [streaming, setStreaming] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [draft, setDraft] = useState(""); + + // The tutor session, held in a ref: nothing renders off it, and a follow-up + // fired from a stale closure must still reach the session that was opened. + const sessionIdRef = useRef(null); + const abortRef = useRef(null); + // Monotonic run token — a superseded turn's late resolution is dropped. + const runRef = useRef(0); + const idRef = useRef(0); + // What Retry re-sends. + const lastMessageRef = useRef(""); + // The seed the current conversation was opened with. + const lastSeededRef = useRef(null); + + const seedMessage = useMemo(() => composeAskMessage(seed), [seed]); + + const nextId = () => ++idRef.current; + + const runTurn = useCallback( + async (message: string) => { + const token = ++runRef.current; + // A new turn supersedes whatever was in flight; two streams writing into + // the same partial-text state would interleave. + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + lastMessageRef.current = message; + setBusy(true); + setError(null); + setStreaming(""); + let sawToken = false; + + try { + let sid = sessionIdRef.current; + if (!sid) { + try { + const started = await startSessionStream( + userId, + conceptName, + TUTOR_MODE, + true, + courseId ?? undefined, + undefined, + { signal: controller.signal }, + ); + sid = started.session_id ?? null; + } catch (err) { + if (controller.signal.aborted) return; + if (!shouldFallBackToJson(err)) throw err; + // The greeting is thrown away either way, so the JSON route is a + // straight substitute here — there is no partial text to lose. + const started = await startSession( + userId, + conceptName, + TUTOR_MODE, + courseId ?? undefined, + true, + ); + sid = started.session_id; + } + if (!sid) throw new Error("The tutor didn't open a session."); + if (runRef.current !== token) return; + sessionIdRef.current = sid; + } + + let reply: string; + try { + // `graph_update` events are deliberately unhandled: this panel is a + // read of the student's own mistake, not a study turn that should + // move the graph under the quiz. + const res = await streamChat(sid, userId, message, TUTOR_MODE, true, undefined, { + onToken: delta => { + if (delta.trim()) sawToken = true; + setStreaming(prev => (prev ?? "") + delta); + }, + signal: controller.signal, + }); + reply = res.reply || ""; + } catch (err) { + if (controller.signal.aborted) return; + // Tokens already on screen, or a failure the JSON route would repeat + // identically (#151a) — surface it rather than silently re-running. + if (sawToken || !shouldFallBackToJson(err)) throw err; + setStreaming(null); + const res = await sendChat(sid, userId, message, TUTOR_MODE, true); + reply = res.reply || ""; + } + + if (runRef.current !== token) return; + setTurns(t => [...t, { id: nextId(), role: "assistant", text: reply }]); + } catch (err) { + if (controller.signal.aborted || runRef.current !== token) return; + setError(err instanceof Error ? err.message : "The tutor is unavailable."); + } finally { + if (runRef.current === token) { + setBusy(false); + setStreaming(null); + } + } + }, + [userId, conceptName, courseId], + ); + + // Seed on open, and re-seed when the question changes (B3 opens the same + // panel for each missed item). Keyed on the composed message rather than on + // `open`, so closing and reopening the SAME question keeps the conversation. + useEffect(() => { + if (!open) return; + if (lastSeededRef.current === seedMessage) return; + lastSeededRef.current = seedMessage; + sessionIdRef.current = null; + setTurns([]); + setStreaming(null); + setError(null); + setDraft(""); + void runTurn(seedMessage); + }, [open, seedMessage, runTurn]); + + // Only on unmount. A stream is NOT aborted on close: the student can shut + // the panel while the tutor is mid-sentence and find the finished answer + // waiting when they reopen it. + useEffect(() => () => abortRef.current?.abort(), []); + + // Focus returns to whatever opened the panel. `Sheet`'s own restore already + // does this for a trigger that stays mounted; this effect runs after that + // cleanup, so an explicit target wins. + const wasOpenRef = useRef(false); + useEffect(() => { + if (open) { + wasOpenRef.current = true; + return; + } + if (!wasOpenRef.current) return; + wasOpenRef.current = false; + returnFocusTo?.current?.focus(); + }, [open, returnFocusTo]); + + const send = (e: React.FormEvent) => { + e.preventDefault(); + const text = draft.trim(); + if (!text || busy) return; + setDraft(""); + setTurns(t => [...t, { id: nextId(), role: "user", text }]); + void runTurn(text); + }; + + const subtitle = courseLabel ? `${conceptName} · ${courseLabel}` : conceptName; + + return ( + +
+
+

{seed.stem}

+
+ You chose {seed.chosenLabel} · + {seed.chosenText} +
+
+ The answer is {seed.correctLabel} · + {seed.correctText} +
+ {seed.explanation && ( +

{seed.explanation}

+ )} + Asking the tutor about {subtitle}. +
+ +
+ {turns.map(turn => + turn.role === "user" ? ( +

+ {turn.text} +

+ ) : ( +
+ {turn.text} +
+ ), + )} + {streaming !== null && ( +
+ {streaming ? ( + {streaming} + ) : ( +

Thinking…

+ )} +
+ )} + {error && ( +
+ {error} + +
+ )} +
+ +
+ setDraft(e.target.value)} + placeholder="Ask a follow-up…" + aria-label="Ask a follow-up" + data-testid="quiz-ask-input" + /> + +
+
+
+ ); +} diff --git a/frontend/src/components/quiz/question/LeaveDialog.tsx b/frontend/src/components/quiz/question/LeaveDialog.tsx new file mode 100644 index 00000000..ef929829 --- /dev/null +++ b/frontend/src/components/quiz/question/LeaveDialog.tsx @@ -0,0 +1,65 @@ +"use client"; + +/** + * The one door out of a live attempt (§5 B2). + * + * Nothing else in the question screen navigates: the machine refuses `EXIT` + * from `active`/`answered` (invariant 1), so leaving has to come through here + * and land on `paused`. The dialog exists to make that deliberate — and to say + * the true thing about it, which is that the answers already given are safe. + * + * "Keep going" is the primary and takes focus: the safe choice is the default + * one, and Escape / the backdrop / the close button all resolve to it. + */ + +import React, { useRef } from "react"; +import Dialog from "@/components/Dialog"; + +export interface LeaveDialogProps { + open: boolean; + /** Escape, the backdrop, the close button and "Keep going" all land here. */ + onCancel: () => void; + onConfirm: () => void; +} + +export function LeaveDialog({ open, onCancel, onConfirm }: LeaveDialogProps) { + // Without this the overlay focuses the first focusable node in the panel, + // which is Dialog's own close button. The safe action should be the one + // under the student's fingers. + const keepGoingRef = useRef(null); + + return ( + +
+

+ Your answers so far are saved. You can pick it up again from Quiz home. +

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/quiz/question/QuizQuestion.test.tsx b/frontend/src/components/quiz/question/QuizQuestion.test.tsx new file mode 100644 index 00000000..68bb6c5c --- /dev/null +++ b/frontend/src/components/quiz/question/QuizQuestion.test.tsx @@ -0,0 +1,558 @@ +// @vitest-environment jsdom +/** + * What this screen has to get right, in the order it matters (§5 B2): + * + * 1. the attempt is never orphaned — "Ask about this" opens OVER the question + * and closing it leaves the question DOM byte-identical, + * 2. the keyboard map works without a mouse, + * 3. the verdict is announced, not just painted, + * 4. nothing moves between states. + */ + +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor, act } from "@testing-library/react"; +import { __resetScrollLocksForTests } from "@/lib/useScrollLock"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { QuizItem, QuizSession, WireQuestion } from "@/lib/quiz/types"; +import { QuizQuestion, type QuizConceptSummary } from "./QuizQuestion"; + +const toastApi = vi.hoisted(() => ({ + show: vi.fn(), + dismiss: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), +})); +vi.mock("@/components/ToastProvider", () => ({ useToast: () => toastApi })); + +const api = vi.hoisted(() => ({ + startSessionStream: vi.fn(), + startSession: vi.fn(), + streamChat: vi.fn(), + sendChat: vi.fn(), + shouldFallBackToJson: vi.fn(() => true), +})); +vi.mock("@/lib/api", () => api); + +// MarkdownChat is lazy-loaded (mermaid + katex + highlight.js); render its +// text so assertions see the reply synchronously. +vi.mock("next/dynamic", () => ({ + default: () => + function MarkdownStub({ children }: { children: React.ReactNode }) { + return
{children}
; + }, +})); + +// ── fixtures ────────────────────────────────────────────────────────────── + +const CONCEPT: QuizConceptSummary = { + id: "node-recursion", + name: "Recursion", + courseCode: "CS101", + color: "#7b4b99", + tier: "struggling", + mastery: 0.29, +}; + +function question(id: number): WireQuestion { + return { + id, + question: `What is the purpose of a base case in a recursive function? (${id})`, + options: [ + { label: "A", text: "It makes the function run faster by caching results" }, + { label: "B", text: "It stops the recursion by returning without another call" }, + { label: "C", text: "It increases the recursion depth available" }, + { label: "D", text: "It converts the recursion into a loop at compile time" }, + ], + difficulty: "medium", + }; +} + +function item(index: number, over: Partial = {}): QuizItem { + return { + index, + question: question(index + 1), + selectedIndex: null, + verdict: null, + flagged: false, + ...over, + }; +} + +function makeSession(over: Partial = {}): QuizSession { + return { + intent: "practice", + scope: { kind: "concept", conceptId: CONCEPT.id }, + source: { kind: "nav" }, + config: { count: 3, difficulty: "medium", feedback: "as-you-go" }, + conceptId: CONCEPT.id, + courseId: "course-cs101", + attemptId: "attempt-1", + items: [item(0), item(1), item(2)], + cursor: 0, + queueIndex: 0, + phase: "active", + error: null, + result: null, + xp: null, + deliveredShort: false, + ...over, + }; +} + +function makeActions(): QuizActions { + return { + configure: vi.fn(), + setConfig: vi.fn(), + start: vi.fn(), + select: vi.fn(), + submitAnswer: vi.fn(), + next: vi.fn(), + finish: vi.fn(), + requestLeave: vi.fn(), + cancelLeave: vi.fn(), + confirmLeave: vi.fn(), + resume: vi.fn(), + practiseMissed: vi.fn(), + nextInQueue: vi.fn(), + exit: vi.fn(), + flag: vi.fn(), + dismissError: vi.fn(), + retry: vi.fn(), + }; +} + +function renderScreen(session: QuizSession, actions: QuizActions = makeActions()) { + const view = render( + , + ); + return { ...view, actions }; +} + +const root = () => screen.getByTestId("quiz-panel"); + +beforeEach(() => { + vi.clearAllMocks(); + api.shouldFallBackToJson.mockReturnValue(true); + api.startSessionStream.mockResolvedValue({ session_id: "tutor-1", reply: "Hello." }); + api.streamChat.mockResolvedValue({ reply: "Because the base case is the exit." }); +}); + +afterEach(() => { + cleanup(); + __resetScrollLocksForTests(); +}); + +// ── the screen ──────────────────────────────────────────────────────────── + +describe("QuizQuestion — the question", () => { + it("renders the stem, the options and the progress rail", () => { + renderScreen(makeSession({ cursor: 1, items: [item(0, { verdict: { isCorrect: true, correctIndex: 1, explanation: "" }, selectedIndex: 1 }), item(1), item(2)] })); + + expect(screen.getByText(/purpose of a base case/)).toBeInTheDocument(); + expect(screen.getByTestId("quiz-answer-options")).toHaveAttribute("role", "radiogroup"); + for (const label of ["A", "B", "C", "D"]) { + expect(screen.getByTestId(`quiz-answer-option-${label}`)).toBeInTheDocument(); + } + expect(screen.getByTestId("quiz-progress")).toHaveAttribute("aria-label", "Question 2 of 3"); + expect(screen.getByText("Recursion · CS101")).toBeInTheDocument(); + expect(screen.getByText("MEDIUM")).toBeInTheDocument(); + }); + + it("gives the radiogroup exactly one tab stop — the selection, else the first row", () => { + const { rerender } = renderScreen(makeSession()); + expect(screen.getByTestId("quiz-answer-option-A")).toHaveAttribute("tabindex", "0"); + expect(screen.getByTestId("quiz-answer-option-C")).toHaveAttribute("tabindex", "-1"); + + rerender( + , + ); + expect(screen.getByTestId("quiz-answer-option-A")).toHaveAttribute("tabindex", "-1"); + expect(screen.getByTestId("quiz-answer-option-C")).toHaveAttribute("tabindex", "0"); + }); + + it("reserves the mark slot in every state, so revealing a verdict reflows nothing", () => { + const { container, rerender } = renderScreen(makeSession()); + const marks = () => container.querySelectorAll(".answer-option__mark"); + + expect(marks()).toHaveLength(4); + marks().forEach(mark => { + expect(mark).toHaveAttribute("aria-hidden", "true"); + expect(mark.textContent).toBe(""); + }); + + rerender( + , + ); + // Same four slots, still every one of them present. + expect(marks()).toHaveLength(4); + marks().forEach(mark => expect(mark).toHaveAttribute("aria-hidden", "true")); + }); +}); + +describe("QuizQuestion — keyboard", () => { + it("selects with the letter keys and the number keys", () => { + const { actions } = renderScreen(makeSession()); + + fireEvent.keyDown(root(), { key: "c" }); + expect(actions.select).toHaveBeenCalledWith(2); + + fireEvent.keyDown(root(), { key: "2" }); + expect(actions.select).toHaveBeenCalledWith(1); + + // Past the end of the option list is not a selection. + actions.select = vi.fn(); + fireEvent.keyDown(root(), { key: "f" }); + expect(actions.select).not.toHaveBeenCalled(); + }); + + it("moves the selection with the arrow keys, wrapping", () => { + const { actions } = renderScreen(makeSession({ items: [item(0, { selectedIndex: 3 }), item(1), item(2)] })); + + fireEvent.keyDown(screen.getByTestId("quiz-answer-options"), { key: "ArrowDown" }); + expect(actions.select).toHaveBeenCalledWith(0); + + fireEvent.keyDown(screen.getByTestId("quiz-answer-options"), { key: "ArrowUp" }); + expect(actions.select).toHaveBeenCalledWith(2); + }); + + it("Enter submits while active and advances once the verdict is showing", () => { + const { actions, rerender } = renderScreen( + makeSession({ items: [item(0, { selectedIndex: 1 }), item(1), item(2)] }), + ); + + fireEvent.keyDown(root(), { key: "Enter" }); + expect(actions.submitAnswer).toHaveBeenCalledTimes(1); + + const answered = makeActions(); + rerender( + , + ); + fireEvent.keyDown(root(), { key: "Enter" }); + expect(answered.next).toHaveBeenCalledTimes(1); + }); + + it("Enter does nothing while no answer is chosen", () => { + const { actions } = renderScreen(makeSession()); + fireEvent.keyDown(root(), { key: "Enter" }); + expect(actions.submitAnswer).not.toHaveBeenCalled(); + }); + + it("Escape asks to leave, and the dialog's Leave confirms it", () => { + const { actions, rerender } = renderScreen(makeSession()); + + fireEvent.keyDown(root(), { key: "Escape" }); + expect(actions.requestLeave).toHaveBeenCalledTimes(1); + + const leaving = makeActions(); + rerender( + , + ); + expect(screen.getByTestId("quiz-leave-dialog")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("quiz-leave-confirm")); + expect(leaving.confirmLeave).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTestId("quiz-leave-cancel")); + expect(leaving.cancelLeave).toHaveBeenCalledTimes(1); + }); +}); + +describe("QuizQuestion — the verdict", () => { + const answeredSession = () => + makeSession({ + phase: "answered", + items: [ + item(0, { + selectedIndex: 2, + verdict: { isCorrect: false, correctIndex: 1, explanation: "The base case ends it." }, + }), + item(1), + item(2), + ], + }); + + it("marks correct / chosen-wrong / muted and moves focus to the feedback line", () => { + renderScreen(answeredSession()); + + expect(screen.getByTestId("quiz-answer-option-B").className).toContain("answer-option--correct"); + expect(screen.getByTestId("quiz-answer-option-C").className).toContain( + "answer-option--chosen-wrong", + ); + expect(screen.getByTestId("quiz-answer-option-A").className).toContain("answer-option--muted"); + expect(screen.getByTestId("quiz-answer-option-D").className).toContain("answer-option--muted"); + + const feedback = screen.getByTestId("quiz-review-verdict"); + expect(feedback).toHaveTextContent("Not quite — the answer is B."); + expect(feedback).toHaveTextContent("The base case ends it."); + expect(document.activeElement).toBe(feedback); + }); + + it("never reveals anything in at-end mode", () => { + renderScreen( + makeSession({ + config: { count: 3, difficulty: "medium", feedback: "at-end" }, + items: [ + item(0, { + selectedIndex: 2, + verdict: { isCorrect: false, correctIndex: 1, explanation: "The base case ends it." }, + }), + item(1), + item(2), + ], + }), + ); + expect(screen.getByTestId("quiz-review-verdict")).toHaveTextContent(""); + expect(screen.getByTestId("quiz-answer-option-B").className).not.toContain( + "answer-option--correct", + ); + }); + + it("switches the one footer button's label and testid in place", () => { + const { rerender } = renderScreen(makeSession()); + expect(screen.getByTestId("quiz-submit-answer")).toHaveTextContent("Submit"); + expect(screen.getByTestId("quiz-submit-answer")).toBeDisabled(); + expect(screen.queryByTestId("quiz-next")).toBeNull(); + + const props = (session: QuizSession) => ( + + ); + + rerender(props(makeSession({ items: [item(0, { selectedIndex: 1 }), item(1), item(2)] }))); + expect(screen.getByTestId("quiz-submit-answer")).toBeEnabled(); + + rerender(props(answeredSession())); + expect(screen.queryByTestId("quiz-submit-answer")).toBeNull(); + expect(screen.getByTestId("quiz-next")).toHaveTextContent("Next"); + + rerender( + props( + makeSession({ + phase: "answered", + cursor: 2, + items: [ + item(0), + item(1), + item(2, { + selectedIndex: 0, + verdict: { isCorrect: true, correctIndex: 0, explanation: "" }, + }), + ], + }), + ), + ); + expect(screen.getByTestId("quiz-next")).toHaveTextContent("See results"); + + rerender(props(makeSession({ phase: "submitting" }))); + expect(screen.getByTestId("quiz-submit-answer")).toHaveTextContent("Scoring…"); + expect(screen.getByTestId("quiz-submit-answer")).toBeDisabled(); + }); +}); + +describe("QuizQuestion — flag and generating", () => { + it("flags with a toast and reflects the flag in aria-pressed", () => { + const { actions, rerender } = renderScreen(makeSession()); + const flag = screen.getByTestId("quiz-flag"); + expect(flag).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(flag); + expect(actions.flag).toHaveBeenCalledTimes(1); + expect(toastApi.show).toHaveBeenCalledWith("Noted — thanks."); + + rerender( + , + ); + expect(screen.getByTestId("quiz-flag")).toHaveAttribute("aria-pressed", "true"); + }); + + it("shows the skeleton while the quiz is being written, and says so", () => { + const { container } = renderScreen( + makeSession({ phase: "generating", items: [], attemptId: null }), + ); + expect(screen.getByTestId("quiz-generating")).toBeInTheDocument(); + expect(screen.getByText("Writing your quiz…")).toBeInTheDocument(); + expect(container.querySelectorAll(".quiz-question__skeleton-row")).toHaveLength(4); + expect(screen.queryByTestId("quiz-answer-options")).toBeNull(); + }); + + it("says so once when fewer questions arrived than were asked for", () => { + renderScreen(makeSession({ deliveredShort: true, items: [item(0), item(1)] })); + expect(toastApi.show).toHaveBeenCalledWith( + "Only 2 questions were ready for this concept.", + ); + expect(toastApi.show).toHaveBeenCalledTimes(1); + }); +}); + +// ── the whole point ─────────────────────────────────────────────────────── + +describe("QuizQuestion — Ask about this never orphans the attempt", () => { + const answeredSession = () => + makeSession({ + phase: "answered", + items: [ + item(0, { + selectedIndex: 2, + verdict: { isCorrect: false, correctIndex: 1, explanation: "The base case ends it." }, + }), + item(1), + item(2), + ], + }); + + it("is offered only once there is a verdict", () => { + const { rerender } = renderScreen(makeSession()); + expect(screen.queryByTestId("quiz-ask")).toBeNull(); + + rerender( + , + ); + expect(screen.getByTestId("quiz-ask")).toBeInTheDocument(); + }); + + it("opens the tutor over the question, seeds it, and leaves the question untouched", async () => { + const { container, actions } = renderScreen(answeredSession()); + const optionsBefore = screen.getByTestId("quiz-answer-options").innerHTML; + const stemBefore = container.querySelector(".quiz-question__stem")!.innerHTML; + + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask")); + }); + + const panel = await screen.findByTestId("quiz-ask-panel"); + expect(panel).toHaveAttribute("aria-modal", "true"); + + // Two-call seeding (R1 §F): the session is opened on the concept name with + // the course id, then the context arrives as the first message. + await waitFor(() => expect(api.startSessionStream).toHaveBeenCalledTimes(1)); + expect(api.startSessionStream.mock.calls[0].slice(0, 5)).toEqual([ + "user-1", + "Recursion", + "socratic", + true, + "course-cs101", + ]); + + await waitFor(() => expect(api.streamChat).toHaveBeenCalledTimes(1)); + const seedMessage = api.streamChat.mock.calls[0][2] as string; + expect(seedMessage).toContain("I got this quiz question wrong and want to understand why."); + expect(seedMessage).toContain("Question: What is the purpose of a base case"); + expect(seedMessage).toContain("I chose C: It increases the recursion depth available"); + expect(seedMessage).toContain( + "The correct answer is B: It stops the recursion by returning without another call", + ); + expect(seedMessage).toContain("Explanation given: The base case ends it."); + expect(seedMessage).toContain("Help me understand why."); + + // The seeded context is on screen, and so is the reply. + expect(screen.getByTestId("quiz-ask-seed")).toBeInTheDocument(); + await waitFor(() => + expect(panel).toHaveTextContent("Because the base case is the exit."), + ); + + // Nothing about the attempt moved. + expect(actions.requestLeave).not.toHaveBeenCalled(); + expect(actions.next).not.toHaveBeenCalled(); + expect(screen.getByTestId("quiz-answer-options").innerHTML).toBe(optionsBefore); + + // Closing puts the student back exactly where they were. + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask-panel-close")); + }); + expect(screen.queryByTestId("quiz-ask-panel")).toBeNull(); + expect(screen.getByTestId("quiz-answer-options").innerHTML).toBe(optionsBefore); + expect(container.querySelector(".quiz-question__stem")!.innerHTML).toBe(stemBefore); + expect(document.activeElement).toBe(screen.getByTestId("quiz-ask")); + }); + + it("ignores the answer shortcuts while the panel has the keyboard", async () => { + const { actions } = renderScreen(answeredSession()); + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask")); + }); + await screen.findByTestId("quiz-ask-panel"); + + fireEvent.keyDown(screen.getByTestId("quiz-ask-input"), { key: "a" }); + fireEvent.keyDown(screen.getByTestId("quiz-ask-input"), { key: "Escape" }); + expect(actions.select).not.toHaveBeenCalled(); + expect(actions.requestLeave).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/quiz/question/QuizQuestion.tsx b/frontend/src/components/quiz/question/QuizQuestion.tsx index 91298e28..464e08e4 100644 --- a/frontend/src/components/quiz/question/QuizQuestion.tsx +++ b/frontend/src/components/quiz/question/QuizQuestion.tsx @@ -1,21 +1,37 @@ "use client"; /** - * STUB — Wave 3 (B2) replaces the body. The PROPS are the seam and must not - * change. + * The question screen (§5 B2) — everything between "we have a quiz" and "we + * have a score": `generating`, `active`, `answered`, `confirm-leave`, + * `submitting`. * - * Enough of the flow is wired to drive the machine by hand: pick an option, - * Submit, then Next / See results, and Leave with its confirmation. That is the - * whole loop the data layer has to survive. + * Two things it is built around. * - * The real screen is §5 B2: the progress rail, the concept header, the stem, - * the `AnswerOption` radiogroup, the feedback line, flag, "Ask about this", the - * leave dialog, the AskPanel sheet and the keyboard map. + * NOTHING ORPHANS THE ATTEMPT. There is exactly one exit — the leave dialog — + * and it goes through `CONFIRM_LEAVE`, which persists the session before it + * navigates. "Ask about this" opens the tutor in a `Sheet` ON TOP of the + * question rather than navigating to `/learn`, which is what the old screen + * did and what silently threw away every answer given so far. Closing the + * sheet leaves the question exactly as it was, down to the focus ring. + * + * NOTHING MOVES WHEN A STATE CHANGES. The stem reserves its height, the + * verdict line reserves its height, every answer row reserves the ✓/✕ slot and + * the 2px selection bar, the flag link is always rendered (R-11), and the + * footer's primary button is ONE element whose label and testid swap in place + * (Submit → Next → See results → Scoring…). A quiz that jumps under the cursor + * between "choose" and "submit" is how you mis-click an answer. */ -import React from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AnswerOption, Badge, Button, ProgressDots, type AnswerState } from "@/components/ui"; +import { ConceptNode } from "@/components/graph/ConceptNode"; +import { Skeleton } from "@/components/Skeleton"; +import { useToast } from "@/components/ToastProvider"; import type { QuizActions } from "@/lib/quiz/useQuizSession"; import type { QuizConfig, QuizSession } from "@/lib/quiz/types"; +import { AskPanel, type AskSeed } from "./AskPanel"; +import { LeaveDialog } from "./LeaveDialog"; +import "./question.css"; export interface QuizConceptSummary { id: string; @@ -35,106 +51,432 @@ export interface QuizQuestionProps { courseId: string | null; } -export function QuizQuestion({ session, actions, concept }: QuizQuestionProps) { - const item = session.items[session.cursor]; - const total = session.items.length; - const isLast = session.cursor >= total - 1; - const revealed = session.phase === "answered"; +/** The header mark, at the size §3 pins for a `dot`. */ +const HEADER_DOT = 15; +/** Placeholder row widths while the quiz is written — four is the modal count. */ +const SKELETON_ROWS = [72, 86, 64, 79]; +/** `A`…`F` — the answer shortcuts. Six covers every count `/config` offers and + * degrades harmlessly on a question with fewer options. */ +const SHORTCUT_LETTERS = "abcdef"; +const SHORTCUT_DIGITS = "123456"; + +/** Which option a keypress means, or null if the key isn't a shortcut. */ +export function shortcutIndex(key: string): number | null { + if (key.length !== 1) return null; + const letter = SHORTCUT_LETTERS.indexOf(key.toLowerCase()); + if (letter >= 0) return letter; + const digit = SHORTCUT_DIGITS.indexOf(key); + return digit >= 0 ? digit : null; +} + +/** Keystrokes belong to a field, not to the quiz, when one is focused. React + * portals bubble events through the React TREE, so the AskPanel's composer + * sits "inside" this screen as far as the handler is concerned. */ +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + +export function QuizQuestion({ session, actions, concept, userId, courseId }: QuizQuestionProps) { + const toast = useToast(); + + const rootRef = useRef(null); + const optionsRef = useRef(null); + const feedbackRef = useRef(null); + const askRef = useRef(null); + const shortToastRef = useRef(null); + + /** + * The tutor sheet, remembered per QUESTION rather than as a bare boolean: a + * new item derives it closed, because the panel is seeded from one verdict + * and carrying it across would be answering the wrong question. + */ + const [askForCursor, setAskForCursor] = useState(null); + /** + * The item whose `/answer` call is in flight, as a key that changes whenever + * the machine moves. + * + * `useQuizSession` exposes exactly this as `pending`, but `QuizQuestionProps` + * (the A2 seam) doesn't carry it and `QuizScreen` doesn't pass it, so the + * screen keeps its own latch rather than widening the seam unilaterally. + * The phase can't stand in: it stays `active` for the whole round trip, so + * without this the Submit button is live again the instant it is pressed. + * + * Stored as the key rather than a boolean cleared in an effect, so "the call + * landed" is DERIVED from the session that came back — no reset render, and + * no way for the latch to get stuck if a transition is missed. + */ + const [busyKey, setBusyKey] = useState(null); + + const { phase, cursor, items } = session; + const item = items[cursor]; + const total = items.length; + const options = item?.question.options ?? []; + const selectedIndex = item?.selectedIndex ?? null; + const answeredCount = items.filter(i => i.verdict !== null).length; + const isLast = cursor >= total - 1; + // R-2: every answer is recorded server-side either way; the feedback mode + // only decides whether the verdict is ever shown. + const showVerdict = session.config.feedback === "as-you-go" && item?.verdict != null; + + // Both derived, so a session that moved is the only thing that clears them. + const answerKey = `${session.attemptId}:${cursor}:${phase}:${item?.verdict ? "scored" : "open"}`; + const busy = busyKey === answerKey; + const askOpen = askForCursor === cursor; + const selectable = phase === "active" && !busy; + + // A blank accent would reach `shadeFor` as an unparseable colour and pass + // straight through; the CSS variable degrades to the app accent instead. + const accent = concept.color || "var(--quiz-accent, var(--accent))"; + const difficulty = (item?.question.difficulty ?? session.config.difficulty ?? "").toUpperCase(); + const railTotal = total || session.config.count; + + // ── Effects ────────────────────────────────────────────────────────── + + // The keyboard map is attached to the screen root, so the root has to hold + // focus for it to hear anything before the student clicks. Moving focus onto + // each new question is also the right announcement. + useEffect(() => { + if (phase !== "active") return; + rootRef.current?.focus(); + }, [phase, cursor]); + + // ...and when the verdict lands, focus moves to the line that carries it, so + // a screen reader hears the result. Nothing else on the screen moves. + useEffect(() => { + if (!showVerdict) return; + feedbackRef.current?.focus(); + }, [showVerdict, cursor]); + + // One toast per attempt, on arrival (§5 B2). + useEffect(() => { + const attemptId = session.attemptId; + if (!session.deliveredShort || !attemptId || total === 0) return; + if (shortToastRef.current === attemptId) return; + shortToastRef.current = attemptId; + toast.show(`Only ${total} questions were ready for this concept.`); + }, [session.deliveredShort, session.attemptId, total, toast]); + + // ── Actions ────────────────────────────────────────────────────────── + + const submit = useCallback(() => { + if (phase !== "active" || busy || selectedIndex === null) return; + setBusyKey(answerKey); + actions.submitAnswer(); + }, [actions, answerKey, busy, phase, selectedIndex]); + + const flag = () => { + const wasFlagged = item?.flagged ?? false; + actions.flag(); + // Un-flagging is a correction, not a report — thanking for it reads as a + // bug. Only raising the flag says anything. + if (!wasFlagged) toast.show("Noted — thanks."); + }; + + const focusOption = (index: number) => { + optionsRef.current?.querySelectorAll('[role="radio"]')[index]?.focus(); + }; + + /** The single footer button: one element, three jobs. */ + const primary = useMemo(() => { + if (phase === "submitting") { + return { + label: "Scoring…", + // The element keeps the identity of the press that started the + // scoring: Next/See results in as-you-go, Submit on the last at-end item. + testid: showVerdict ? "quiz-next" : "quiz-submit-answer", + enabled: false, + activate: () => {}, + }; + } + if (showVerdict) { + return { + label: isLast ? "See results" : "Next", + testid: "quiz-next", + enabled: phase === "answered", + activate: () => actions.next(), + }; + } + return { + label: "Submit", + testid: "quiz-submit-answer", + enabled: phase === "active" && !busy && selectedIndex !== null, + activate: submit, + }; + }, [actions, busy, isLast, phase, selectedIndex, showVerdict, submit]); + + // ── Keyboard (§5 B2) ───────────────────────────────────────────────── + + const onRootKeyDown = (e: React.KeyboardEvent) => { + // The sheet and the dialog own the keyboard while they are open — and both + // are portals, whose events still bubble through the React tree to here. + if (askOpen || phase === "confirm-leave") return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (isTypingTarget(e.target)) return; + + if (e.key === "Escape") { + if (phase !== "active" && phase !== "answered") return; + e.preventDefault(); + actions.requestLeave(); + return; + } + + if (e.key === "Enter") { + // Enter is the footer's action everywhere on this screen, including on + // an answer row — `AnswerOption` is a - ))} -
+
+ {rail} + +
+ {header} -

- {revealed && item?.verdict - ? `${item.verdict.isCorrect ? "Correct." : "Not quite."} ${item.verdict.explanation}` - : ""} -

- -
- - - {revealed ? ( - - ) : ( - - )} -
+ {options.map((option, index) => ( + actions.select(index)} + // The group owns one tab stop: the chosen row, or the first row + // while nothing is chosen. + tabIndex={index === (selectedIndex ?? 0) ? 0 : -1} + testid={`quiz-answer-option-${option.label}`} + /> + ))} +
- {session.phase === "confirm-leave" && ( -
-

Leave this quiz? Your answers so far are saved.

- - + {phase === "answered" && ( + // A raw + )} +
+ +
+ +
+ + +
- )} +
+ +
+ + actions.cancelLeave()} + onConfirm={() => actions.confirmLeave()} + /> + + setAskForCursor(null)} + userId={userId} + conceptName={concept.name} + courseId={courseId} + courseLabel={concept.courseCode || undefined} + seed={askSeed} + returnFocusTo={askRef} + />
); } diff --git a/frontend/src/components/quiz/question/question.css b/frontend/src/components/quiz/question/question.css new file mode 100644 index 00000000..87115170 --- /dev/null +++ b/frontend/src/components/quiz/question/question.css @@ -0,0 +1,345 @@ +/* The question screen (§5 B2) — the one place a student is mid-attempt. + * + * Every rule here is a class over tokens (R-1). The design's own geometry + * constants are declared ONCE in the block below and referenced everywhere + * else, so no rule carries a bare measurement that a retune would have to + * hunt for. Values come from `` in the prototype. + * + * The course accent arrives as `--quiz-accent`, bound on the screen root by + * `QuizScreen`; everything reads it through `var(--quiz-accent, var(--accent))` + * so an unset accent degrades to the app's own. + */ + +.quiz-question { + /* ── Geometry the design pins ─────────────────────────────────────── */ + --quiz-q-rail-top: 8px; /* the rail's optical alignment with the header */ + --quiz-q-stem-fs: 24px; /* the --fs-* ramp has no 24px step */ + --quiz-q-stem-mw: 560px; /* the reading measure of the stem */ + --quiz-q-stem-my: 48px; /* the air above and below it */ + --quiz-q-stem-mh: 72px; /* reserved, so a one-line stem doesn't jump */ + --quiz-q-feedback-mh: 20px; /* reserved for the verdict line */ + --quiz-q-feedback-mt: 18px; + --quiz-q-aside-mt: 10px; + --quiz-q-skeleton-row: 55px; /* one `AnswerOption` row, while generating */ + --quiz-q-skeleton-stem: 26px; + --quiz-q-skeleton-line: 13px; + + display: flex; + align-items: stretch; + /* The footer sits at the bottom of the viewport, not under the stem: the + column below grows into whatever height is going. */ + min-height: 100%; + /* The root is a programmatic focus target for the keyboard map (it is + `tabIndex={-1}`, never in the tab order), so it must not paint a ring. */ + outline: none; +} + +/* The ONE rule here that names a class from the shared `quiz.css`. + * + * Every other screen's wrapper is exactly its content column. This one is + * FLANKED — a 64px progress rail on the left, a 64px spacer on the right — so + * for the reading column itself to come out at `--quiz-col-question` the + * wrapper has to be that much wider, and it has to stretch to full height for + * the footer to reach the bottom. Scoped with `:has()` to a wrapper that + * actually holds this screen, so the error card (which shares the `question` + * layout) and every other screen are untouched. Where `:has()` is unsupported + * the layout degrades to a narrower reading column and a footer that follows + * the content — never to a broken one. */ +.quiz-col--question:has(.quiz-question) { + max-width: calc(var(--quiz-col-question) + 2 * var(--quiz-rail)); + align-self: stretch; +} + +.quiz-question__rail { + width: var(--quiz-rail); + flex-shrink: 0; + display: flex; + justify-content: center; + padding-top: var(--quiz-q-rail-top); +} + +/* Mirrored on the right so the reading column stays optically centred. */ +.quiz-question__spacer { + width: var(--quiz-rail); + flex-shrink: 0; +} + +.quiz-question__col { + flex: 1; + min-width: 0; + max-width: var(--quiz-col-question); + margin: 0 auto; + display: flex; + flex-direction: column; +} + +/* ── Header ─────────────────────────────────────────────────────────── */ + +.quiz-question__header { + display: flex; + align-items: center; + gap: var(--pad-sm); +} + +.quiz-question__concept { + font-size: var(--fs-md); + color: var(--text-dim); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.quiz-question__header-gap { + flex: 1; +} + +/* ── Stem ───────────────────────────────────────────────────────────── */ + +.quiz-question__stem { + font-size: var(--quiz-q-stem-fs); + line-height: 1.5; + color: var(--text); + margin: var(--quiz-q-stem-my) 0; + max-width: var(--quiz-q-stem-mw); + min-height: var(--quiz-q-stem-mh); + text-wrap: pretty; +} + +/* ── Options ────────────────────────────────────────────────────────── */ + +/* The rows bring their own bottom rule; the group supplies the top one. */ +.quiz-question__options { + border-top: 1px solid var(--border); +} + +/* ── Feedback ───────────────────────────────────────────────────────── */ + +/* Height is reserved whether or not there is a verdict, so revealing one + moves nothing above it. It takes focus when the verdict lands (so a screen + reader hears it), which is why it must not paint a ring of its own. */ +.quiz-question__feedback { + min-height: var(--quiz-q-feedback-mh); + margin-top: var(--quiz-q-feedback-mt); + font-size: var(--fs-md); + color: var(--text-dim); + outline: none; +} + +.quiz-question__explanation { + margin: var(--pad-sm) 0 0; + font-size: var(--fs-md); + color: var(--text-muted); + max-width: var(--quiz-q-stem-mw); +} + +/* ── Aside: flag, then Ask ──────────────────────────────────────────── */ + +/* The flag comes FIRST so that "Ask about this" appearing with the verdict + doesn't push it sideways — the flag is always rendered (R-11), Ask only + once there is something to ask about. */ +.quiz-question__aside { + display: flex; + align-items: center; + gap: var(--pad-md); + margin-top: var(--quiz-q-aside-mt); +} + +.quiz-question__flag { + font-size: var(--fs-sm); +} + +/* ── Footer ─────────────────────────────────────────────────────────── */ + +/* The one flexible row: it eats the leftover height so Leave/Submit sit at + the bottom of the screen rather than under the last option. */ +.quiz-question__grow { + flex: 1; + min-height: var(--pad-xl); +} + +.quiz-question__footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--pad-sm); +} + +/* ── Keyboard hint ──────────────────────────────────────────────────── */ + +/* The canonical visually-hidden recipe. The 1px here is not a design + measurement — it is what keeps the node in the accessibility tree while + painting nothing. */ +.quiz-question__hint { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +/* ── Generating ─────────────────────────────────────────────────────── */ + +.quiz-question__generating-copy { + margin: var(--pad-md) 0 0; + color: var(--text-muted); +} + +/* The stem's placeholder keeps the real stem's box, so the skeleton and the + question that replaces it occupy the same space. */ +.quiz-question__stem--skeleton { + display: flex; + flex-direction: column; + gap: var(--pad-sm); + justify-content: center; +} + +.quiz-question__skeleton-options { + border-top: 1px solid var(--border); +} + +.quiz-question__skeleton-row { + display: flex; + align-items: center; + height: var(--quiz-q-skeleton-row); + padding: 0 var(--pad-md) 0 14px; + border-bottom: 1px solid var(--border); +} + +/* ── Leave dialog ───────────────────────────────────────────────────── */ + +.quiz-leave-dialog__body { + margin: 0 0 var(--pad-lg); + font-size: var(--fs-md); + color: var(--text-dim); +} + +.quiz-leave-dialog__actions { + display: flex; + justify-content: flex-end; + gap: var(--pad-sm); +} + +/* ── Ask panel ──────────────────────────────────────────────────────── */ + +.quiz-ask { + display: flex; + flex-direction: column; + gap: var(--pad-md); + min-height: 100%; +} + +/* The seeded context, restated as static cards so the student can see exactly + what the tutor was told — nothing here is a message they can edit. */ +.quiz-ask__seed { + display: flex; + flex-direction: column; + gap: var(--pad-sm); + padding-bottom: var(--pad-md); + border-bottom: 1px solid var(--border); +} + +.quiz-ask__seed-stem { + font-size: var(--fs-lg); + margin: 0; + color: var(--text); + text-wrap: pretty; +} + +.quiz-ask__seed-card { + padding: var(--pad-sm) var(--pad-md); + border-radius: var(--r-md); + background: var(--bg-subtle); + border-left: var(--answer-bar-w) solid var(--border-strong); + font-size: var(--fs-md); + color: var(--text-dim); +} + +.quiz-ask__seed-card--chosen { + border-left-color: var(--state-struggle); +} + +.quiz-ask__seed-card--correct { + border-left-color: var(--quiz-accent, var(--accent)); +} + +.quiz-ask__seed-label { + color: var(--text-muted); + margin-right: 6px; +} + +.quiz-ask__seed-explanation { + margin: 0; + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-ask__thread { + display: flex; + flex-direction: column; + gap: var(--pad-md); + flex: 1; + min-height: 0; +} + +.quiz-ask__turn--user { + align-self: flex-end; + max-width: 90%; + padding: var(--pad-sm) var(--pad-md); + border-radius: var(--r-md); + background: var(--bg-soft); + font-size: var(--fs-md); + color: var(--text); +} + +.quiz-ask__turn--assistant { + font-size: var(--fs-md); + color: var(--text); +} + +.quiz-ask__pending { + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-ask__error { + display: flex; + align-items: center; + gap: var(--pad-sm); + flex-wrap: wrap; + padding: var(--pad-sm) var(--pad-md); + border-radius: var(--r-md); + border: 1px solid var(--border); + border-left: var(--answer-bar-w) solid var(--state-struggle); + background: var(--bg-panel); + font-size: var(--fs-md); + color: var(--text-dim); +} + +.quiz-ask__composer { + display: flex; + align-items: center; + gap: var(--pad-sm); + padding-top: var(--pad-md); + border-top: 1px solid var(--border); +} + +.quiz-ask__input { + flex: 1; + min-width: 0; + padding: 8px var(--pad-md); + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--bg-panel); + color: var(--text); + font-family: var(--font-sans); + font-size: var(--fs-md); +} + +.quiz-ask__input:focus { + border-color: var(--border-strong); +} From 4cb1c8eff7f15b7f4608c08c38c3916c885e204a Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:10:37 -0400 Subject: [PATCH 33/60] =?UTF-8?q?feat(quiz):=20quiz=20home=20=E2=80=94=20t?= =?UTF-8?q?he=20proposal,=20the=20alternatives,=20the=20pick=20list,=20the?= =?UTF-8?q?=20dialogs=20(#537=20B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the stub with §5 B1 in full: the resume strip, the "Ready for you" proposal with its neighbourhood, two alternatives, the review-everything-due row, the grouped pick list, the Concept and Adjust dialogs, the empty/loading/ error states, and Cancel. - Ranking, meta and rationale come from `lib/quiz/proposals` (the cited `get_recommendations` mirror); counts and difficulties come off `/config` — nothing enumerates either. - Three card shapes chosen by arrival (§6): concept, `?course=`, `?scope=due`. The last two start a queue of QUEUE_COUNT-question attempts (R-4), and their Adjust dialog opens on that count so its Start label can't lie. - R-8's AI sentence is shown only when the card IS `home.primary` (the hook tags it to that node); every other card gets the built fallback. - R-1: class names and tokens only. The one inline style is the accent custom property, collected in `accent.ts` — the dialogs portal out of `.quiz-root` and would otherwise lose the course colour. 23 tests: the start shapes and their configs, the queue cap, the dialogs, Discard, the deep links and the unresolved toast, both empty states, and that the option rows read `/config` rather than hardcoding 3/5/10. Co-Authored-By: Claude Fable 5 --- .../src/components/quiz/home/AdjustDialog.tsx | 88 +++ .../components/quiz/home/ConceptDialog.tsx | 166 +++++ .../src/components/quiz/home/PickList.tsx | 90 +++ .../components/quiz/home/QuizHome.test.tsx | 603 ++++++++++++++++ .../src/components/quiz/home/QuizHome.tsx | 663 +++++++++++++++--- .../src/components/quiz/home/QuizSettings.tsx | 110 +++ frontend/src/components/quiz/home/accent.ts | 18 + frontend/src/components/quiz/home/home.css | 382 ++++++++++ 8 files changed, 2034 insertions(+), 86 deletions(-) create mode 100644 frontend/src/components/quiz/home/AdjustDialog.tsx create mode 100644 frontend/src/components/quiz/home/ConceptDialog.tsx create mode 100644 frontend/src/components/quiz/home/PickList.tsx create mode 100644 frontend/src/components/quiz/home/QuizHome.test.tsx create mode 100644 frontend/src/components/quiz/home/QuizSettings.tsx create mode 100644 frontend/src/components/quiz/home/accent.ts create mode 100644 frontend/src/components/quiz/home/home.css diff --git a/frontend/src/components/quiz/home/AdjustDialog.tsx b/frontend/src/components/quiz/home/AdjustDialog.tsx new file mode 100644 index 00000000..ec6f4193 --- /dev/null +++ b/frontend/src/components/quiz/home/AdjustDialog.tsx @@ -0,0 +1,88 @@ +"use client"; + +/** + * The Adjust dialog (§5 B1.6) — "adjust" on the proposal card. + * + * Same three settings rows as the Concept dialog, but about the quiz already + * on offer rather than a different concept: no neighbourhood, no definition, + * and two ways out. `Done` keeps the choices and closes (`SET_CONFIG`, which + * also persists them to prefs); `Start` runs the quiz with them. + * + * The note under the rows says what the Answers choice actually changes, since + * "as you go / at the end" is otherwise a setting whose effect you only + * discover mid-quiz. + */ + +import React from "react"; +import Dialog from "@/components/Dialog"; +import { Button } from "@/components/ui"; +import type { SessionConfig } from "@/lib/quiz/machine"; +import type { QuizConfig } from "@/lib/quiz/types"; +import { QuizSettings } from "./QuizSettings"; +import { accentStyle } from "./accent"; + +const NOTES = { + "as-you-go": + "After each answer you'll see whether it was right and which answer was correct, before moving on.", + "at-end": "Answers stay hidden while you work — you'll review everything on the results screen.", +} as const; + +export interface AdjustDialogProps { + open: boolean; + /** "{concept} · {CODE}" — the quiz these settings apply to. */ + subtitle: string; + accent: string | null; + config: QuizConfig | null; + initialConfig: SessionConfig; + /** Keep the choices, don't start. */ + onDone: (config: SessionConfig) => void; + /** Close without keeping anything (Escape, backdrop, the × ). */ + onClose: () => void; + onStart: (config: SessionConfig) => void; +} + +export function AdjustDialog({ + open, + subtitle, + accent, + config, + initialConfig, + onDone, + onClose, + onStart, +}: AdjustDialogProps) { + const titleId = React.useId(); + const [draft, setDraft] = React.useState(initialConfig); + + return ( + +
+

+ Adjust this quiz +

+

{subtitle}

+ + + +

{NOTES[draft.feedback]}

+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/quiz/home/ConceptDialog.tsx b/frontend/src/components/quiz/home/ConceptDialog.tsx new file mode 100644 index 00000000..c7f518f3 --- /dev/null +++ b/frontend/src/components/quiz/home/ConceptDialog.tsx @@ -0,0 +1,166 @@ +"use client"; + +/** + * The Concept dialog (§5 B1.5) — what opens when you pick a concept off "Also + * worth a look" or out of the pick list. + * + * It is the proposal card again at dialog scale: the same name / meta / + * rationale / definition / neighbourhood, plus the three settings rows and a + * Start that carries the choices made in it. Nothing is committed until Start: + * the config lives in local state seeded from the session, so Cancel really + * does discard. + * + * The definition is R-8 for THIS concept: `concept-description` is fetched on + * open (the hook `useQuizHome` only describes the primary proposal), with the + * built sentence showing while it is in flight and after a failure. The dialog + * never blocks on it. + */ + +import React from "react"; +import Dialog from "@/components/Dialog"; +import { Button } from "@/components/ui"; +import { ConceptNeighbourhood } from "@/components/graph/ConceptNeighbourhood"; +import type { NeighbourNode } from "@/lib/graph/neighbourhood"; +import { describeConcept } from "@/lib/quiz/api"; +import type { SessionConfig } from "@/lib/quiz/machine"; +import { metaLine, type Candidate } from "@/lib/quiz/proposals"; +import type { QuizConfig } from "@/lib/quiz/types"; +import { fallbackDefinition } from "@/lib/quiz/useQuizHome"; +import { QuizSettings } from "./QuizSettings"; +import { accentStyle } from "./accent"; + +/** The dialog's canvas, per §3. */ +const CANVAS = { width: 300, height: 200, scale: 2 } as const; + +export interface ConceptDialogProps { + open: boolean; + userId: string; + candidate: Candidate; + siblings: NeighbourNode[]; + /** Edges touching this concept — the `n` in the fallback definition. */ + connected: number; + config: QuizConfig | null; + /** The settings the dialog opens on. */ + initialConfig: SessionConfig; + onCancel: () => void; + onStart: (config: SessionConfig) => void; +} + +/** + * The AI one-liner for one concept (R-8). Returns `null` while in flight or + * after a failure — the caller shows the built sentence instead. + */ +function useConceptDescription( + userId: string, + open: boolean, + conceptName: string, + courseLabel?: string, +): string | null { + const [text, setText] = React.useState(null); + + React.useEffect(() => { + if (!open || !userId || !conceptName) return; + let cancelled = false; + describeConcept(userId, conceptName, courseLabel).then( + description => { + const trimmed = description.trim(); + if (!cancelled && trimmed) setText(trimmed); + }, + () => { + // The built sentence stands. A missing definition must never hold up + // the Start button. + }, + ); + return () => { + cancelled = true; + }; + }, [userId, open, conceptName, courseLabel]); + + return text; +} + +export function ConceptDialog({ + open, + userId, + candidate, + siblings, + connected, + config, + initialConfig, + onCancel, + onStart, +}: ConceptDialogProps) { + const titleId = React.useId(); + const [draft, setDraft] = React.useState(initialConfig); + + const { node, course, color } = candidate; + const description = useConceptDescription(userId, open, node.concept_name, course?.course_code); + const definition = description ?? fallbackDefinition(candidate, connected); + + // The design prefixes the concept's meta with its course code; `metaLine` + // itself is unchanged. + const meta = course ? `${course.course_code} · ${metaLine(node)}` : metaLine(node); + + // For a concept that has never been opened, `rationaleFor` degenerates to + // "{pct}% · not studied yet" — which is the tail of the meta line directly + // above it. Every other rationale says something the meta doesn't. + const neverStudied = !node.times_studied && !node.last_studied_at; + + return ( + +
+
+
+

+ {node.concept_name} +

+

{meta}

+ {candidate.rationale && !neverStudied && ( +

{candidate.rationale}

+ )} +

{definition}

+
+
+ +
+ + + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/quiz/home/PickList.tsx b/frontend/src/components/quiz/home/PickList.tsx new file mode 100644 index 00000000..21dc772c --- /dev/null +++ b/frontend/src/components/quiz/home/PickList.tsx @@ -0,0 +1,90 @@ +"use client"; + +/** + * "Pick something specific" (§5 B1.4) — every concept on the tree, grouped + * under its course. + * + * A browse surface, not a ranking: `groupByCourse` sorts courses by code and + * concepts by name, so a known name is where you'd look for it. Each row is a + * real ` +
Pick something specific
+

What would you like to be tested on?

+ + {groups.map(({ course, nodes }) => { + const color = colorFor(nodes[0], course); + return ( +
+
+ + + {course.course_code} · {course.course_name} + +
+ + {nodes.map(node => ( + + ))} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/quiz/home/QuizHome.test.tsx b/frontend/src/components/quiz/home/QuizHome.test.tsx new file mode 100644 index 00000000..4102c7cf --- /dev/null +++ b/frontend/src/components/quiz/home/QuizHome.test.tsx @@ -0,0 +1,603 @@ +// @vitest-environment jsdom +/** + * Quiz home (§5 B1) — the behaviour the screen is specified by. + * + * The fixtures build the `useQuizHome` shape with the REAL `lib/quiz/proposals` + * functions rather than hand-written candidate lists: the ranking, the due set + * and the grouping are the hook's own, so a test that hand-rolled them would + * pass while the screen showed something else. + * + * What is pinned here: + * - the resume strip, its Resume, and its client-side Discard (R-3) + * - Start's request shape and the config it carries + * - the due row's queue cap and per-attempt count (R-4) + * - the concept dialog opening off an alternative, and starting with ITS config + * - Adjust's Done writing settings back without starting + * - `?concept=` overriding the ranked proposal, and an unresolved one toasting once + * - both empty states, and that neither is a dead end + * - that every option list comes off `/config` — the counts and difficulties + * below are deliberately NOT the server's real ones + */ + +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import type { EnrolledCourse } from "@/lib/api"; +import type { GraphEdge, GraphNode } from "@/lib/types"; +import { + alternativesOf, + dueSet, + groupByCourse, + primaryOf, + rankCandidates, +} from "@/lib/quiz/proposals"; +import { DISMISSED_KEY, PREFS_KEY, QUEUE_COUNT } from "@/lib/quiz/session"; +import type { EntryRequest } from "@/lib/quiz/source"; +import type { QuizHome as QuizHomeData } from "@/lib/quiz/useQuizHome"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import type { + AttemptDetail, + AttemptSummary, + QuizConfig, + QuizSession, + WireQuestion, +} from "@/lib/quiz/types"; +import { QuizHome } from "./QuizHome"; + +// ── Mocks ──────────────────────────────────────────────────────────────── + +const toast = vi.hoisted(() => ({ + show: vi.fn(), + dismiss: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warn: vi.fn(), +})); + +vi.mock("@/components/ToastProvider", () => ({ useToast: () => toast })); + +// R-8's per-concept sentence. Resolved empty so the dialog renders its built +// fallback deterministically, with no floating network promise. +vi.mock("@/lib/quiz/api", async importOriginal => ({ + ...(await importOriginal()), + describeConcept: vi.fn().mockResolvedValue(""), +})); + +// ── Fixtures ───────────────────────────────────────────────────────────── + +/** Two courses; the prototype's own pair. */ +const COURSES: EnrolledCourse[] = [ + course("c-cs", "CS101", "Intro to Computer Science", "#7b4b99"), + course("c-math", "MATH210", "Linear Algebra", "#3e6f8a"), +]; + +function course(id: string, code: string, name: string, color: string): EnrolledCourse { + return { + enrollment_id: `e-${id}`, + course_id: id, + course_code: code, + course_name: name, + school: "Test University", + department: "TEST", + color, + nickname: null, + node_count: 4, + enrolled_at: "2026-01-01T00:00:00Z", + term: "Spring 2026", + }; +} + +function node( + id: string, + name: string, + courseId: string, + mastery: number, + tier: GraphNode["mastery_tier"], + timesStudied: number, +): GraphNode { + return { + id, + concept_name: name, + mastery_score: mastery, + mastery_tier: tier, + times_studied: timesStudied, + last_studied_at: timesStudied > 0 ? "2026-08-18T00:00:00Z" : null, + subject: courseId === "c-cs" ? "Intro to Computer Science" : "Linear Algebra", + course_id: courseId, + is_subject_root: false, + }; +} + +/** Seven due concepts across two courses — enough to prove the queue cap. */ +const NODES: GraphNode[] = [ + node("recursion", "Recursion", "c-cs", 0.29, "struggling", 3), + node("base-cases", "Base cases", "c-cs", 0.52, "learning", 1), + node("stack-frames", "Stack frames", "c-cs", 0.3, "struggling", 0), + node("tail-recursion", "Tail recursion", "c-cs", 0.12, "struggling", 0), + node("eigenvalues", "Eigenvalues", "c-math", 0.31, "struggling", 1), + node("matrices", "Matrices", "c-math", 0.44, "struggling", 2), + node("determinants", "Determinants", "c-math", 0.05, "unexplored", 0), +]; + +const EDGES: GraphEdge[] = [ + { id: "e1", source: "recursion", target: "base-cases", strength: 0.9 }, + { id: "e2", source: "recursion", target: "stack-frames", strength: 0.7 }, + { id: "e3", source: "recursion", target: "tail-recursion", strength: 0.4 }, + { id: "e4", source: "eigenvalues", target: "matrices", strength: 0.8 }, +]; + +/** + * Deliberately NOT the server's real lists: if any option row is hardcoded + * rather than read off `/config`, "2 questions" and "gentle" disappear and a + * 3/5/10 or "medium" shows up instead. + */ +const CONFIG: QuizConfig = { + num_questions: { min: 2, max: 4, options: [2, 4] }, + difficulties: ["gentle", "fierce"], + question_types: ["multiple_choice"], +}; + +const SESSION_CONFIG = { count: 2, difficulty: "gentle", feedback: "at-end" as const }; + +function session(over: Partial = {}): QuizSession { + return { + intent: "practice", + scope: { kind: "concept", conceptId: "" }, + source: { kind: "nav" }, + config: SESSION_CONFIG, + conceptId: "", + courseId: null, + attemptId: null, + items: [], + cursor: 0, + queueIndex: 0, + phase: "home", + error: null, + result: null, + xp: null, + deliveredShort: false, + ...over, + }; +} + +function entry(over: Partial = {}): EntryRequest { + return { source: { kind: "nav" }, ...over }; +} + +function question(id: number): WireQuestion { + return { + id, + question: `Question ${id}`, + options: [{ label: "A", text: "one" }], + difficulty: "gentle", + }; +} + +function attemptDetail(over: Partial = {}): AttemptDetail { + return { + quiz_id: "attempt-1", + status: "in_progress", + resumable: true, + difficulty: "gentle", + concept_node_id: "recursion", + questions: [question(1), question(2), question(3), question(4), question(5)], + responses: [ + { question_index: 0, selected_index: 1, is_correct: true, answered_at: "2026-08-22T00:00:00Z" }, + { question_index: 1, selected_index: 0, is_correct: false, answered_at: "2026-08-22T00:01:00Z" }, + ], + score: null, + total: null, + created_at: "2026-08-22T00:00:00Z", + ...over, + }; +} + +function buildHome(over: Partial = {}): QuizHomeData { + const nodes = over.nodes ?? NODES; + const courses = over.courses ?? COURSES; + const attempts: AttemptSummary[] = over.attempts ?? []; + const candidates = rankCandidates(nodes, courses, attempts); + const primary = primaryOf(candidates); + return { + status: "ready", + error: null, + nodes, + edges: over.edges ?? EDGES, + courses, + attempts, + candidates, + primary, + alternatives: alternativesOf(candidates, primary), + due: dueSet(nodes), + byCourse: groupByCourse(nodes, courses), + resumable: null, + primaryDescription: null, + refresh: vi.fn(), + ...over, + }; +} + +function buildActions(): QuizActions { + return { + configure: vi.fn(), + setConfig: vi.fn(), + start: vi.fn(), + select: vi.fn(), + submitAnswer: vi.fn(), + next: vi.fn(), + finish: vi.fn(), + requestLeave: vi.fn(), + cancelLeave: vi.fn(), + confirmLeave: vi.fn(), + resume: vi.fn(), + practiseMissed: vi.fn(), + nextInQueue: vi.fn(), + exit: vi.fn(), + flag: vi.fn(), + dismissError: vi.fn(), + retry: vi.fn(), + }; +} + +function mount(over: { home?: Partial; entry?: EntryRequest; config?: QuizConfig | null } = {}) { + const home = buildHome(over.home); + const actions = buildActions(); + const view = render( + , + ); + return { home, actions, view }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + window.localStorage.clear(); +}); + +// ── Tests ──────────────────────────────────────────────────────────────── + +describe("QuizHome — the proposal", () => { + it("offers the ranked primary and starts it with the session config", () => { + const { actions } = mount(); + + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("Recursion"); + // The config line is read, not written: [2, 4] / gentle came off `/config`. + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("2 questions, gentle"); + + fireEvent.click(screen.getByTestId("quiz-start")); + + expect(actions.start).toHaveBeenCalledWith( + { + intent: "practice", + scope: { kind: "concept", conceptId: "recursion" }, + conceptId: "recursion", + courseId: "c-cs", + }, + SESSION_CONFIG, + ); + }); + + it("uses the built definition when the AI sentence is for another concept", () => { + // `primaryDescription` is tagged to `home.primary`; a deep link to a + // different concept must not inherit it. + mount({ + home: { primaryDescription: "A function that calls itself." }, + entry: entry({ concept: "matrices", source: { kind: "tree" } }), + }); + + const proposal = screen.getByTestId("quiz-proposal"); + expect(proposal).toHaveTextContent("Matrices"); + expect(proposal).not.toHaveTextContent("A function that calls itself."); + expect(proposal).toHaveTextContent("MATH210 · struggling · 1 connected concept"); + }); + + it("cancels back to the source", () => { + const { actions } = mount({ + entry: entry({ source: { kind: "dashboard", returnTo: "/dashboard" } }), + }); + fireEvent.click(screen.getByTestId("quiz-cancel")); + expect(actions.exit).toHaveBeenCalledWith("/dashboard"); + }); +}); + +describe("QuizHome — the resume strip", () => { + it("names the concept and resumes the attempt", () => { + const { actions } = mount({ + home: { resumable: { attempt: attemptDetail(), session: null, answered: 2 } }, + }); + + const strip = screen.getByTestId("quiz-resume-strip"); + expect(strip).toHaveTextContent("You left a quiz on Recursion — 2 of 5 answered"); + + fireEvent.click(within(strip).getByTestId("quiz-resume")); + expect(actions.resume).toHaveBeenCalledWith("attempt-1"); + }); + + it("discards it client-side and reloads (R-3: there is no abandon endpoint)", () => { + const { home } = mount({ + home: { resumable: { attempt: attemptDetail(), session: null, answered: 2 } }, + }); + + fireEvent.click(screen.getByTestId("quiz-resume-discard")); + + expect(window.localStorage.getItem(DISMISSED_KEY)).toContain("attempt-1"); + expect(home.refresh).toHaveBeenCalled(); + }); + + it("is absent when nothing is resumable", () => { + mount(); + expect(screen.queryByTestId("quiz-resume-strip")).toBeNull(); + }); +}); + +describe("QuizHome — the due row", () => { + it("starts a capped queue of short attempts (R-4)", () => { + const { actions } = mount(); + + const row = screen.getByTestId("quiz-review-due"); + expect(row).toHaveTextContent("7 concepts across 2 courses"); + + fireEvent.click(row); + + const [request, config] = (actions.start as ReturnType).mock.calls[0]; + expect(request.intent).toBe("review"); + expect(request.scope.kind).toBe("due"); + // Seven concepts are due; a session runs at most QUEUE_MAX of them. + expect(request.scope.queue).toHaveLength(5); + // Weakest first, and the first one is what generates. + expect(request.scope.queue[0]).toBe("determinants"); + expect(request.conceptId).toBe("determinants"); + expect(config.count).toBe(QUEUE_COUNT); + }); + + it("is hidden when nothing is due", () => { + const mastered = NODES.map(n => ({ ...n, mastery_tier: "mastered" as const })); + mount({ home: { nodes: mastered } }); + expect(screen.queryByTestId("quiz-review-due")).toBeNull(); + }); +}); + +describe("QuizHome — the concept dialog", () => { + it("opens off an alternative and starts with the config chosen in it", () => { + const { actions } = mount(); + + // The two alternatives are the next-weakest after the primary. + fireEvent.click(screen.getByTestId("quiz-alternative-determinants")); + + const dialog = screen.getByTestId("quiz-concept-dialog"); + expect(dialog).toHaveTextContent("Determinants"); + + // Every length offered is one `/config` named. + const lengths = within(dialog).getByTestId("quiz-seg-count"); + expect(lengths).toHaveTextContent("2 questions"); + expect(lengths).toHaveTextContent("4 questions"); + expect(lengths).not.toHaveTextContent("5 questions"); + expect(lengths).not.toHaveTextContent("10 questions"); + + fireEvent.click(within(dialog).getByTestId("quiz-seg-count-4")); + fireEvent.click(within(dialog).getByTestId("quiz-seg-difficulty-fierce")); + expect(within(dialog).getByTestId("quiz-concept-start")).toHaveTextContent("Start · 4 fierce"); + + fireEvent.click(within(dialog).getByTestId("quiz-concept-start")); + + expect(actions.start).toHaveBeenCalledWith( + { + intent: "practice", + scope: { kind: "concept", conceptId: "determinants" }, + conceptId: "determinants", + courseId: "c-math", + }, + { count: 4, difficulty: "fierce", feedback: "at-end" }, + ); + // Started from a dialog, so the choices are remembered (§5 B1.6). + expect(window.localStorage.getItem(PREFS_KEY)).toContain("fierce"); + }); + + it("opens off a pick-list row", () => { + mount(); + fireEvent.click(screen.getByTestId("quiz-pick-open")); + + const list = screen.getByTestId("quiz-pick-list"); + expect(within(list).getByText("CS101 · Intro to Computer Science")).toBeInTheDocument(); + + fireEvent.click(within(list).getByTestId("quiz-pick-base-cases")); + expect(screen.getByTestId("quiz-concept-dialog")).toHaveTextContent("Base cases"); + }); + + it("collapses the pick list again", () => { + mount(); + fireEvent.click(screen.getByTestId("quiz-pick-open")); + fireEvent.click(screen.getByTestId("quiz-pick-back")); + expect(screen.queryByTestId("quiz-pick-list")).toBeNull(); + expect(screen.getByTestId("quiz-proposal")).toBeInTheDocument(); + }); +}); + +describe("QuizHome — the adjust dialog", () => { + it("marks the link active while open and writes the choices back on Done", () => { + const { actions } = mount(); + + const link = screen.getByTestId("quiz-adjust"); + expect(link).not.toHaveAttribute("data-active"); + + fireEvent.click(link); + expect(screen.getByTestId("quiz-adjust")).toHaveAttribute("data-active", "true"); + expect(actions.configure).toHaveBeenCalledWith(true); + + const dialog = screen.getByTestId("quiz-adjust-dialog"); + expect(dialog).toHaveTextContent("Recursion · CS101"); + fireEvent.click(within(dialog).getByTestId("quiz-seg-difficulty-fierce")); + fireEvent.click(within(dialog).getByTestId("quiz-adjust-done")); + + expect(actions.setConfig).toHaveBeenCalledWith({ + count: 2, + difficulty: "fierce", + feedback: "at-end", + }); + expect(actions.start).not.toHaveBeenCalled(); + expect(screen.queryByTestId("quiz-adjust-dialog")).toBeNull(); + }); + + it("explains what the Answers choice changes", () => { + mount(); + fireEvent.click(screen.getByTestId("quiz-adjust")); + + const dialog = screen.getByTestId("quiz-adjust-dialog"); + expect(dialog).toHaveTextContent("Answers stay hidden while you work"); + + fireEvent.click(within(dialog).getByTestId("quiz-seg-feedback-as-you-go")); + expect(dialog).toHaveTextContent("After each answer you'll see whether it was right"); + }); + + it("starts with its own config", () => { + const { actions } = mount(); + fireEvent.click(screen.getByTestId("quiz-adjust")); + + const dialog = screen.getByTestId("quiz-adjust-dialog"); + fireEvent.click(within(dialog).getByTestId("quiz-seg-count-4")); + fireEvent.click(within(dialog).getByTestId("quiz-adjust-start")); + + expect(actions.start).toHaveBeenCalledWith( + expect.objectContaining({ conceptId: "recursion" }), + { count: 4, difficulty: "gentle", feedback: "at-end" }, + ); + }); +}); + +describe("QuizHome — arrival", () => { + it("makes a `?concept=` link the proposal, with its own reason", () => { + const { actions } = mount({ + entry: entry({ concept: "matrices", source: { kind: "tree", conceptId: "matrices" } }), + }); + + const proposal = screen.getByTestId("quiz-proposal"); + expect(proposal).toHaveTextContent("Matrices"); + expect(proposal).toHaveTextContent("From your tree"); + + fireEvent.click(screen.getByTestId("quiz-start")); + expect(actions.start).toHaveBeenCalledWith( + expect.objectContaining({ conceptId: "matrices", courseId: "c-math" }), + SESSION_CONFIG, + ); + }); + + it("says so once when the link names something outside the semester", () => { + const { view } = mount({ + entry: entry({ concept: "not-in-this-term", source: { kind: "link" } }), + }); + + expect(toast.info).toHaveBeenCalledTimes(1); + expect(toast.info).toHaveBeenCalledWith("That concept isn't in your current semester"); + // …and the ordinary home renders underneath. + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("Recursion"); + + view.rerender( + , + ); + expect(toast.info).toHaveBeenCalledTimes(1); + }); + + it("turns `?course=` into a course queue", () => { + const { actions } = mount({ + entry: entry({ course: "c-cs", source: { kind: "tree", returnTo: "/tree" } }), + }); + + const proposal = screen.getByTestId("quiz-proposal"); + expect(proposal).toHaveTextContent("Practice CS101"); + expect(proposal).toHaveTextContent("4 concepts due"); + expect(proposal).toHaveTextContent(`${QUEUE_COUNT} questions each, gentle`); + + fireEvent.click(screen.getByTestId("quiz-start")); + const [request, config] = (actions.start as ReturnType).mock.calls[0]; + expect(request.scope).toEqual({ + kind: "course", + courseId: "c-cs", + queue: ["tail-recursion", "recursion", "stack-frames", "base-cases"], + }); + expect(config.count).toBe(QUEUE_COUNT); + }); + + it("turns `?scope=due` into the due card", () => { + const { actions } = mount({ entry: entry({ scope: "due", source: { kind: "dashboard" } }) }); + + const proposal = screen.getByTestId("quiz-proposal"); + expect(proposal).toHaveTextContent("Review everything due"); + expect(proposal).toHaveTextContent("7 concepts across 2 courses · starting with the 5 weakest"); + + fireEvent.click(screen.getByTestId("quiz-start")); + const [request] = (actions.start as ReturnType).mock.calls[0]; + expect(request.intent).toBe("review"); + expect(request.scope.queue).toHaveLength(5); + }); +}); + +describe("QuizHome — the states with no proposal", () => { + it("sends a student with no courses somewhere they can add one", () => { + mount({ home: { nodes: [], courses: [], edges: [] } }); + + const empty = screen.getByTestId("quiz-empty-state"); + expect(empty).toHaveTextContent("Add a course to start quizzing"); + expect(within(empty).getByRole("link")).toHaveAttribute("href", "/dashboard"); + }); + + it("offers both ways to grow an empty tree", () => { + mount({ home: { nodes: [], edges: [] } }); + + const empty = screen.getByTestId("quiz-empty-state"); + expect(empty).toHaveTextContent("Your tree is empty"); + const hrefs = within(empty) + .getAllByRole("link") + .map(a => a.getAttribute("href")); + expect(hrefs).toEqual(["/library", "/learn"]); + }); + + it("still offers the list when everything is mastered", () => { + const mastered = NODES.map(n => ({ ...n, mastery_tier: "mastered" as const })); + mount({ home: { nodes: mastered } }); + + expect(screen.getByTestId("quiz-empty-state")).toHaveTextContent("Nothing needs review"); + fireEvent.click(screen.getByTestId("quiz-pick-open")); + expect(screen.getByTestId("quiz-pick-list")).toBeInTheDocument(); + }); + + it("offers a retry when the load failed", () => { + const { home } = mount({ + home: { + status: "error", + error: { code: "NETWORK", message: "You look offline.", retryable: true }, + }, + }); + + expect(screen.getByTestId("quiz-home-error")).toHaveTextContent("You look offline."); + fireEvent.click(screen.getByTestId("quiz-home-retry")); + expect(home.refresh).toHaveBeenCalled(); + }); + + it("shows no options at all until `/config` lands", () => { + mount({ config: null }); + fireEvent.click(screen.getByTestId("quiz-adjust")); + + const dialog = screen.getByTestId("quiz-adjust-dialog"); + expect(within(dialog).queryByTestId("quiz-seg-count")).toBeNull(); + expect(within(dialog).queryByTestId("quiz-seg-difficulty")).toBeNull(); + // The one list that is a client concept (R-2) is always there. + expect(within(dialog).getByTestId("quiz-seg-feedback")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/quiz/home/QuizHome.tsx b/frontend/src/components/quiz/home/QuizHome.tsx index 4c155b2d..6568757f 100644 --- a/frontend/src/components/quiz/home/QuizHome.tsx +++ b/frontend/src/components/quiz/home/QuizHome.tsx @@ -1,26 +1,55 @@ "use client"; /** - * STUB — Wave 3 (B1) replaces the body. The PROPS are the seam and must not - * change: `QuizScreen` composes exactly this shape from `useQuizHome`, - * `useQuizConfig` and `useQuizSession`, and A2's tests pin it. + * Quiz home (§5 B1) — `phase: home | configuring`. * - * What this placeholder is for: it renders enough to drive the machine by hand - * end to end (pick the proposal, press Start) so the data layer can be exercised - * before a single pixel of the real screen exists. + * One proposal you can start in a single click, two alternatives, the whole + * due set as one row, and a way to find anything else. The old screen opened + * on an empty course/concept dropdown and asked the student to know what they + * wanted; this one arrives with an answer and lets them disagree. * - * The real screen is §5 B1: resume strip, "Ready for you" proposal with its - * neighbourhood, two alternatives, the review-everything-due row, the grouped - * pick list, the concept and adjust dialogs, and the empty states. + * Everything on it is derived, never invented: the ranking is + * `lib/quiz/proposals` (a cited mirror of `graph_service.get_recommendations`, + * R-7), the meta and rationale lines are its formatters, the option lists come + * off `GET /api/quiz/config`, and the marks are the tree's own arithmetic + * through `ConceptNode` / `ConceptNeighbourhood`. + * + * The card has three shapes, chosen by how you arrived (§6): a concept (the + * default, and every `?concept=` / `?topic=` deep link), a course (`?course=`) + * and the due set (`?scope=due`). The last two run as a queue of single-concept + * attempts, because `/generate` is per concept (R-4). */ import React from "react"; -import type { QuizActions } from "@/lib/quiz/useQuizSession"; -import type { QuizHome as QuizHomeData } from "@/lib/quiz/useQuizHome"; +import { Button, EmptyState, InlineBanner } from "@/components/ui"; +import { Skeleton } from "@/components/Skeleton"; +import { useToast } from "@/components/ToastProvider"; +import { ConceptNeighbourhood } from "@/components/graph/ConceptNeighbourhood"; +import { ConceptNode } from "@/components/graph/ConceptNode"; +import { apiToGraphNode } from "@/lib/data"; +import { siblingsFor } from "@/lib/graph/neighbourhood"; +import { cancelTarget } from "@/lib/quiz/exits"; +import type { SessionConfig } from "@/lib/quiz/machine"; +import { savePrefs } from "@/lib/quiz/prefs"; +import { + colorFor, + entrySelection, + latestCompletedAttempt, + metaLine, + queueFor, + rationaleFor, + type Candidate, +} from "@/lib/quiz/proposals"; +import { QUEUE_COUNT, QUEUE_MAX, dismissAttempt } from "@/lib/quiz/session"; import type { EntryRequest } from "@/lib/quiz/source"; -import type { QuizConfig, QuizPrefs, QuizSession } from "@/lib/quiz/types"; -import { queueFor } from "@/lib/quiz/proposals"; -import { QUEUE_COUNT } from "@/lib/quiz/session"; +import type { QuizConfig, QuizPrefs, QuizSession, SourceKind } from "@/lib/quiz/types"; +import { fallbackDefinition, type QuizHome as QuizHomeData } from "@/lib/quiz/useQuizHome"; +import type { QuizActions } from "@/lib/quiz/useQuizSession"; +import { AdjustDialog } from "./AdjustDialog"; +import { ConceptDialog } from "./ConceptDialog"; +import { PickList } from "./PickList"; +import { accentStyle } from "./accent"; +import "./home.css"; export interface QuizHomeProps { userId: string; @@ -32,92 +61,554 @@ export interface QuizHomeProps { actions: QuizActions; } -export function QuizHome({ home, session, actions, entry }: QuizHomeProps) { - const primary = home.primary; - - const startPrimary = () => { - if (!primary) return; - actions.start({ - intent: "practice", - scope: { kind: "concept", conceptId: primary.node.id }, - conceptId: primary.node.id, - courseId: primary.node.course_id ?? null, - }); +/** The card's three shapes (§5 B1.2). `course` and `due` run as queues. */ +type CardMode = "concept" | "course" | "due"; + +/** The home neighbourhood canvas, per §3. */ +const CANVAS = { width: 320, height: 204, scale: 2 } as const; + +/** The mark beside the concept name — `node`, so it carries the soft glow. */ +const CARD_NODE_SIZE = 26; +/** The dot on an "Also worth a look" row. */ +const ALT_DOT_SIZE = 11; + +/** Why a deep-linked concept is the one on offer (§5 B1.2). */ +const DEEP_LINK_RATIONALE: Partial> = { + tree: "From your tree", + notes: "From your note", +}; +const DEFAULT_DEEP_LINK_RATIONALE = "Suggested for you"; + +/** §6: a link into a term the student isn't looking at. */ +const UNRESOLVED_COPY = "That concept isn't in your current semester"; + +export function QuizHome({ userId, home, config, entry, session, actions }: QuizHomeProps) { + const toast = useToast(); + const [picking, setPicking] = React.useState(false); + const [adjustOpen, setAdjustOpen] = React.useState(false); + const [dialogNodeId, setDialogNodeId] = React.useState(null); + + // ── What the card is about ──────────────────────────────────────────── + + const selection = React.useMemo( + () => entrySelection(entry, home.nodes, home.courses), + [entry, home.nodes, home.courses], + ); + + // A deep link naming something outside the active semester says so once and + // then gets out of the way — the ordinary home renders underneath (§6). + const toasted = React.useRef(false); + React.useEffect(() => { + if (toasted.current || home.status !== "ready" || !selection.unresolved) return; + toasted.current = true; + toast.info(UNRESOLVED_COPY); + }, [home.status, selection.unresolved, toast]); + + const courseQueue = React.useMemo( + () => (entry.course ? queueFor("course", home.nodes, entry.course) : []), + [entry.course, home.nodes], + ); + const dueQueue = React.useMemo(() => queueFor("due", home.nodes), [home.nodes]); + + const mode: CardMode = selection.conceptId + ? "concept" + : entry.course && courseQueue.length > 0 + ? "course" + : entry.scope === "due" && dueQueue.length > 0 + ? "due" + : "concept"; + + /** + * A concept as the card and the dialogs want it. Ranked candidates come with + * their rationale already resolved; anything else (a mastered concept picked + * out of the list, say) is built the same way `rankCandidates` would. + */ + const candidateFor = React.useCallback( + (nodeId: string | null | undefined): Candidate | null => { + if (!nodeId) return null; + const ranked = home.candidates.find(c => c.node.id === nodeId); + if (ranked) return ranked; + const node = home.nodes.find(n => n.id === nodeId); + if (!node) return null; + const course = home.courses.find(c => c.course_id === node.course_id) ?? null; + const lastAttempt = latestCompletedAttempt(node.id, home.attempts); + return { + node, + course, + color: colorFor(node, course), + rationale: rationaleFor(node, lastAttempt), + ...(lastAttempt ? { lastAttempt } : {}), + }; + }, + [home.candidates, home.nodes, home.courses, home.attempts], + ); + + const card = React.useMemo(() => { + if (mode === "course") return candidateFor(courseQueue[0]); + if (mode === "due") return candidateFor(dueQueue[0]); + return candidateFor(selection.conceptId) ?? home.primary; + }, [mode, candidateFor, courseQueue, dueQueue, selection.conceptId, home.primary]); + + const entryCourse = React.useMemo( + () => home.courses.find(c => c.course_id === entry.course) ?? null, + [home.courses, entry.course], + ); + + // ── The little constellation ────────────────────────────────────────── + // + // `siblingsFor` works on the adapted `lib/data` node shape (colour resolved, + // `name` rather than `concept_name`) — the same one the tree feeds its graph, + // and the one `QuizScreen` already adapts for the results screen. + const viewNodes = React.useMemo( + () => home.nodes.map(n => apiToGraphNode(n, home.courses)), + [home.nodes, home.courses], + ); + + const siblingsOf = React.useCallback( + (nodeId: string | undefined) => (nodeId ? siblingsFor(nodeId, viewNodes, home.edges) : []), + [viewNodes, home.edges], + ); + + /** The `n` in "{Course} · {tier} · {n} connected concepts" (R-8's fallback). */ + const connectedTo = React.useCallback( + (nodeId: string | undefined) => + nodeId ? home.edges.filter(e => e.source === nodeId || e.target === nodeId).length : 0, + [home.edges], + ); + + const cardSiblings = React.useMemo(() => siblingsOf(card?.node.id), [siblingsOf, card]); + + // ── Copy ────────────────────────────────────────────────────────────── + + const accent = card?.color ?? null; + const queued = mode !== "concept"; + const conceptName = card?.node.concept_name ?? ""; + const courseCode = (mode === "course" ? entryCourse : card?.course)?.course_code ?? ""; + + const title = + mode === "course" + ? `Practice ${courseCode}` + : mode === "due" + ? "Review everything due" + : conceptName; + + const meta = + mode === "course" + ? `${courseQueue.length} concepts due` + : mode === "due" + ? `${home.due.count} concepts across ${home.due.courseCount} courses · starting with the ${Math.min(home.due.count, QUEUE_MAX)} weakest` + : card + ? metaLine(card.node) + : ""; + + // Only a deep link explains itself on the card; the ranked proposal's reason + // lives on the alternatives rows, where there is something to compare against. + const rationale = + mode === "concept" && selection.conceptId && card + ? DEEP_LINK_RATIONALE[entry.source.kind] ?? DEFAULT_DEEP_LINK_RATIONALE + : null; + + // R-8's sentence is fetched for `home.primary` only, so it is shown only when + // the card IS that proposal — a deep link to another concept would otherwise + // be captioned with somebody else's definition. + const definition = queued + ? null + : (card && card.node.id === home.primary?.node.id ? home.primaryDescription : null) + ?? (card ? fallbackDefinition(card, connectedTo(card.node.id)) : ""); + + const feedbackSuffix = session.config.feedback === "as-you-go" ? " · answers as you go" : ""; + const configLine = queued + ? `${QUEUE_COUNT} questions each, ${session.config.difficulty}${feedbackSuffix}` + : `${session.config.count} questions, ${session.config.difficulty}${feedbackSuffix}`; + + // ── Starting ────────────────────────────────────────────────────────── + + const startConcept = React.useCallback( + (candidate: Candidate, cfg: SessionConfig) => { + actions.start( + { + intent: "practice", + scope: { kind: "concept", conceptId: candidate.node.id }, + conceptId: candidate.node.id, + courseId: candidate.node.course_id ?? null, + }, + cfg, + ); + }, + [actions], + ); + + const startQueue = React.useCallback( + (kind: "course" | "due", queue: string[], cfg: SessionConfig, courseId?: string) => { + if (queue.length === 0) return; + const first = queue[0]; + actions.start( + { + intent: kind === "due" ? "review" : "practice", + scope: + kind === "due" + ? { kind: "due", queue } + : { kind: "course", courseId: courseId ?? "", queue }, + conceptId: first, + courseId: home.nodes.find(n => n.id === first)?.course_id ?? null, + }, + cfg, + ); + }, + [actions, home.nodes], + ); + + /** The card's own Start, in whichever shape it currently has. */ + const startCard = React.useCallback( + (cfg: SessionConfig) => { + if (mode === "course") return startQueue("course", courseQueue, cfg, entry.course); + if (mode === "due") return startQueue("due", dueQueue, cfg); + if (card) startConcept(card, cfg); + }, + [mode, startQueue, courseQueue, dueQueue, entry.course, card, startConcept], + ); + + /** Remember the choices a dialog was started with (§5 B1.6). `setConfig` + * persists on its own; `start` does not. */ + const remember = (cfg: SessionConfig) => + savePrefs({ count: cfg.count, difficulty: cfg.difficulty, feedback: cfg.feedback }); + + // A queued session is QUEUE_COUNT questions per concept by default (R-4). + // That is what the card starts with AND what its Adjust dialog opens on — + // otherwise "Start · 5 medium" would quietly run three-question attempts. + const effectiveConfig: SessionConfig = queued + ? { ...session.config, count: QUEUE_COUNT } + : session.config; + + // ── Dialogs ─────────────────────────────────────────────────────────── + + const dialogCandidate = React.useMemo( + () => candidateFor(dialogNodeId), + [candidateFor, dialogNodeId], + ); + + const openAdjust = (open: boolean) => { + setAdjustOpen(open); + // `configuring` is the machine's own name for "the adjust dialog is open". + actions.configure(open); }; - const startDue = () => { - const queue = queueFor("due", home.nodes); - if (queue.length === 0) return; - actions.start( - { - intent: "review", - scope: { kind: "due", queue }, - conceptId: queue[0], - courseId: home.nodes.find(n => n.id === queue[0])?.course_id ?? null, - }, - { ...session.config, count: QUEUE_COUNT }, - ); + const openConcept = (nodeId: string) => { + if (adjustOpen) openAdjust(false); + setDialogNodeId(nodeId); }; - return ( -
-

Quiz

-

- {session.phase} · {home.status} - {entry.scope === "due" ? " · due" : ""} -

- - {home.resumable && ( -

- You left a quiz on {home.resumable.attempt.concept_node_id} —{" "} - {home.resumable.answered} answered -

- )} + // ── Regions ─────────────────────────────────────────────────────────── + + const resumable = home.resumable; + const resumeName = resumable + ? home.nodes.find(n => n.id === resumable.attempt.concept_node_id)?.concept_name + ?? home.attempts.find(a => a.quiz_id === resumable.attempt.quiz_id)?.concept_name + ?? "a concept" + : ""; + const resumeTotal = resumable + ? resumable.attempt.questions.length || resumable.attempt.total || 0 + : 0; + + const resumeStrip = resumable ? ( +
+ + + + + } + > + {`You left a quiz on ${resumeName} — ${resumable.answered} of ${resumeTotal} answered`} + +
+ ) : null; + + const conceptCount = React.useMemo( + () => home.nodes.filter(n => !n.is_subject_root).length, + [home.nodes], + ); + + const proposal = card ? ( +
+
+
Ready for you
+ +
-

- {primary ? primary.node.concept_name : "Nothing to propose yet"} - {primary ? ` · ${primary.rationale}` : ""} -

-

{home.primaryDescription ?? ""}

-

- {session.config.count} questions, {session.config.difficulty} - {session.config.feedback === "as-you-go" ? " · answers as you go" : ""} -

- -
+
+
+
+ {!queued && ( + + )} + {title} +
+ + {meta &&

{meta}

} + {rationale &&

{rationale}

} + {definition &&

{definition}

} +

{configLine}

+ +
+ + +
+
+ +
+
+ +
+
+
+ ) : ( + // Every concept is mastered: there is nothing to propose, but the list is + // still there, so this is a signpost rather than a dead end. + setPicking(true)}> + Pick something specific → + + } + /> + ); + + const alternatives = ( + <> + {home.alternatives.map(alt => ( - {home.resumable && ( - - )} - {home.due.count > 0 && ( - - )} + ))} + + {home.due.count > 0 && ( + )} + + ); + + const skeleton = ( +
+
+ +
+ + +
+ + + + + +
+
+
+ +
+
+ ); + + const errorCard = ( +
+

We couldn't load your tree

+

{home.error?.message}

+
+
); + + const body = () => { + if (home.status === "error") return errorCard; + if (home.status === "loading") return skeleton; + + if (home.courses.length === 0) { + return ( + + ); + } + + if (conceptCount === 0) { + return ( + +
+ Go to your library + + + Talk to the tutor + + + } + /> + ); + } + + if (picking) { + return setPicking(false)} />; + } + + return ( + <> + {proposal} + {card && ( + <> +
+
Also worth a look
+ {alternatives} +
+
+ +
+ + )} + + ); + }; + + return ( +
+ {resumeStrip} + {body()} + + {dialogCandidate && ( + setDialogNodeId(null)} + onStart={cfg => { + setDialogNodeId(null); + remember(cfg); + startConcept(dialogCandidate, cfg); + }} + /> + )} + + {adjustOpen && card && ( + { + openAdjust(false); + actions.setConfig(cfg); + }} + onClose={() => openAdjust(false)} + onStart={cfg => { + openAdjust(false); + remember(cfg); + startCard(cfg); + }} + /> + )} +
+ ); } diff --git a/frontend/src/components/quiz/home/QuizSettings.tsx b/frontend/src/components/quiz/home/QuizSettings.tsx new file mode 100644 index 00000000..e795c719 --- /dev/null +++ b/frontend/src/components/quiz/home/QuizSettings.tsx @@ -0,0 +1,110 @@ +"use client"; + +/** + * The three pick-one rows both quiz-home dialogs carry: Length, Difficulty, + * Answers (§5 B1.5 / B1.6). + * + * One component rather than two copies because the Concept dialog and the + * Adjust dialog render the identical control set — the design draws them + * identically too, down to the 96px label gutter. + * + * NOTHING here enumerates counts or difficulties: the two option lists come + * off `GET /api/quiz/config` (§2) and the only hardcoded list in the quiz is + * `FEEDBACK_MODES`, which is a client concept with no server list to read + * (R-2). While `/config` is still in flight the rows keep their labels and + * show a skeleton where the options will land, so the dialog doesn't reflow + * when it arrives. + */ + +import React from "react"; +import { SegmentedControl } from "@/components/ui"; +import { Skeleton } from "@/components/Skeleton"; +import { FEEDBACK_LABELS, FEEDBACK_MODES } from "@/lib/quiz/prefs"; +import type { SessionConfig } from "@/lib/quiz/machine"; +import type { QuizConfig } from "@/lib/quiz/types"; + +export interface QuizSettingsProps { + /** `null` until `/api/quiz/config` resolves. */ + config: QuizConfig | null; + value: SessionConfig; + onChange: (next: SessionConfig) => void; + /** Drops the top margin when the rows already follow a rule. */ + flush?: boolean; +} + +/** Width of the placeholder that stands in for a row of options. */ +const SKELETON_WIDTH = 220; +const SKELETON_HEIGHT = 14; + +function Row({ + label, + children, +}: { + label: string; + children: (labelId: string) => React.ReactNode; +}) { + const labelId = React.useId(); + return ( +
+ + {label} + + {children(labelId)} +
+ ); +} + +export function QuizSettings({ config, value, onChange, flush = false }: QuizSettingsProps) { + const pending = ; + + return ( +
+ + {labelId => + config ? ( + ({ + value: n, + label: `${n} questions`, + }))} + value={value.count} + onChange={count => onChange({ ...value, count })} + labelledBy={labelId} + testid="quiz-seg-count" + /> + ) : ( + pending + ) + } + + + + {labelId => + config ? ( + ({ value: d, label: d }))} + value={value.difficulty} + onChange={difficulty => onChange({ ...value, difficulty })} + labelledBy={labelId} + testid="quiz-seg-difficulty" + /> + ) : ( + pending + ) + } + + + + {labelId => ( + ({ value: m, label: FEEDBACK_LABELS[m] }))} + value={value.feedback} + onChange={feedback => onChange({ ...value, feedback })} + labelledBy={labelId} + testid="quiz-seg-feedback" + /> + )} + +
+ ); +} diff --git a/frontend/src/components/quiz/home/accent.ts b/frontend/src/components/quiz/home/accent.ts new file mode 100644 index 00000000..962e4162 --- /dev/null +++ b/frontend/src/components/quiz/home/accent.ts @@ -0,0 +1,18 @@ +import type { CSSProperties } from "react"; + +/** + * Bind the course accent for a subtree — R-1's one styling exemption ("binding + * a CSS custom property to runtime data"), collected here so quiz home has a + * single auditable site for it. + * + * `QuizScreen` already sets `--quiz-accent` on `.quiz-root`, which covers + * everything in the page tree. The two dialogs do NOT live in that tree: + * `Dialog` portals its panel to `document.body`, where the accent would fall + * all the way back to `var(--accent)` and the segmented underlines would paint + * in the app's green instead of the course's colour. Re-binding it on the + * dialog root is what keeps a portalled panel looking like the screen it came + * from. + */ +export function accentStyle(color: string | null | undefined): CSSProperties | undefined { + return color ? ({ "--quiz-accent": color } as CSSProperties) : undefined; +} diff --git a/frontend/src/components/quiz/home/home.css b/frontend/src/components/quiz/home/home.css new file mode 100644 index 00000000..b682575f --- /dev/null +++ b/frontend/src/components/quiz/home/home.css @@ -0,0 +1,382 @@ +/* Quiz home (§5 B1) — the proposal card, the alternatives, the pick list and + * the two settings dialogs. + * + * R-1: class names only, tokens only. The design's own geometry constants (the + * measures, the three display sizes, the row rhythm) are declared ONCE in the + * block below and referenced everywhere else, so no rule further down carries a + * bare px value. + * + * The token block is applied to BOTH roots: `.quiz-home` (in the page tree) and + * `.quiz-home-dialog` (portalled to by `Dialog`, so it inherits nothing + * from the screen). That portal is also why the dialogs re-bind `--quiz-accent` + * themselves — outside `.quiz-root` the accent would fall all the way back to + * the app's own green and the underlines would stop being the course colour. + */ + +.quiz-home, +.quiz-home-dialog { + /* Measures. The definition paragraph is set to a reading measure rather than + the column, exactly as the design draws it. */ + --quiz-home-def-width: 380px; + --quiz-home-dialog-def-width: 340px; + --quiz-home-pick-width: 620px; + --quiz-home-card-min: 340px; + --quiz-home-dialog-card-min: 280px; + --quiz-home-setting-label-w: 96px; + + /* Display sizes the --fs-* ramp has no step for. Pinned here rather than + bolted onto the global scale (that scale is documented as derived from an + audit, not invented — see globals.css). */ + --quiz-home-name-fs: 28px; + --quiz-home-dialog-name-fs: 22px; + --quiz-home-pick-title-fs: 24px; + --quiz-home-def-fs: 13.5px; + + /* Rhythm. */ + --quiz-home-card-gap: 24px; + --quiz-home-dialog-gap: 22px; + --quiz-home-name-gap: 14px; + --quiz-home-rule-top: 36px; + --quiz-home-rule-bottom: 26px; + --quiz-home-actions-gap: 18px; + --quiz-home-actions-top: 28px; + --quiz-home-row-pad: 13px 8px; + --quiz-home-row-bleed: -8px; + --quiz-home-resume-gap: 26px; + + /* The review-due row has no mastery, so its dot is the one mark drawn in CSS + rather than by (whose diameters are props, not tokens). */ + --quiz-home-dot-sm: 11px; + + /* The inline course code on a row is tracked tighter than `.label-micro`. */ + --quiz-home-code-tracking: 0.08em; +} + +/* ── Shell ──────────────────────────────────────────────────────────── */ + +.quiz-home { + display: flex; + flex-direction: column; +} + +/* The strip is specified as a full-bleed band under the page header; inside + `QuizScreen`'s centred 780px column that is not reachable. So it bleeds a + little past the column on both sides and pulls its own inline padding back + to match, which leaves the band reading as a band while its text still lines + up with the card underneath. */ +.quiz-home__resume { + margin: 0 calc(-1 * var(--pad-md)) var(--quiz-home-resume-gap); +} + +.quiz-home__resume .inline-banner { + padding-left: var(--pad-md); + padding-right: var(--pad-md); +} + +.quiz-home__rule { + border: 0; + border-top: 1px solid var(--border); + margin: var(--quiz-home-rule-top) 0 var(--quiz-home-rule-bottom); +} + +.quiz-home__rule--tight { + margin: var(--quiz-home-rule-bottom) 0 0; +} + +/* ── The proposal card ──────────────────────────────────────────────── */ + +.quiz-home__card { + display: flex; + align-items: stretch; + gap: var(--quiz-home-card-gap); +} + +.quiz-home__card-main { + flex: 1; + min-width: var(--quiz-home-card-min); +} + +/* The eyebrow and Cancel span the WHOLE card, not just its text column, so + Cancel sits at the top-right of the proposal region (§5 B1.8) rather than + floating in the gutter between the text and the neighbourhood. */ +.quiz-home__card-head { + display: flex; + align-items: baseline; + gap: var(--pad-md); +} + +.quiz-home__card-head .quiz-eyebrow { + flex: 1; + min-width: 0; + margin-bottom: 0; +} + +.quiz-home__name { + display: flex; + align-items: center; + gap: var(--quiz-home-name-gap); + margin-top: var(--pad-md); +} + +.quiz-home__name-text { + font-size: var(--quiz-home-name-fs); + color: var(--text); +} + +.quiz-home__meta { + margin: 12px 0 0; + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-home__rationale { + margin: 2px 0 0; + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-home__definition { + margin: 14px 0 0; + max-width: var(--quiz-home-def-width); + font-size: var(--quiz-home-def-fs); + line-height: 1.6; + color: var(--text-dim); + text-wrap: pretty; +} + +.quiz-home__config { + margin: var(--pad-md) 0 0; + font-size: var(--fs-base); + color: var(--text-dim); +} + +.quiz-home__actions { + display: flex; + align-items: center; + gap: var(--quiz-home-actions-gap); + margin-top: var(--quiz-home-actions-top); +} + +/* The vertical hairline between the card and its neighbourhood. */ +.quiz-home__card-divider { + width: 1px; + flex-shrink: 0; + background: var(--border); +} + +.quiz-home__neighbourhood { + flex-shrink: 0; + align-self: center; +} + +/* ── Rows: alternatives, review-due, pick list ──────────────────────── */ + +.quiz-home__row { + display: flex; + align-items: baseline; + gap: 12px; + padding: var(--quiz-home-row-pad); + /* The rows bleed 8px past the column so their hover/focus surface extends + under the text, exactly as the design draws it. */ + margin: 0 var(--quiz-home-row-bleed); + width: calc(100% - 2 * var(--quiz-home-row-bleed)); + border: 0; + border-radius: var(--r-sm); + background: none; + text-align: left; + cursor: pointer; + transition: background var(--dur-fast) var(--ease); +} + +.quiz-home__row:hover { + background: color-mix(in srgb, var(--text) 3%, transparent); +} + +.quiz-home__row--ruled { + border-bottom: 1px solid var(--border); + border-radius: 0; +} + +.quiz-home__row-mark { + flex-shrink: 0; + align-self: center; + display: inline-flex; +} + +/* The review-due row has no mastery to draw, so its dot is an outline. */ +.quiz-home__row-mark--hollow { + width: var(--quiz-home-dot-sm); + height: var(--quiz-home-dot-sm); + border: 1px solid var(--text-muted); + border-radius: var(--r-full); +} + +.quiz-home__row-name { + font-size: var(--fs-lg); + color: var(--text); +} + +.quiz-home__row-code { + font-family: var(--font-mono); + font-size: var(--fs-2xs); + letter-spacing: var(--quiz-home-code-tracking); + color: var(--text-muted); +} + +.quiz-home__row-spacer { + flex: 1; +} + +.quiz-home__row-meta { + font-size: var(--fs-md); + color: var(--text-muted); + text-align: right; +} + +.quiz-home__pick-open { + margin-top: var(--pad-lg); +} + +/* ── Pick something specific ────────────────────────────────────────── */ + +.quiz-home__pick { + max-width: var(--quiz-home-pick-width); +} + +.quiz-home__pick-title { + margin: 12px 0 0; + font-size: var(--quiz-home-pick-title-fs); + line-height: 1.3; + color: var(--text); +} + +.quiz-home__pick-eyebrow { + margin-top: var(--pad-lg); +} + +.quiz-home__pick-group { + display: flex; + align-items: center; + gap: 8px; + margin: 34px 0 2px; +} + +/* ── Empty, loading and error ───────────────────────────────────────── */ + +.quiz-home__skeleton { + display: flex; + align-items: stretch; + gap: var(--quiz-home-card-gap); +} + +.quiz-home__skeleton-main { + flex: 1; + min-width: var(--quiz-home-card-min); + display: flex; + flex-direction: column; + gap: var(--pad-md); +} + +.quiz-home__skeleton-name { + display: flex; + align-items: center; + gap: var(--quiz-home-name-gap); +} + +/* ── The two dialogs ────────────────────────────────────────────────── */ + +.quiz-home-dialog__title { + margin: 0; + font-size: var(--fs-xl); + font-weight: 700; + color: var(--text); +} + +.quiz-home-dialog__subtitle { + margin: 4px 0 var(--pad-lg); + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-home-dialog__card { + display: flex; + align-items: stretch; + gap: var(--quiz-home-dialog-gap); +} + +.quiz-home-dialog__main { + flex: 1; + min-width: var(--quiz-home-dialog-card-min); + padding-top: 4px; +} + +.quiz-home-dialog__name { + margin: 0; + font-size: var(--quiz-home-dialog-name-fs); + line-height: 1.2; + color: var(--text); +} + +.quiz-home-dialog__meta { + margin: 8px 0 0; + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-home-dialog__rationale { + margin: 2px 0 0; + font-size: var(--fs-md); + color: var(--text-muted); +} + +.quiz-home-dialog__definition { + margin: 14px 0 0; + max-width: var(--quiz-home-dialog-def-width); + font-size: var(--quiz-home-def-fs); + line-height: 1.6; + color: var(--text-dim); + text-wrap: pretty; +} + +.quiz-home-dialog__note { + min-height: 34px; + padding-top: 8px; + margin: 0; + font-size: var(--fs-sm); + color: var(--text-muted); + text-wrap: pretty; +} + +.quiz-home-dialog__footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: var(--pad-lg); + padding-top: 18px; + border-top: 1px solid var(--border); +} + +/* ── Settings rows (shared by both dialogs) ─────────────────────────── */ + +.quiz-home-settings { + margin-top: var(--pad-lg); + border-top: 1px solid var(--border); +} + +/* Inside the adjust dialog the rows follow a subtitle, which already carries + the gap; the rule above them is the only separation needed. */ +.quiz-home-settings--flush { + margin-top: 0; +} + +.quiz-home-settings__row { + display: flex; + align-items: baseline; + gap: 8px; + padding: 12px 0 4px; +} + +.quiz-home-settings__label { + width: var(--quiz-home-setting-label-w); + flex-shrink: 0; +} From 973a81ef85d8e77051ab42947f431ec528058a6d Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:14:10 -0400 Subject: [PATCH 34/60] feat(ui): Button forwards its ref (#537) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question screen needs "Ask about this" as a return-focus target for the Ask panel's Sheet, and without a forwarded ref it had to drop to a raw , + ); + expect(ref.current).toBe(screen.getByRole("button", { name: "Ask about this" })); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); + + it("is a usable initialFocusRef for an overlay, which is why the ref exists", () => { + function Harness() { + const ref = React.useRef(null); + return ( + <> + + {}} title="Ask about this" initialFocusRef={ref}> +

body

+
+ + ); + } + render(); + // The overlay only ever reads `.current`; proving the ref is populated with + // the real node is what makes it a legal target. + expect(screen.getByRole("button", { name: "Ask about this" })).toBeInstanceOf( + HTMLButtonElement, + ); + }); + it("exposes the open-dialog state the quiz's `adjust` link needs", () => { render(
- {item.explanation && open && ( -

+ {/* Always in the DOM so `aria-controls` never points at nothing — + an IDREF to a missing element is invalid ARIA and some screen + readers announce the control as broken. `hidden` occupies no + space, so the expand behaves exactly as before. */} + {item.explanation && ( +

)} diff --git a/frontend/src/components/quiz/results/QuizResults.test.tsx b/frontend/src/components/quiz/results/QuizResults.test.tsx index 7f8ce332..e329ccca 100644 --- a/frontend/src/components/quiz/results/QuizResults.test.tsx +++ b/frontend/src/components/quiz/results/QuizResults.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; import React from "react"; import { markRadius } from "@/components/graph/ConceptNode"; import { __resetReducedMotionStoreForTests } from "@/lib/usePrefersReducedMotion"; @@ -141,7 +141,11 @@ function actions(): QuizActions { function renderResults( over: Partial = {}, - opts: { prefersReducedMotion?: boolean; acts?: QuizActions } = {}, + opts: { + prefersReducedMotion?: boolean; + acts?: QuizActions; + nextConcept?: { id: string; name: string } | null; + } = {}, ) { const acts = opts.acts ?? actions(); const view = render( @@ -155,11 +159,21 @@ function renderResults( ], }} prefersReducedMotion={opts.prefersReducedMotion ?? true} + nextConcept={opts.nextConcept} />, ); return { ...view, acts }; } +/** The growth circle's drawn radius and its scale ratio — 1 once grown. */ +function growthMark(container: HTMLElement) { + const body = container.querySelector(".concept-node__body--growth")!; + return { + r: Number(body.getAttribute("r")), + grow: Number(body.getAttribute("style")?.match(/--concept-grow:\s*([\d.]+)/)?.[1]), + }; +} + const PERFECT: Partial = { result: { ...RESULT, @@ -232,22 +246,28 @@ describe("QuizResults", () => { it("toggles the explanation disclosure and reveals the text", () => { renderResults(); const toggle = screen.getByTestId("quiz-missed-explain-102"); + // The panel is always in the DOM (so `aria-controls` resolves) and hidden + // until asked for, so visibility — not presence — is the assertion. + const panel = document.getElementById(toggle.getAttribute("aria-controls")!)!; + expect(panel).toHaveTextContent("Without a base case the recursion never stops."); + expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect( - screen.queryByText("Without a base case the recursion never stops."), - ).toBeNull(); + expect(panel).not.toBeVisible(); fireEvent.click(toggle); expect(toggle).toHaveAttribute("aria-expanded", "true"); - expect( - screen.getByText("Without a base case the recursion never stops."), - ).toBeInTheDocument(); + expect(panel).toBeVisible(); fireEvent.click(toggle); expect(toggle).toHaveAttribute("aria-expanded", "false"); - expect( - screen.queryByText("Without a base case the recursion never stops."), - ).toBeNull(); + expect(panel).not.toBeVisible(); + }); + + it("names the missed region from its eyebrow", () => { + renderResults(); + const list = screen.getByTestId("quiz-missed-list"); + const heading = document.getElementById(list.getAttribute("aria-labelledby")!); + expect(heading).toHaveTextContent("One to look at"); }); it("opens the AskPanel seeded from the row that asked", () => { @@ -277,6 +297,21 @@ describe("QuizResults", () => { expect(screen.queryByTestId("quiz-practise-missed")).toBeNull(); }); + it("restarts the same concept on the session's own config", () => { + const { acts } = renderResults(PERFECT); + fireEvent.click(screen.getByTestId("quiz-again")); + // No config override: `useQuizSession.start` defaults it to the live + // session's config, so omitting it IS "the same quiz again" — passing + // prefs here would silently change the length or difficulty. + expect(acts.start).toHaveBeenCalledTimes(1); + expect(acts.start).toHaveBeenCalledWith({ + intent: "practice", + scope: { kind: "concept", conceptId: "recursion" }, + conceptId: "recursion", + courseId: "cs101", + }); + }); + it("offers the next concept while the queue has more, and calls nextInQueue", () => { const { acts } = renderResults({ scope: { kind: "course", courseId: "cs101", queue: ["recursion", "base-cases"] }, @@ -287,6 +322,30 @@ describe("QuizResults", () => { expect(acts.nextInQueue).toHaveBeenCalledTimes(1); }); + it("names the next concept when the graph could resolve it", () => { + renderResults( + { + scope: { kind: "course", courseId: "cs101", queue: ["recursion", "base-cases"] }, + queueIndex: 0, + }, + { nextConcept: { id: "base-cases", name: "Base cases" } }, + ); + expect(screen.getByTestId("quiz-next-concept")).toHaveTextContent("Next: Base cases →"); + }); + + it("still offers the queue exit unnamed when the next id can't be named", () => { + // `nextConcept` is a LABEL: a next id outside the scoped graph must not + // hide the exit, and must not get an invented name. + renderResults( + { + scope: { kind: "due", queue: ["recursion", "off-graph"] }, + queueIndex: 0, + }, + { nextConcept: null }, + ); + expect(screen.getByTestId("quiz-next-concept")).toHaveTextContent("Next concept →"); + }); + it("falls back to practising the missed questions, and calls practiseMissed", () => { const { acts } = renderResults(); const button = screen.getByTestId("quiz-practise-missed"); @@ -295,7 +354,9 @@ describe("QuizResults", () => { expect(acts.practiseMissed).toHaveBeenCalledTimes(1); }); - it("counts the missed questions in the practise label", () => { + it("says 'ones' — uncounted — when more than one was missed", () => { + // The prototype's `practiseLabel`: never a count. The count is already the + // eyebrow above the list. renderResults({ result: { ...RESULT, @@ -303,11 +364,34 @@ describe("QuizResults", () => { results: RESULT.results.map((r, i) => (i === 0 ? { ...r, correct: false } : r)), }, }); + expect(screen.getByTestId("quiz-missed-list")).toHaveTextContent("2 to look at"); expect(screen.getByTestId("quiz-practise-missed")).toHaveTextContent( - "Practise the 2 you missed", + "Practise the ones you missed", ); }); + it("says so when an item was submitted unanswered", () => { + // `selected` is "" on the wire for a skipped item; "You chose · …" would + // read as a rendering bug. + const missed = buildMissedItems( + session({ + result: { + ...RESULT, + results: RESULT.results.map((r, i) => (i === 1 ? { ...r, selected: "" } : r)), + }, + }), + ); + expect(missed[0]).toMatchObject({ chosenLabel: "", chosenText: "" }); + + renderResults({ + result: { + ...RESULT, + results: RESULT.results.map((r, i) => (i === 1 ? { ...r, selected: "" } : r)), + }, + }); + expect(screen.getByTestId("quiz-missed-102")).toHaveTextContent("No answer · the answer is B"); + }); + it("labels the secondary exit from the source and calls exit()", () => { const { acts } = renderResults({ source: { kind: "notes", noteId: "n1" } }); const back = screen.getByTestId("quiz-back-to-source"); @@ -332,10 +416,29 @@ describe("QuizResults", () => { it("renders the grown node at its end state immediately under reduced motion", () => { const { container } = renderResults({}, { prefersReducedMotion: true }); - const body = container.querySelector(".concept-node__body--growth")!; // The end state, first paint: the after-radius, at full scale — no // transition to run. - expect(Number(body.getAttribute("r"))).toBeCloseTo(markRadius(0.46, false, 2.5), 5); - expect(Number(body.getAttribute("style")?.match(/--concept-grow:\s*([\d.]+)/)?.[1])).toBe(1); + const mark = growthMark(container); + expect(mark.r).toBeCloseTo(markRadius(0.46, false, 2.5), 5); + expect(mark.grow).toBe(1); + }); + + it("grows once and does not replay when the screen re-renders", async () => { + // With motion on, the mark starts at the BEFORE ratio and grows to 1. The + // headline behaviour of this screen is that it then stays there: a + // disclosure toggle (or opening the AskPanel) must not restart it. + const { container } = renderResults({}, { prefersReducedMotion: false }); + expect(growthMark(container).grow).toBeCloseTo( + markRadius(0.29, false, 2.5) / markRadius(0.46, false, 2.5), + 3, + ); + await waitFor(() => expect(growthMark(container).grow).toBe(1)); + + fireEvent.click(screen.getByTestId("quiz-missed-explain-102")); + expect(screen.getByTestId("quiz-missed-explain-102")).toHaveAttribute("aria-expanded", "true"); + + const after = growthMark(container); + expect(after.grow).toBe(1); + expect(after.r).toBeCloseTo(markRadius(0.46, false, 2.5), 5); }); }); diff --git a/frontend/src/components/quiz/results/QuizResults.tsx b/frontend/src/components/quiz/results/QuizResults.tsx index 8ac18460..ca1604e1 100644 --- a/frontend/src/components/quiz/results/QuizResults.tsx +++ b/frontend/src/components/quiz/results/QuizResults.tsx @@ -50,7 +50,6 @@ export interface QuizResultsProps { * `null` means "no name available", which covers a finished queue AND a next * id that isn't in the scoped graph. It is a LABEL only: whether that exit * renders at all stays `queueOf(session.scope).length > session.queueIndex + 1`. - * Optional so the current render keeps compiling until it reads this. */ nextConcept?: { id: string; name: string } | null; } @@ -63,6 +62,7 @@ export function QuizResults({ concept, neighbourhood, prefersReducedMotion, + nextConcept, }: QuizResultsProps) { // AskPanel needs the viewer's id and the seam (`QuizResultsProps`) is fixed, // so it comes off the same context `QuizScreen` reads. The context has a @@ -121,6 +121,9 @@ export function QuizResults({ scale={CANVAS.scale} centreVariant={{ kind: "growth", before, after }} animate={!prefersReducedMotion} + // The name is set below the canvas in the display serif; captioning + // the centre too would say it twice. The siblings stay captioned. + showCentreLabel={false} ariaLabel={growthLabel} testid="quiz-results-graph" /> @@ -166,18 +169,17 @@ export function QuizResults({
-
+
{hasNext ? ( ) : missed.length > 0 ? ( ) : ( - -
- - )} - - {phase === "active" && currentQuestion && ( - <> -
-
Question {qIndex + 1} of {questions.length}
-
- {difficulty === "adaptive" && resolvedDifficulty && ( -
- Adaptive · {resolvedDifficulty} -
- )} -
{currentQuestion.difficulty}
-
-
-
{currentQuestion.question}
-
- {currentQuestion.options.map(o => { - const selected = currentSelection === o.label; - return ( - - ); - })} -
-
- - -
- - )} - - {phase === "review" && currentQuestion && ( - <> -
Review
-
- {lastCorrect ? "Correct." : "Not quite."} -
-
{currentQuestion.question}
-
- {currentQuestion.options.map(o => { - const picked = currentSelection === o.label; - const right = o.correct; - const bg = right ? "var(--accent-soft)" : picked ? "var(--err-soft)" : "var(--bg-subtle)"; - const color = right ? "var(--accent)" : picked ? "var(--err)" : "var(--text-dim)"; - return ( -
- {o.label}. - {o.text} - {right && } - {!right && picked && } -
- ); - })} -
-
- {currentQuestion.explanation} -
-
- - -
- - )} - - {phase === "results" && results && ( - <> -
Results
-
- {Math.round((results.score / Math.max(1, results.total)) * 100)}% -
-
- {results.score} / {results.total} correct · mastery{" "} - = results.mastery_before ? "var(--accent)" : "var(--err)", fontWeight: 600 }}> - {Math.round(results.mastery_before * 100)}% → {Math.round(results.mastery_after * 100)}% - -
-
- - -
- - )} -
- ); -} diff --git a/frontend/src/components/quiz/question/AskPanel.test.tsx b/frontend/src/components/quiz/question/AskPanel.test.tsx index c8731ab9..792e0764 100644 --- a/frontend/src/components/quiz/question/AskPanel.test.tsx +++ b/frontend/src/components/quiz/question/AskPanel.test.tsx @@ -172,6 +172,50 @@ describe("AskPanel", () => { expect(api.streamChat.mock.calls[1][2]).toBe(composeAskMessage(SEED)); }); + it("keeps a half-written answer when the stream dies mid-sentence (ADR 0020)", async () => { + api.streamChat.mockImplementationOnce(async (...args: unknown[]) => { + const handlers = args[6] as { onToken?: (d: string) => void }; + handlers.onToken?.("A base case is "); + handlers.onToken?.("the branch that"); + throw new Error("the tutor was interrupted"); + }); + + renderPanel(); + + // The partial the student was already reading stays on screen, marked. It + // is NOT blinked out and replaced by an error strip. + await screen.findByText("A base case is the branch that"); + expect(screen.getByText(/Interrupted/)).toBeInTheDocument(); + expect(screen.getByRole("alert")).toHaveTextContent("the tutor was interrupted"); + // Tokens appeared, so the JSON rung is skipped: never silently re-run a + // turn the student has already partly read. + expect(api.sendChat).not.toHaveBeenCalled(); + + // Retry drops the fragment rather than stacking a whole answer under it. + await act(async () => { + fireEvent.click(screen.getByTestId("quiz-ask-retry")); + }); + await screen.findByText("The base case is the exit."); + expect(screen.queryByText("A base case is the branch that")).toBeNull(); + expect(screen.queryByText(/Interrupted/)).toBeNull(); + expect(screen.queryByTestId("quiz-ask-retry")).toBeNull(); + }); + + it("aborts an in-flight stream on unmount", async () => { + let signal: AbortSignal | undefined; + api.streamChat.mockImplementationOnce(async (...args: unknown[]) => { + signal = (args[6] as { signal?: AbortSignal }).signal; + return new Promise(() => {}); // never settles — the tab goes away first + }); + + const { unmount } = renderPanel(); + await waitFor(() => expect(signal).toBeDefined()); + expect(signal!.aborted).toBe(false); + + unmount(); + expect(signal!.aborted).toBe(true); + }); + it("uses the JSON start route when the streamed one falls over", async () => { api.startSessionStream.mockRejectedValueOnce(new Error("no stream")); renderPanel(); diff --git a/frontend/src/components/quiz/question/AskPanel.tsx b/frontend/src/components/quiz/question/AskPanel.tsx index 08e14b80..44c789e9 100644 --- a/frontend/src/components/quiz/question/AskPanel.tsx +++ b/frontend/src/components/quiz/question/AskPanel.tsx @@ -82,6 +82,9 @@ interface AskTurn { id: number; role: "user" | "assistant"; text: string; + /** ADR 0020: the stream was cut off after producing this much. The partial + * stays in the thread, marked; Retry drops it and starts the answer over. */ + interrupted?: boolean; } /** @@ -147,6 +150,10 @@ export function AskPanel({ setError(null); setStreaming(""); let sawToken = false; + // The partial reply so far. `streaming` state can't serve here: it is + // cleared before the JSON leg and again in `finally`, and ADR 0020 needs + // the text to outlive both. + let partial = ""; try { let sid = sessionIdRef.current; @@ -189,6 +196,7 @@ export function AskPanel({ const res = await streamChat(sid, userId, message, TUTOR_MODE, true, undefined, { onToken: delta => { if (delta.trim()) sawToken = true; + partial += delta; setStreaming(prev => (prev ?? "") + delta); }, signal: controller.signal, @@ -198,6 +206,7 @@ export function AskPanel({ if (controller.signal.aborted) return; // Tokens already on screen, or a failure the JSON route would repeat // identically (#151a) — surface it rather than silently re-running. + // The partial survives via the outer catch. if (sawToken || !shouldFallBackToJson(err)) throw err; setStreaming(null); const res = await sendChat(sid, userId, message, TUTOR_MODE, true); @@ -208,6 +217,16 @@ export function AskPanel({ setTurns(t => [...t, { id: nextId(), role: "assistant", text: reply }]); } catch (err) { if (controller.signal.aborted || runRef.current !== token) return; + // ADR 0020, and the whole reason `partial` exists: half an answer the + // student was already reading must not blink out and be replaced by an + // error strip. It stays in the thread, marked unfinished. Retry drops + // it (`retry` below) so the second attempt doesn't read as a sequel. + if (partial.trim()) { + setTurns(t => [ + ...t, + { id: nextId(), role: "assistant", text: partial, interrupted: true }, + ]); + } setError(err instanceof Error ? err.message : "The tutor is unavailable."); } finally { if (runRef.current === token) { @@ -253,6 +272,16 @@ export function AskPanel({ returnFocusTo?.current?.focus(); }, [open, returnFocusTo]); + /** + * Re-send the turn that failed. The interrupted partial is dropped first: + * the retry produces a whole answer, and leaving the fragment above it would + * read as the first half of the same reply. + */ + const retry = () => { + setTurns(t => (t.length > 0 && t[t.length - 1].interrupted ? t.slice(0, -1) : t)); + void runTurn(lastMessageRef.current); + }; + const send = (e: React.FormEvent) => { e.preventDefault(); const text = draft.trim(); @@ -280,7 +309,7 @@ export function AskPanel({ {seed.explanation && (

{seed.explanation}

)} - Asking the tutor about {subtitle}. + Asking the tutor about {subtitle}.
@@ -292,6 +321,9 @@ export function AskPanel({ ) : (
{turn.text} + {turn.interrupted && ( +

Interrupted — the tutor didn't finish.

+ )}
), )} @@ -311,7 +343,7 @@ export function AskPanel({ type="button" className="btn btn--sm" data-testid="quiz-ask-retry" - onClick={() => void runTurn(lastMessageRef.current)} + onClick={retry} > Try again diff --git a/frontend/src/components/quiz/question/QuizQuestion.test.tsx b/frontend/src/components/quiz/question/QuizQuestion.test.tsx index 68bb6c5c..b1da41a0 100644 --- a/frontend/src/components/quiz/question/QuizQuestion.test.tsx +++ b/frontend/src/components/quiz/question/QuizQuestion.test.tsx @@ -282,6 +282,34 @@ describe("QuizQuestion — keyboard", () => { expect(answered.next).toHaveBeenCalledTimes(1); }); + it("listens on the screen root, not on window", () => { + const { actions } = renderScreen(makeSession()); + // The same keypress that selects when it reaches the root must do nothing + // when the quiz doesn't own the focus. + fireEvent.keyDown(document.body, { key: "c" }); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(actions.select).not.toHaveBeenCalled(); + expect(actions.requestLeave).not.toHaveBeenCalled(); + }); + + it("leaves Space to the answer row's own activation", () => { + const { actions } = renderScreen( + makeSession({ items: [item(0, { selectedIndex: 1 }), item(1), item(2)] }), + ); + const row = screen.getByTestId("quiz-answer-option-C"); + + // `fireEvent` returns false when the handler called preventDefault. Enter + // is the footer's action and IS pre-empted; Space must not be, or the + // row's native button activation never runs. + expect(fireEvent.keyDown(row, { key: " " })).toBe(true); + expect(fireEvent.keyDown(row, { key: "Enter" })).toBe(false); + expect(actions.submitAnswer).toHaveBeenCalledTimes(1); + + // ...and what the browser does on Space is a click. + fireEvent.click(row); + expect(actions.select).toHaveBeenCalledWith(2); + }); + it("Enter does nothing while no answer is chosen", () => { const { actions } = renderScreen(makeSession()); fireEvent.keyDown(root(), { key: "Enter" }); @@ -411,6 +439,43 @@ describe("QuizQuestion — the verdict", () => { expect(screen.getByTestId("quiz-submit-answer")).toHaveTextContent("Scoring…"); expect(screen.getByTestId("quiz-submit-answer")).toBeDisabled(); }); + + it("holds Submit while the hook says a call is in flight", () => { + const actions = makeActions(); + const session = makeSession({ items: [item(0, { selectedIndex: 1 }), item(1), item(2)] }); + const { rerender } = render( + , + ); + expect(screen.getByTestId("quiz-submit-answer")).toBeEnabled(); + + rerender( + , + ); + // Same phase, same selection — only `pending` moved, and it is enough. + expect(screen.getByTestId("quiz-submit-answer")).toBeDisabled(); + expect(screen.getByTestId("quiz-answer-option-A")).toHaveAttribute("aria-disabled", "true"); + + fireEvent.keyDown(root(), { key: "Enter" }); + fireEvent.keyDown(root(), { key: "d" }); + expect(actions.submitAnswer).not.toHaveBeenCalled(); + expect(actions.select).not.toHaveBeenCalled(); + }); }); describe("QuizQuestion — flag and generating", () => { @@ -446,10 +511,33 @@ describe("QuizQuestion — flag and generating", () => { expect(screen.queryByTestId("quiz-answer-options")).toBeNull(); }); - it("says so once when fewer questions arrived than were asked for", () => { - renderScreen(makeSession({ deliveredShort: true, items: [item(0), item(1)] })); - expect(toastApi.show).toHaveBeenCalledWith( - "Only 2 questions were ready for this concept.", + it("says so exactly once when fewer questions arrived than were asked for", () => { + const short = makeSession({ deliveredShort: true, items: [item(0), item(1)] }); + const { rerender } = renderScreen(short); + expect(toastApi.show).toHaveBeenCalledWith("Only 2 questions were ready for this concept."); + expect(toastApi.show).toHaveBeenCalledTimes(1); + + // Every re-render of the same attempt — a selection, a verdict, a flag — + // must not re-announce it. + rerender( + , + ); + rerender( + , ); expect(toastApi.show).toHaveBeenCalledTimes(1); }); @@ -528,9 +616,8 @@ describe("QuizQuestion — Ask about this never orphans the attempt", () => { expect(panel).toHaveTextContent("Because the base case is the exit."), ); - // Nothing about the attempt moved. - expect(actions.requestLeave).not.toHaveBeenCalled(); - expect(actions.next).not.toHaveBeenCalled(); + // Nothing about the attempt moved — not one machine event, of any kind. + Object.values(actions).forEach(fn => expect(fn).not.toHaveBeenCalled()); expect(screen.getByTestId("quiz-answer-options").innerHTML).toBe(optionsBefore); // Closing puts the student back exactly where they were. diff --git a/frontend/src/components/quiz/question/QuizQuestion.tsx b/frontend/src/components/quiz/question/QuizQuestion.tsx index d19c12d6..51a98c9c 100644 --- a/frontend/src/components/quiz/question/QuizQuestion.tsx +++ b/frontend/src/components/quiz/question/QuizQuestion.tsx @@ -90,7 +90,14 @@ function isTypingTarget(target: EventTarget | null): boolean { return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; } -export function QuizQuestion({ session, actions, concept, userId, courseId }: QuizQuestionProps) { +export function QuizQuestion({ + session, + actions, + concept, + userId, + courseId, + pending = false, +}: QuizQuestionProps) { const toast = useToast(); const rootRef = useRef(null); @@ -105,21 +112,6 @@ export function QuizQuestion({ session, actions, concept, userId, courseId }: Qu * and carrying it across would be answering the wrong question. */ const [askForCursor, setAskForCursor] = useState(null); - /** - * The item whose `/answer` call is in flight, as a key that changes whenever - * the machine moves. - * - * `useQuizSession` exposes exactly this as `pending`, but `QuizQuestionProps` - * (the A2 seam) doesn't carry it and `QuizScreen` doesn't pass it, so the - * screen keeps its own latch rather than widening the seam unilaterally. - * The phase can't stand in: it stays `active` for the whole round trip, so - * without this the Submit button is live again the instant it is pressed. - * - * Stored as the key rather than a boolean cleared in an effect, so "the call - * landed" is DERIVED from the session that came back — no reset render, and - * no way for the latch to get stuck if a transition is missed. - */ - const [busyKey, setBusyKey] = useState(null); const { phase, cursor, items } = session; const item = items[cursor]; @@ -132,9 +124,16 @@ export function QuizQuestion({ session, actions, concept, userId, courseId }: Qu // only decides whether the verdict is ever shown. const showVerdict = session.config.feedback === "as-you-go" && item?.verdict != null; - // Both derived, so a session that moved is the only thing that clears them. - const answerKey = `${session.attemptId}:${cursor}:${phase}:${item?.verdict ? "scored" : "open"}`; - const busy = busyKey === answerKey; + /** + * A quiz call is in flight. Straight off the hook (`useQuizSession`'s + * `pending`, on the props since A2's fix round 4): the phase can't say it, + * because it stays `active` for the whole `/answer` round trip — and the + * hook is the only thing that knows about the calls it refuses (a missing + * `attemptId`, a duplicate press), so a screen-local latch would stay stuck + * on exactly those. + */ + const busy = pending; + // Derived, so a new question is the only thing that closes the sheet. const askOpen = askForCursor === cursor; const selectable = phase === "active" && !busy; @@ -174,9 +173,8 @@ export function QuizQuestion({ session, actions, concept, userId, courseId }: Qu const submit = useCallback(() => { if (phase !== "active" || busy || selectedIndex === null) return; - setBusyKey(answerKey); actions.submitAnswer(); - }, [actions, answerKey, busy, phase, selectedIndex]); + }, [actions, busy, phase, selectedIndex]); const flag = () => { const wasFlagged = item?.flagged ?? false; @@ -437,18 +435,14 @@ export function QuizQuestion({ session, actions, concept, userId, courseId }: Qu This question is confusing {phase === "answered" && ( - // A raw + )}
diff --git a/frontend/src/components/quiz/question/question.css b/frontend/src/components/quiz/question/question.css index 87115170..15f9da31 100644 --- a/frontend/src/components/quiz/question/question.css +++ b/frontend/src/components/quiz/question/question.css @@ -23,6 +23,11 @@ --quiz-q-skeleton-row: 55px; /* one `AnswerOption` row, while generating */ --quiz-q-skeleton-stem: 26px; --quiz-q-skeleton-line: 13px; + /* The skeleton row's left inset is `AnswerOption`'s own (`padding: 17px 16px + 17px 14px`, globals.css), so the placeholder starts where the answer text + will. Not `--answer-letter-w`: that is the letter column's WIDTH, which is + the same number today by coincidence, not by construction. */ + --quiz-q-skeleton-inset: 14px; display: flex; align-items: stretch; @@ -145,7 +150,10 @@ margin-top: var(--quiz-q-aside-mt); } -.quiz-question__flag { +/* The prototype's flag link is 12px, one step below the body text around it. + `.btn.btn--link` (0,2,0) sets 13px, so this has to be equally specific to + land — a bare `.quiz-question__flag` is a dead rule. */ +.btn.quiz-question__flag { font-size: var(--fs-sm); } @@ -206,7 +214,7 @@ display: flex; align-items: center; height: var(--quiz-q-skeleton-row); - padding: 0 var(--pad-md) 0 14px; + padding: 0 var(--pad-md) 0 var(--quiz-q-skeleton-inset); border-bottom: 1px solid var(--border); } @@ -226,13 +234,38 @@ /* ── Ask panel ──────────────────────────────────────────────────────── */ +/* The panel is portalled to , so it is NOT inside `.quiz-question` and + inherits none of its tokens. Its own geometry is declared here, on its own + root, for the same reason. (`--quiz-accent` is likewise out of scope from + here and falls through to `--accent`, which is the honest degradation: the + sheet floats over the app, not inside the course-tinted screen.) */ .quiz-ask { + --quiz-ask-label-gap: 6px; /* between "You chose C ·" and the answer text */ + --quiz-ask-input-pad-y: 8px; /* the composer's field, matching `.btn` height */ + display: flex; flex-direction: column; gap: var(--pad-md); min-height: 100%; } +/* The panel's own visually-hidden line. Deliberately NOT + `.quiz-question__hint`: that one is the question screen's keyboard hint, and + restyling it must not silently restyle the sheet. Same canonical recipe — + the 1px/-1px here are what keep a node in the accessibility tree while + painting nothing, not design measurements. */ +.quiz-ask__sr { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + /* The seeded context, restated as static cards so the student can see exactly what the tutor was told — nothing here is a message they can edit. */ .quiz-ask__seed { @@ -269,7 +302,7 @@ .quiz-ask__seed-label { color: var(--text-muted); - margin-right: 6px; + margin-right: var(--quiz-ask-label-gap); } .quiz-ask__seed-explanation { @@ -306,6 +339,14 @@ color: var(--text-muted); } +/* ADR 0020: a reply the student was already reading survives the failure that + cut it off, marked as unfinished rather than silently passed off as whole. */ +.quiz-ask__interrupted { + margin: var(--pad-sm) 0 0; + font-size: var(--fs-sm); + color: var(--text-muted); +} + .quiz-ask__error { display: flex; align-items: center; @@ -331,7 +372,7 @@ .quiz-ask__input { flex: 1; min-width: 0; - padding: 8px var(--pad-md); + padding: var(--quiz-ask-input-pad-y) var(--pad-md); border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--bg-panel); diff --git a/frontend/src/components/screens/Quiz.tsx b/frontend/src/components/screens/Quiz.tsx deleted file mode 100644 index 037602e5..00000000 --- a/frontend/src/components/screens/Quiz.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import React, { Suspense, useEffect, useMemo, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { TopBar } from "../TopBar"; -import { FullHeightScreen } from "../FullHeightScreen"; -import { AIDisclaimerChip } from "../chat/AIDisclaimerChip"; -import { DisclaimerModal } from "../DisclaimerModal"; -import { QuizPanel } from "../QuizPanel"; -import { useUser } from "@/context/UserContext"; -import { useActiveSemester, courseInTerm } from "@/lib/useActiveSemester"; -import { getCourses, getGraph, type EnrolledCourse } from "@/lib/api"; -import type { GraphNode as ApiNode } from "@/lib/types"; - -type Concept = { id: string; name: string; course_id: string | null; course_code: string | null; term: string | null; terms: string[] | null }; - -export function Quiz() { - return ( - Loading…
}> - - - ); -} - -function QuizInner() { - const router = useRouter(); - const searchParams = useSearchParams(); - const { userId, userReady } = useUser(); - const [activeSemester, , semesterHydrated] = useActiveSemester(); - - const [concepts, setConcepts] = useState([]); - const [courses, setCourses] = useState([]); - const [loaded, setLoaded] = useState(false); - - // Waits for the active-semester read from localStorage before the first - // fetch, so returning users fetch scoped once instead of unscoped-then-scoped; - // re-runs when the active semester changes. - useEffect(() => { - if (!userReady || !userId || !semesterHydrated) return; - let cancelled = false; - (async () => { - try { - const [cRes, gRes] = await Promise.all([ - getCourses(userId).catch(() => ({ courses: [] as EnrolledCourse[] })), - getGraph(userId, activeSemester || undefined).catch(() => ({ nodes: [] as ApiNode[], edges: [], stats: {} })), - ]); - if (cancelled) return; - setCourses(cRes.courses ?? []); - const courseById = new Map((cRes.courses ?? []).map(c => [c.course_id, c])); - const nodes = (gRes.nodes ?? []) as ApiNode[]; - setConcepts( - nodes - .filter(n => !n.is_subject_root) - .map(n => ({ - id: n.id, - name: n.concept_name || "Concept", - course_id: n.course_id ?? null, - course_code: n.course_id ? (courseById.get(n.course_id)?.course_code ?? null) : null, - term: n.course_id ? (courseById.get(n.course_id)?.term ?? null) : null, - terms: n.course_id ? (courseById.get(n.course_id)?.terms ?? null) : null, - })), - ); - } catch (err) { - console.error("quiz bootstrap failed", err); - } finally { - if (!cancelled) setLoaded(true); - } - })(); - return () => { cancelled = true; }; - }, [userReady, userId, semesterHydrated, activeSemester]); - - // The graph fetch above is already scoped to the active semester, so this - // is defensive rather than load-bearing — matches the Tree/Learn pickers. - const scopedConcepts = useMemo( - () => (activeSemester ? concepts.filter(c => courseInTerm(c, activeSemester)) : concepts), - [concepts, activeSemester], - ); - - // Scope the course picker to the active semester too, so it stays consistent - // with the concept list the quiz draws from. - const scopedCourses = useMemo( - () => (activeSemester ? courses.filter(c => courseInTerm(c, activeSemester)) : courses), - [courses, activeSemester], - ); - - const topicParam = searchParams.get("topic"); - const conceptParam = searchParams.get("concept"); - - const initialConceptId = useMemo(() => { - if (conceptParam) return conceptParam; - if (!topicParam) return null; - const t = topicParam.trim().toLowerCase(); - return scopedConcepts.find(c => c.name.toLowerCase() === t)?.id ?? null; - }, [conceptParam, topicParam, scopedConcepts]); - - return ( - - - } - /> -
- {!userReady ? null : !userId ? ( -
Sign in to take a quiz.
- ) : loaded ? ( - router.push("/learn")} - /> - ) : null} -
-
- ); -} From bec932e1321414a1c2d046019c5a3478e7d92eb5 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:37:30 -0400 Subject: [PATCH 40/60] =?UTF-8?q?fix(quiz):=20quiz=20home=20=E2=80=94=20re?= =?UTF-8?q?view=20round=201=20(#537=20B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I1 — the Cancel test passed on the fallback. `mount()` now takes a session override, and Cancel is pinned in BOTH directions: a session carrying `source.returnTo` lands there, one without lands on `/dashboard`. A regression that ignored `returnTo` outright now fails. I2 — R-1. Every bare px outside the token block is hoisted into a named token (~20 of them), and the header comment is true again: the only lengths left below the block are 1px hairlines, the house idiom. The rhythm tokens are now FIXED throughout rather than half `--pad-*` — the card used to half-respond to the density setting and half not. That also closes the review's small fidelity deltas (the eyebrow gap, the pick-open margin, the dialog subtitle and footer), which were all `--pad-*` standing in for a design value it didn't match. Minors: "Also worth a look" is gated on there being something under it; Cancel is lifted out of the proposal into a head row that renders in every no-card state (empty tree, no courses, mastered, error) so no arrival is a dead end; the pick list's course dots use the `dot` variant, losing a glow the design doesn't draw; the ruled pick rows keep their 6px radius and the due row's hollow mark is an 8px ring centred in the 11px box, both as drawn; the concept dialog's stand-in definition says only the part its meta line doesn't already carry ("1 connected concept on your tree") instead of restating the course code and tier back at itself; `?topic=` and the notes "From your note" rationale are covered. A2's seam, consumed: the content below the resume strip is wrapped in `.quiz-inset--home` > `.quiz-col quiz-col--home`, so the strip is a genuine full-bleed band under the TopBar and the `--pad-md` bleed hack and the `InlineBanner` padding override are gone. The two wrapper classes are NESTED rather than stacked on one element: `box-sizing: border-box` is global, so one div carrying both ate the 64px of page padding out of the 780px measure and set the card at 716. The card reads `home.cardDescription`, so a deep-linked concept finally gets its own AI sentence and the identity-guard workaround (and its test) are deleted. Gates: vitest 30 passed, tsc clean, eslint 0 problems. Co-Authored-By: Claude Fable 5 --- .../components/quiz/home/ConceptDialog.tsx | 25 ++- .../src/components/quiz/home/PickList.tsx | 4 + .../components/quiz/home/QuizHome.test.tsx | 108 ++++++++++- .../src/components/quiz/home/QuizHome.tsx | 150 ++++++++++----- frontend/src/components/quiz/home/home.css | 171 ++++++++++++------ 5 files changed, 336 insertions(+), 122 deletions(-) diff --git a/frontend/src/components/quiz/home/ConceptDialog.tsx b/frontend/src/components/quiz/home/ConceptDialog.tsx index c7f518f3..06cf8715 100644 --- a/frontend/src/components/quiz/home/ConceptDialog.tsx +++ b/frontend/src/components/quiz/home/ConceptDialog.tsx @@ -11,9 +11,9 @@ * does discard. * * The definition is R-8 for THIS concept: `concept-description` is fetched on - * open (the hook `useQuizHome` only describes the primary proposal), with the - * built sentence showing while it is in flight and after a failure. The dialog - * never blocks on it. + * open (the hook `useQuizHome` describes only the concept the card shows), with + * a built sentence showing while it is in flight and after a failure. The + * dialog never blocks on it. */ import React from "react"; @@ -25,13 +25,28 @@ import { describeConcept } from "@/lib/quiz/api"; import type { SessionConfig } from "@/lib/quiz/machine"; import { metaLine, type Candidate } from "@/lib/quiz/proposals"; import type { QuizConfig } from "@/lib/quiz/types"; -import { fallbackDefinition } from "@/lib/quiz/useQuizHome"; import { QuizSettings } from "./QuizSettings"; import { accentStyle } from "./accent"; /** The dialog's canvas, per §3. */ const CANVAS = { width: 300, height: 200, scale: 2 } as const; +/** + * The stand-in definition, minus everything the meta line directly above it + * already says. + * + * `useQuizHome.fallbackDefinition` is "{CODE} · {tier} · {n} connected + * concepts", which on the card sits under a meta line that carries neither the + * code nor (visibly) the tier. In the dialog the meta IS "{CODE} · {pct}% · + * {tier} · {when}", so the built sentence opened the panel by repeating its own + * first two thirds. Only the connection count is new, so only the connection + * count is said. + */ +function connectionsLine(connected: number): string { + if (connected === 0) return "Not yet connected to anything else on your tree."; + return `${connected} connected ${connected === 1 ? "concept" : "concepts"} on your tree.`; +} + export interface ConceptDialogProps { open: boolean; userId: string; @@ -95,7 +110,7 @@ export function ConceptDialog({ const { node, course, color } = candidate; const description = useConceptDescription(userId, open, node.concept_name, course?.course_code); - const definition = description ?? fallbackDefinition(candidate, connected); + const definition = description ?? connectionsLine(connected); // The design prefixes the concept's meta with its course code; `metaLine` // itself is unchanged. diff --git a/frontend/src/components/quiz/home/PickList.tsx b/frontend/src/components/quiz/home/PickList.tsx index 21dc772c..b03ea4fb 100644 --- a/frontend/src/components/quiz/home/PickList.tsx +++ b/frontend/src/components/quiz/home/PickList.tsx @@ -46,8 +46,12 @@ export function PickList({ groups, onPick, onBack }: PickListProps) { return (
+ {/* `dot`, not the default `node`: the design's course mark is a + flat circle, and the glow the node variant adds reads as a + halo the drawing doesn't have. */} ; entry?: EntryRequest; config?: QuizConfig | null } = {}) { +function mount( + over: { + home?: Partial; + entry?: EntryRequest; + config?: QuizConfig | null; + /** The live session. Cancel reads `session.source`, NOT the entry's. */ + session?: QuizSession; + } = {}, +) { const home = buildHome(over.home); const actions = buildActions(); const view = render( @@ -254,7 +262,7 @@ function mount(over: { home?: Partial; entry?: EntryRequest; confi config={over.config === undefined ? CONFIG : over.config} prefs={{ count: null, difficulty: null, feedback: "at-end" }} entry={over.entry ?? entry()} - session={session()} + session={over.session ?? session()} actions={actions} />, ); @@ -290,27 +298,55 @@ describe("QuizHome — the proposal", () => { ); }); - it("uses the built definition when the AI sentence is for another concept", () => { - // `primaryDescription` is tagged to `home.primary`; a deep link to a - // different concept must not inherit it. + it("captions a deep-linked card with the sentence fetched for THAT concept", () => { + // The hook describes the concept the CARD shows, deep link included (A2 fix + // round 5) — so the paragraph belongs to Matrices, not to the ranked primary. mount({ - home: { primaryDescription: "A function that calls itself." }, + home: { cardConceptId: "matrices", cardDescription: "A rectangular array of numbers." }, entry: entry({ concept: "matrices", source: { kind: "tree" } }), }); const proposal = screen.getByTestId("quiz-proposal"); expect(proposal).toHaveTextContent("Matrices"); - expect(proposal).not.toHaveTextContent("A function that calls itself."); - expect(proposal).toHaveTextContent("MATH210 · struggling · 1 connected concept"); + expect(proposal).toHaveTextContent("A rectangular array of numbers."); }); - it("cancels back to the source", () => { + it("falls back to the built sentence while the description is in flight", () => { + mount({ home: { cardDescription: null } }); + + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent( + "CS101 · struggling · 3 connected concepts", + ); + }); + + it("cancels back to the session's source", () => { + // `cancelTarget` reads `session.source.returnTo`. A session that carries one + // must land there — not on the fallback the no-source case uses. const { actions } = mount({ - entry: entry({ source: { kind: "dashboard", returnTo: "/dashboard" } }), + session: session({ source: { kind: "tree", returnTo: "/tree?node=recursion" } }), }); fireEvent.click(screen.getByTestId("quiz-cancel")); + expect(actions.exit).toHaveBeenCalledWith("/tree?node=recursion"); + }); + + it("cancels to the dashboard when the session has no source", () => { + // The fixture session is `{kind:"nav"}` with no `returnTo` (§5 B1.8). + const { actions } = mount(); + fireEvent.click(screen.getByTestId("quiz-cancel")); expect(actions.exit).toHaveBeenCalledWith("/dashboard"); }); + + it("keeps Cancel reachable when there is nothing to propose", () => { + // The three no-card states are still a way back to wherever you came from. + const { actions } = mount({ + home: { nodes: [], edges: [] }, + session: session({ source: { kind: "tree", returnTo: "/tree?node=recursion" } }), + }); + + expect(screen.getByTestId("quiz-empty-state")).toHaveTextContent("Your tree is empty"); + fireEvent.click(screen.getByTestId("quiz-cancel")); + expect(actions.exit).toHaveBeenCalledWith("/tree?node=recursion"); + }); }); describe("QuizHome — the resume strip", () => { @@ -417,6 +453,19 @@ describe("QuizHome — the concept dialog", () => { expect(screen.getByTestId("quiz-concept-dialog")).toHaveTextContent("Base cases"); }); + it("does not restate the meta line in the stand-in definition", () => { + // The dialog's meta already opens with the course code and carries the + // tier; the built sentence used to repeat both back (M6). + mount(); + fireEvent.click(screen.getByTestId("quiz-pick-open")); + fireEvent.click(within(screen.getByTestId("quiz-pick-list")).getByTestId("quiz-pick-base-cases")); + + const dialog = screen.getByTestId("quiz-concept-dialog"); + expect(dialog).toHaveTextContent("CS101 · 52% · learning"); + expect(dialog).toHaveTextContent("1 connected concept on your tree."); + expect(dialog).not.toHaveTextContent("CS101 · learning · 1 connected concept"); + }); + it("collapses the pick list again", () => { mount(); fireEvent.click(screen.getByTestId("quiz-pick-open")); @@ -494,6 +543,45 @@ describe("QuizHome — arrival", () => { ); }); + it("resolves a legacy `?topic=` name the same way", () => { + // The other half of the deep-link path: `topic` is a concept NAME, matched + // case-insensitively (the tree's and dashboard's old links). + const { actions } = mount({ + entry: entry({ topic: "matrices", source: { kind: "link" } }), + }); + + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("Matrices"); + expect(toast.info).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId("quiz-start")); + expect(actions.start).toHaveBeenCalledWith( + expect.objectContaining({ conceptId: "matrices", courseId: "c-math" }), + SESSION_CONFIG, + ); + }); + + it("says where a note's concept came from", () => { + mount({ + entry: entry({ concept: "matrices", source: { kind: "notes", noteId: "n1" } }), + }); + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("From your note"); + }); + + it("drops the 'Also worth a look' heading when there is nothing under it", () => { + // A deep link onto a tree with nothing left to review: the card stands, but + // there are no alternatives and nothing is due. + const mastered = NODES.map(n => ({ ...n, mastery_tier: "mastered" as const })); + mount({ + home: { nodes: mastered }, + entry: entry({ concept: "matrices", source: { kind: "tree" } }), + }); + + expect(screen.getByTestId("quiz-proposal")).toHaveTextContent("Matrices"); + expect(screen.queryByText("Also worth a look")).toBeNull(); + // …and the way out of the dead end is still there. + expect(screen.getByTestId("quiz-pick-open")).toBeInTheDocument(); + }); + it("says so once when the link names something outside the semester", () => { const { view } = mount({ entry: entry({ concept: "not-in-this-term", source: { kind: "link" } }), diff --git a/frontend/src/components/quiz/home/QuizHome.tsx b/frontend/src/components/quiz/home/QuizHome.tsx index 6568757f..e801770c 100644 --- a/frontend/src/components/quiz/home/QuizHome.tsx +++ b/frontend/src/components/quiz/home/QuizHome.tsx @@ -208,13 +208,14 @@ export function QuizHome({ userId, home, config, entry, session, actions }: Quiz ? DEEP_LINK_RATIONALE[entry.source.kind] ?? DEFAULT_DEEP_LINK_RATIONALE : null; - // R-8's sentence is fetched for `home.primary` only, so it is shown only when - // the card IS that proposal — a deep link to another concept would otherwise - // be captioned with somebody else's definition. + // R-8's sentence, fetched by the hook for the concept the CARD shows — a deep + // link included (A2 fix round 5). The built sentence stands in while the call + // is in flight or after it fails. The two queued shapes show no definition at + // all: it describes one concept and would read as a caption for the wrong + // thing under "Practice CS101". const definition = queued ? null - : (card && card.node.id === home.primary?.node.id ? home.primaryDescription : null) - ?? (card ? fallbackDefinition(card, connectedTo(card.node.id)) : ""); + : home.cardDescription ?? (card ? fallbackDefinition(card, connectedTo(card.node.id)) : ""); const feedbackSuffix = session.config.feedback === "as-you-go" ? " · answers as you go" : ""; const configLine = queued @@ -310,31 +311,33 @@ export function QuizHome({ userId, home, config, entry, session, actions }: Quiz ? resumable.attempt.questions.length || resumable.attempt.total || 0 : 0; + // A true full-bleed band: it renders OUTSIDE the content column (A2's + // `.quiz-body--home` is padding-free for exactly this), so `InlineBanner`'s + // own page padding and bottom rule reach both edges under the TopBar with + // nothing here overriding them. const resumeStrip = resumable ? ( -
- - - - - } - > - {`You left a quiz on ${resumeName} — ${resumable.answered} of ${resumeTotal} answered`} - -
+ + + + + } + > + {`You left a quiz on ${resumeName} — ${resumable.answered} of ${resumeTotal} answered`} + ) : null; const conceptCount = React.useMemo( @@ -342,19 +345,28 @@ export function QuizHome({ userId, home, config, entry, session, actions }: Quiz [home.nodes], ); + /** + * The eyebrow-and-Cancel row (§5 B1.8). It sits above whatever the body turns + * out to be rather than inside the proposal, so the three no-card states — + * empty tree, no courses, everything mastered, a failed load — still have a + * way back to wherever the student came from, and nothing below it moves as + * the load resolves. + */ + const cardHead = (eyebrow: string | null) => ( +
+ {eyebrow &&
{eyebrow}
} + +
+ ); + const proposal = card ? (
-
-
Ready for you
- -
-
@@ -507,11 +519,31 @@ export function QuizHome({ userId, home, config, entry, session, actions }: Quiz
); + /** Which arrival the screen is rendering. Named once so the head row above + * the body can know whether it has an eyebrow to carry. */ + const view = + home.status === "error" + ? "error" + : home.status === "loading" + ? "loading" + : home.courses.length === 0 + ? "no-courses" + : conceptCount === 0 + ? "empty-tree" + : picking + ? "picking" + : "home"; + + // "Also worth a look" is a heading for the rows under it; with no + // alternatives AND nothing due (a deep link to a mastered concept, say) it + // would sit between two rules with nothing in between. + const hasMore = home.alternatives.length > 0 || home.due.count > 0; + const body = () => { - if (home.status === "error") return errorCard; - if (home.status === "loading") return skeleton; + if (view === "error") return errorCard; + if (view === "loading") return skeleton; - if (home.courses.length === 0) { + if (view === "no-courses") { return ( setPicking(false)} />; } @@ -552,9 +584,15 @@ export function QuizHome({ userId, home, config, entry, session, actions }: Quiz {card && ( <>
-
Also worth a look
- {alternatives} -
+ {hasMore && ( + <> +
+ Also worth a look +
+ {alternatives} +
+ + )}