Uh oh!
There was an error while loading. Please reload this page.
feat(quiz): rebuild the quiz UI from the approved designs (#537 frontend) - #580
Conversation
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 <noreply@anthropic.com>
…odeStyle extraction (#537) Extends KnowledgeGraph2D.testmode.test.tsx's snapshot() with fill, opacity, stroke-opacity, edge stroke/width and the label font/fill/truncation, and commits the array captured from the CURRENT renderer as a fixture. The extraction of the pure node-style layer into lib/graph/nodeStyle lands next; this is the proof it changes nothing the tree paints. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eStyle (#537) shadeFor/hexToHsl were byte-duplicated in KnowledgeGraph2D and 3D; the tier->opacity ramp had four copies and the mastery->radius, edge-width and label-truncation rules one each. They are now one module, imported by both renderers and (next) by the quiz's ConceptNode/ConceptNeighbourhood. - shadeFor(base, id, as) keeps BOTH output forms: "css" hsl(...) for the SVG renderer, "hex" for the 3D one (three.js paints the space-separated form black). - tierFor mirrors backend/config.py::get_mastery_tier with a pinning table test; it exists only for the growth variant's after-tier (R-12) -- every graph node still reads the server's mastery_tier string. - edgeWidthFor preserves the renderer's `|| 0.5` falsy-zero quirk verbatim. - The force-collide radius (18 + m*6), the tick fast-path and every testid are untouched; nodeVal keeps its own 4..10 volume ramp. Proof: the golden snapshot committed in the previous commit still matches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three shipped quiz endpoints had zero frontend callers and the machine-readable error envelope was thrown away on arrival (R1 §H, gap G12). This adds the data layer's foundation: - `ApiError` gains `code` / `requestId` / `retryAfterSec` / `body`; `fetchJSON` parses the JSON body and the `Retry-After` header. `message` stays the raw body text, so every existing caller is unaffected. - `humanizeError` prefers `error.message` off the coded envelope — the 422 case used to surface a Pydantic fragment because top-level `detail` is a list there. - `lib/quiz/errors.ts`: `QuizErrorCode` + the final copy table + `describeQuizError`, so a rate limit, a daily cap and a generation timeout stop looking identical. - `lib/quiz/api.ts`: wrappers for all six endpoints. `generate` sends `include_answer_key: false` (R-2) so no verdict can be computed client-side; `/answer` deliberately omits `time_ms`/`confidence`. - `lib/quiz/types.ts`: the §2 shared vocabulary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There is no neighbourhood endpoint, so the quiz's little constellation derives its three siblings client-side: real neighbours by descending edge strength (excluding the synthetic subject_root__* hubs, which are wired to every concept in the course), backfilled with same-course peers ordered by hashSeed(id) so a freshly extracted concept isn't drawn alone and the picture never reshuffles between renders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e marks (#537) New components, deliberately not a refactor of KnowledgeGraph2D's <g> (that mark is welded to the tick fast-path, five testids and two overlay rings the quiz doesn't want). Every number comes from lib/graph/nodeStyle, so a tree retune moves these too. - The mark is authored once in reference units (a 30-unit box whose half-width is radiusFor()'s own scale) and `size` only sets the CSS width — that is what makes a 15px dot and a 26px node the same mark at two sizes. - growth grows once on mount by transitioning a scale bound to a CSS custom property; with prefers-reduced-motion or animate={false} the first paint is already the identical end state and no transition exists. tierFor(after) is the one place the quiz derives a tier from a score (R-12). - ConceptNeighbourhood lays the three design slots out as canvas fractions so all three presets share one arrangement, and skips the top-right caption, which would clip on the edge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…A2) `reduce(session, event)` is pure and total; every effect lives in the hook, so the six invariants of §4 are properties of the transition table rather than of a component tree. All six have a test: no event walks out of a live quiz, `source` is byte-identical from entry to every terminal transition, an answered-then- unmounted quiz resumes on the first unanswered item, SELECT is ignored once the verdict shows, NEXT_IN_QUEUE past the end is ignored, and `recorded: false` advances like any other answer. `source.ts` is the thread that was missing entirely: the old screen pushed `/learn` for Cancel, Exit and Done alike. `return` is accepted only as a same-origin path — a `?return=https://…` on a link a student trusts is an open redirect, so it is dropped rather than sanitised, and re-checked at exit because a session can come back off localStorage. `session.ts` persists from `generating` onward and clears on submit/exit; every storage touch is wrapped, so a private window degrades to "no saved session". Discard hides an attempt client-side — there is no abandon endpoint (G4). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A2) `GET /api/graph/{user}/recommendations` returns `{concept_name, reason}` and nothing else — no node id, no course, no score, no last_studied_at — so it cannot render a card, join to an attempt, or seed a queue (R4 §2). The full node list is already loaded for the "pick something specific" list, so the one true ranking rule is reproduced over it with a citation to `graph_service.py:914-945`, the same way `Learn.tsx::tierForScore` mirrors `config.py::get_mastery_tier`. A TODO marks the join to swap in once the endpoint is enriched. One presentation-layer tie-break rides on top (R-7): unexplored nodes score 0.0, so a raw `mastery_score.asc` always opens on a concept the student has never seen. The primary slot prefers the weakest one they have actually studied; the ordering itself is untouched. "Due" is the same membership filter over the whole scoped graph — there is no spaced-repetition concept anywhere in the backend, so no days-since threshold is invented here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…redesign (#537) Each replaces a shape the app was already re-implementing privately, so the quiz gets its look by consolidating rather than adding another copy: - Button variant="link": the bare text button ("adjust", "Discard", "Done"). `ghost` keeps button padding, so it still reads as chrome. - SegmentedControl: the 2px-underline mechanic four screens re-implement (Achievements, Admin, Learn, FlashcardImportModal), with .label-micro type and real radiogroup semantics — one tab stop, arrows move AND select, Home/End, disabled options skipped. NOT <Toggle>, which is the filled pill. - AnswerOption: selection is a 2px LEFT bar and the ✓/✕ slot is always reserved, so neither picking nor revealing reflows the row. A verdict is never colour alone — it also carries a mark and a spoken suffix. - ProgressDots: the question rail, plus the row orientation Onboarding's private step dots want. One role="img" with a spoken label, not N dots. - InlineBanner: the tinted resume strip. role="status" — it appears without the user asking. No precedent existed anywhere in the app. - Sheet: a right-anchored panel over the page ("Ask about this"). Dialog's portal/trap/scroll-lock/Escape/focus-restore is now `useOverlayBehaviour`, exported from Dialog and shared, so the two cannot drift. - EmptyState: promoted out of Gradebook/Landing.tsx, which imports it back (size="hero" is that screen's display-scale treatment). Its action button gained a testid, so its lone eslint suppression is pruned. Styling is classes + tokens only (R-1) in one commented globals.css block, including the ConceptNode/ConceptNeighbourhood paint. The design's geometry constants are declared once as tokens there rather than inlined. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`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 <noreply@anthropic.com>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 <a class="btn">, 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 <noreply@anthropic.com>
… additions) (#537) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… A2) One component now owns the quiz route. `screens/Quiz.tsx` → `QuizPanel.tsx` is replaced by `QuizScreen`, which reads the entry off the URL, holds the session, and switches on phase between `QuizHome`, `QuizQuestion` and `QuizResults`. The AI-disclaimer gate carries over unchanged. The three screens ship as STUBS whose props are the seam Wave 3 builds against: each one renders enough to drive the machine by hand end to end (start, answer, next, leave, resume, submit, exit), so the data layer is exercisable before a pixel of the real design exists. `--quiz-accent`, bound from the active concept's course colour, is the single inline style anywhere under `components/quiz/**` (R-1). Everything else is a class over tokens; `quiz.css` declares the design's own geometry constants (three column widths, three page paddings) once as tokens so no screen carries a bare px measurement. `useQuizHome`'s load state is derived from a request key rather than reset at the top of its effect — a synchronous setState in an effect body is a cascading render, and the stale-response guard falls out of the same key for free. Testids: the doc gains a "redesign" table alongside the legacy one (both surfaces coexist until `QuizPanel` is deleted), naming the three renames explicitly. The eslint testid file-list gains the three screens plus `AnswerOption`/`SegmentedControl`/`Sheet` — the controls render inside those primitives, so a testid-less button there would un-anchor the surface from outside the screen files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nguishable (#537) §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 <noreply@anthropic.com>
… graph (#537 A2) `?topic=<name>` is the fuzzy legacy form every old tree and dashboard link still uses; `?concept=<id>` 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 <noreply@anthropic.com>
…ANSWER (#537 A2) 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 <noreply@anthropic.com>
…lection, cancelTarget) (#537) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…round 1)
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 <noreply@anthropic.com>…wers are 15px (#537, review I-1 + M-4) 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 <noreply@anthropic.com>
…view I-2) ~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 <noreply@anthropic.com>
… it (#537, review I-3 + M-6..M-8) 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 <noreply@anthropic.com>
…rom git (#537 A2, fix round 2) 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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…open (#537 C3) The "Generate quiz" action pushed a bare /quiz?concept=<id>, 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=<id>), and the page understands ?note=<id> 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 <noreply@anthropic.com>
- "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 <noreply@anthropic.com>…ory (#537 C1) The tree's half of the quiz entry/exit thread (contract §6, R-10). Quick quiz now links through `buildQuizHref`. A concept is addressed by node ID rather than by name — a concept name is only unique within a course, so the old `?topic=<name>` link could seed the wrong node — and carries `from=tree` plus a `return` that comes back to this node's open panel. A subject root targets its abstract course instead of dropping the student on a bare `/quiz`: the #319 rule (a course has no single topic) still holds, but the quiz home can now propose within a course. `/tree?node=<id>` is the other end of that thread: it selects the node, which is what opens the desktop panel and the mobile sheet alike. That is the whole focus gesture — neither graph renderer exposes a camera, only a `highlightId` paint — and an unknown id is ignored in silence. Applied once per param value so a later graph reload cannot re-open a panel the student closed. `?suggest=` is untouched. The node panel gains "Recent quizzes": the five newest completed attempts for the selected concept, as date · score/total · Δ mastery. The attempts endpoint is user-scoped and unfiltered (gaps G2/G3), so concept, status, order and cap are all applied client-side over one lazily-fetched page — fetched on the first concept selection, cached for the screen's lifetime, refreshed when a quiz submit fires `sapling:graph-changed`. Rows are inert because no finished-attempt review endpoint exists (gap G5). Empty reads "No quizzes yet" and leans on the Quick quiz button above as its CTA. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ng (#537 C2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
#537 B3) 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 <noreply@anthropic.com>
…yout (#537 C2 fix round 1) 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 <noreply@anthropic.com>
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 <text> 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 <noreply@anthropic.com>…ound (#537 B3, fix round 1) Review round 1. Important 1 — the practise primary follows the prototype's `practiseLabel`: "Practise the one you missed" / "Practise the ones you missed", never a count (the count is already the eyebrow above the list). Test renamed and re-pointed. Seam props that landed while this was in review: - `showCentreLabel={false}` on the neighbourhood (A1, 66b19c2) replaces the `:last-of-type { display: none }` workaround in results.css, TODO and all. The render is byte-for-byte what the workaround produced. - `nextConcept` (A2, 1c2c6f5) names the queue exit: "Next: {name} →", falling back to "Next concept →" only when the id can't be named. It is a LABEL — the exit still renders on the queue-length check, never on the name. Minors: - The explanation panel is always in the DOM and toggles `hidden`, so `aria-controls` never points at a missing element. - The disclosure flips off `prev`, not the captured `open` — two batched clicks no longer collapse into one toggle. - The missed `<section>` is named by its eyebrow (`aria-labelledby`), so it is exposed as a landmark instead of loose text. - Dropped the unread `quiz-results-exits` testid (not in §7). Three behaviours that were argued in comments are now pinned: the node grows once and does not replay across a re-render, the unanswered "No answer · …" row, and `quiz-again` restarting with the session's own config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ly shows (#537 A2, fix round 5) (1) Home renders WITHOUT the centred column. The resume strip has to reach both edges under the TopBar, which it cannot do inside a padded, max-width parent, so `.quiz-body--home` is padding-free and `display: block` and the home screen wraps its own content. `.quiz-col--home` stays available and `.quiz-inset--home` hands back the page padding it lost; quiz.css says so at the rule. Question and results keep the column exactly as they were. (2) `useQuizHome` takes the entry and describes the concept the CARD will show, not the ranked primary. The entry overrides the ranking (§5 B1.2): a `?concept=`/`?topic=` link names its own concept, `?course=` opens on that course's weakest, `?scope=due` on the weakest due overall. Describing the primary regardless meant paying for an LLM call about a concept nobody was looking at AND leaving the deep-linked card with no paragraph — the home screen was already working around it by nulling the description whenever the card was not the primary. An entry that cannot be resolved (a link into a term the student is not viewing) falls through to the ranking rather than showing nothing. Exposed as `cardConceptId` and `cardDescription`. `primaryDescription` stays as a deprecated alias of the same value so the current render keeps compiling. Note: `QuizHome.test.tsx` is B1's, and the two new required fields broke its `buildHome` fixture — a spread over `Partial<QuizHomeData>` widens any key the literal omits. Two mechanical keys added there, nothing else touched; I broke it, so I fixed it rather than leaving tsc red. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…seams (#537 B2, fix round 1) Review round 1. Important — AskPanel threw away a partial reply. A stream that failed AFTER producing tokens rethrew, the outer catch set the error and `finally` cleared `streaming`, so a half-written answer the student was reading blinked out and was replaced by an error strip. Learn's ladder (ADR 0020) does the opposite on purpose. The tokens now accumulate into a local `partial`, the outer catch appends it as an `interrupted` turn before the error, and Retry drops that fragment before re-running so the second answer doesn't read as a sequel. Seams that landed while this was in review: - `QuizQuestionProps.pending` (A2 ed1bca6) replaces the screen-local `busyKey` latch. The hook is the only thing that knows about the submits it refuses, so the local version could stick on exactly those. - `Button` forwards its ref (A1 973a81e), so "Ask about this" is a `<Button>` again rather than a raw `.btn` standing in for one. Minors: three loose px hoisted into tokens (the AskPanel's onto `.quiz-ask` — it is portalled, so `.quiz-question`'s tokens never reach it); the dead `.quiz-question__flag` rule made specific enough to win, so the flag renders the prototype's 12px; the panel gets its own visually-hidden class instead of borrowing the question screen's keyboard-hint one. Five tests: the interrupted partial + its Retry, abort-on-unmount, `pending` holding Submit, the keyboard map not being on `window`, Space left to the row's own activation. Two tightened: "never orphans" now asserts NO action fired, and `deliveredShort` re-renders to prove the toast is once-per-attempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>The three files themselves — `components/QuizPanel.tsx`, its test, and `components/screens/Quiz.tsx` — landed in b26ce09: they were staged in the shared worktree index when B2's fix round committed. This is the rest of the removal, so nothing points at them any more. `lib/api.ts`: the old `QuizConfig` interface and `fetchQuizConfig` / `generateQuiz` / `submitQuiz` are gone. `lib/quiz/api.ts` has its own client over all six quiz endpoints and its own `QuizConfig` in `lib/quiz/types.ts`; these three were reachable only from `QuizPanel`. `generateQuizFromNote` stays — the notetaker uses it. The three `any[]`s that went with them freed three `eslint-suppressions.json` entries (26 -> 23 on `lib/api.ts`), pruned here because the ratchet exits 2 on a stale suppression. `eslint.config.mjs`: `QuizPanel.tsx` off the testid file list. The eleven new quiz entries stay. `quiz.css`: `.quiz-stub` and `.quiz-stub__actions` were Wave 3 placeholder scaffolding nothing renders any more. `.quiz-stub__phase` is NOT dead — `QuizScreen` still uses it for the signed-out line — so it stays, with a comment that says what it is rather than what it was for. `docs/frontend-testids.md`: the two coexisting quiz tables collapse into one. Every id in it was grepped out of the source first; the renames (`quiz-exit`/`quiz-retake`/`quiz-explain-concept`) are kept as a rename record, which is what a journey author anchoring an old id needs. `quiz-results-exits` is named in contract §7's Waves 3–4 list but is a CSS class in `QuizResults.tsx`, not a testid — left out rather than documented as fiction. `e2e/quiz.spec.ts` still drives the old flow and is D2's to rewrite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… for queue cards (#537 A2, seam round) (1) `quiz.css` told the home screen to put `quiz-col quiz-col--home quiz-inset--home` on one element. That is wrong and B1 found it: `box-sizing: border-box` is global, so a single div resolves `max-width: 780px` INCLUSIVE of the 64px of page padding and the card measures 716. The design puts the padding outside the column, so the DOM has to as well. The comment now documents the nesting B1 shipped — `.quiz-inset--home` wrapping `.quiz-col.quiz-col--home` — as the contract, with the reason and a diagram, and the `.quiz-inset--home` rule repeats "must be the PARENT, never a sibling class". Comment only; no rule changed and no file of B1's touched. (2) `useQuizHome` was fetching a concept description for the two queue-shaped entries. A `?course=` or `?scope=due` card is headed "Practice {CODE}" / "Review everything due" over a queue summary (§5 B1.2) — there is no definition slot on either, so that was an LLM call per home visit for text nothing renders. The card resolution now carries `describable`: the concept id is still resolved (Start generates on it, the accent comes from it), only the paragraph is skipped. An entry that resolves to nothing still degrades to an ordinary primary card, description included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#537) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…izPanel (#537) The header opened with "three things the current QuizPanel gets wrong" — D1 has since deleted QuizPanel, so the comment pointed at nothing. Same three guarantees, now stated as the row's own: a radio row for the quiz question screen with a left selection bar and a permanently reserved mark slot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…A2) D1 removed `QuizPanel.tsx` and `screens/Quiz.tsx`; four places still narrated them, which is worse than no comment — a reader chases a file that isn't there. - `lib/quiz/api.ts`: dropped the "the legacy wrappers stay until QuizPanel is deleted" sentence. It now says what is true: this is the only quiz client, and `lib/api.ts` keeps the shared `fetchJSON` and the non-quiz routes. - `lib/quiz/useQuizHome.ts`: "matches what `screens/Quiz.tsx` does today" now states the behaviour and its reason directly — the hydration wait exists so a returning user doesn't fetch unscoped, re-fetch scoped, and flash concepts from a term they aren't looking at. - `components/quiz/QuizScreen.tsx`: the signed-out line was still wearing `quiz-stub__phase`, a class named for scaffolding that no longer exists. It is `.quiz-signin-note` now, with its rule in quiz.css. No `quiz-stub` reference remains anywhere in src. - `lib/quizSelection.ts`: the header claimed to serve QuizPanel's picker. It now records what survived that picker — `resolveInitialSelection` is the id half of `proposals.ts::entrySelection`, and its "unknown id selects nothing" answer is precisely why the redesign reuses it instead of re-deriving: a deep link into a term the student isn't viewing must read as unresolved, not as a quiz on something off-screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two new Playwright lanes over the real local stack, alongside D2's rewrite of the quiz's own journeys. `quiz-integration.spec.ts` pins the features the redesign REACHES INTO: the tree's `?node=` focus and its Recent-quizzes block (the R-10 round trip that replaced the hardcoded `/learn` exit), the gamification read behind the results screen's XP line (R-9), the `quizzes_completed` achievement counter, the "Ask about this" handoff (seed contents, a real streamed reply, a follow-up, and the attempt still open in `quiz_attempts` when the sheet closes), the notetaker's disabled-until-linked gate and its `?note=` return path, and semester scoping over a deep link. Every write goes through the UI and every read comes back over a different layer — raw SQL or the API. `quiz-errors.spec.ts` renders every error code the task named, with the exact copy IMPORTED from `lib/quiz/errors.ts` rather than retyped, plus the partial delivery toast and the three empty states. The envelopes are faked at the network layer, byte-identical to `quiz_errors.py::quiz_error_body` (429 carries `Retry-After`); each card is also asserted NOT to contain the server's own sentence, which is the pre-#537 bug it would regress to. `support/quizStack.ts` holds the fixtures and the two interaction helpers. Deliberately not `support/quiz.ts` — that file is D2's. Verified: two full flock'd stack cycles, 20/20 passing both times, zero flake. `backend/tests/test_e2e_function_handlers.py` re-run unchanged (24 passed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… tell (#537) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The #537 screens replaced QuizPanel/screens/Quiz wholesale, so every anchor in quiz.spec.ts moved with them. What the four tests are FOR did not, and they are re-driven against the new UI: the #393 mastery journey (now "3 of 3 correct" + the "25% -> 34% - struggling -> struggling" delta line, with at-end mode proving the verdict is NOT revealed per question), the #129 resubmit 409, the #540 config mirroring (re-anchored on the Adjust dialog's segmented controls), and #184's successor — the zero-question case is unreachable now that /generate 502s, so the failure is forced at the network layer in the coded-envelope shape and the mapped copy is asserted verbatim. quiz-journeys.spec.ts adds what the redesign is actually for: resume, leave-and-return, ask-without-abandoning (the tutor over the question rather than a navigation that dropped the attempt), the missed-question review and its focused re-practice, the five entry-point arrival states, the four exit destinations, and a keyboard-only pass. Two properties ride in as test.fixme with their diagnosis, per docs/e2e-exploration.md 8: - a resumed quiz loses its stored verdicts and origin (quiz home clears the record via SET_CONFIG -> persistSession before Resume can read it); - "Done" leaves the deep link in the address bar (the same-route router.push does not drop the query), so home stays pinned to the concept just finished. The lane also has a hard ceiling worth knowing about: /api/quiz/generate is 8 calls per 300s per user, in-process, and the per-test truncate does not reset it. The two specs spend exactly eight real generations between them and stub the rest; the accounting is in quiz-journeys.spec.ts's header. graph.spec.ts gains one comment: its mirrored tier constants now live in lib/graph/nodeStyle.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rops the deep link (#537 A2) Both found by the browser lane, both ridden in as `test.fixme` in `e2e/quiz-journeys.spec.ts`. (a) Quiz home was deleting the paused attempt it was about to offer. `persistSession` cleared storage for any phase outside the live set, `home` included — and mounting home starts a fresh home-phase session whose config effect fires `SET_CONFIG` the moment `/api/quiz/config` resolves, persisting as it goes. localStorage is the only home for the half of a session the wire never sends (R-3): the verdicts, the scope and the origin. So the record was gone before Resume could read it. Observable as question one coming back unanswered, and a dashboard-sourced quiz exiting to the tree (masked on a tree-sourced one, where the `returnToSource` fallback produces the same destination). `persistSession` now saves or does nothing. Clearing is not a phase's business: §4 names exactly two moments, SUBMITTED and EXIT, and `useQuizSession` already calls `clearSession()` explicitly at both. (b) Done left `?concept=` in the URL. `router.push("/quiz")` does not take for a query-only change on the same route, so home stayed pinned to the concept just finished and a reload reopened the link. A same-route exit now uses `replace` — what Dashboard's suggest-dismiss and Calendar's filters already do to drop a query in place, and the right history semantics anyway: Back must not return to the results of a quiz whose session has been cleared. A real route change still pushes. `EXIT` also stops leaving `conceptId`/`scope` pointing at the finished quiz, so nothing that prefers the session over the proposal stays pinned to it. The exit destination is now read BEFORE the reset, because `returnToSource` needs the conceptId that EXIT clears. The stale `session.test.ts` case that asserted the clear-on-home behaviour is replaced by one that pins the opposite. Reverting either fix (or the EXIT reset) fails the matching test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it for the E2E stacks (#537) services/request_limits keeps its sliding window in a module-level dict, so the 8-per-300s quiz-generation guard is neither per-test nor per-spec: ONE Playwright run shares a single window across every worker, and the per-test TRUNCATE + re-seed does not reset it — only a backend restart does. The redesigned quiz lane makes ~20 real generations, so the production number 429s whichever quiz specs run last. QUIZ_GENERATE_RATE_LIMIT and QUIZ_GENERATE_RATE_WINDOW_SEC now read the environment with the shipped values as defaults, validated as positive ints and fail-SAFE (junk falls back to the default with a warning rather than booting with a disabled guard, or refusing to boot). No route, no product behaviour, and no other constant changed. scripts/e2e-up.sh and e2e.yml raise the limit to 1000 for the E2E stacks only — under function mode a generation is a scripted constant that costs nothing, which is the only thing the limit exists to bound. Also: supabase/snippets/.gitkeep, because the Supabase CLI mounts that directory and git does not track empty ones, so a fresh worktree's `make e2e-up` died at `supabase start` with a statfs LegacyContainerCreateError; and the testid registry's quiz-results-score row, which described a percentage the redesigned screen never renders. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…md, showCentreLabel, composition, forwardRef) (#537) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed (#537) `c3580e95` [A2-e2e-fix] fixed one of the two defects these journeys rode in for. Verified on a full Chapter-1 lane against the built app: #537: a resumed quiz restores the verdicts and the origin it was left with → PASSES. Un-fixme'd. persistSession no longer deletes the record quiz home is about to offer, so the rail comes back with question one marked done and a dashboard-sourced quiz exits to the dashboard. #537: Done drops the deep link and returns a clean quiz home → STILL FAILS. Stays fixme, with the diagnosis re-measured rather than inherited: switching the same-route exit from router.push to router.replace did not take either — twenty seconds after the click the address bar still reads /quiz?concept=rich-node-cs-recursion. What the commit DID buy is the screen underneath: the failing run's snapshot shows the ordinary ranked proposal ("Ready for you" / "Suggested for you"), not the deep-linked card, because EXIT now clears conceptId. So the residual defect is the URL alone and its one observable consequence is that a reload reopens the deep link. Two untried candidates are named in the docblock for whoever picks it up. The file header's generation budget is rewritten too: it told the next author the ceiling was 8 per 300s and that lifting it was an unlanded backend change. It landed in the previous commit — the E2E stacks now run at 1000 — so the budget is advice about speed, not a hard cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…de stale (#537) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…outs, registry (#537) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o-op navigation (#537 A2) ROOT CAUSE. The exit was asking the App Router to NAVIGATE to the route the app is already on. `/quiz?concept=x` to `/quiz` is a route-tree-identical change — only client-side search params differ — which App Router resolves to a no-op and never commits, so no history entry is written and the address bar does not move. `push` was tried, then `replace`; the lane measured the same unchanged URL after both. Established by elimination, not assertion. The new `QuizScreen.test.tsx` drives the whole screen against mocked clients — home, start, answer, results, Done — and records every push, replace, pushState and replaceState. Against the code as it was it showed exactly ONE navigation, correctly targeted at `/quiz`, with nothing re-issuing the deep link afterwards. So none of the suspected causes were real: nothing re-pushes the entry after EXIT, nothing races the reset and re-reads stale searchParams, nothing remounts. The single correct router call simply does not move the URL. FIX. `goTo(destination, router)` picks the mechanism the destination needs: a different route is a navigation and goes through the router, which works; the same route with a different query is a URL edit and goes through `window.history.replaceState`, which is what Next 15+ supports for changing search params in place and threads back into `usePathname`/`useSearchParams`. Using the navigation API for navigations and the URL API for URL edits is the fix — reaching for `history` to force a navigation the router had refused would have been the hack. "Where am I" is read from `window.location.pathname`, deliberately not `usePathname()`: a hook value that is one render stale, or null outside the App Router, would silently route back to the broken branch. `confirmLeave` goes through the same helper so there is one answer to how this screen moves the student; it is a real route change and still pushes. The hook tests now assert the address bar itself (jsdom put on `/quiz?concept=…`) rather than which router method was called — the previous version passed while the real URL never moved, which is exactly the failure mode being fixed. Only my hunks of `useQuizSession.ts` are in this commit; another agent's in-flight unmount-safety work in the same file is left in the working tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The other half of B M-1: a mountedRef latch so the async chains stop setting state after the screen is gone. The chains themselves still run — an /answer already sent must reach /submit — and the ref + the stored record are still updated, so an unmounted chain leaves a resumable session behind. Only the render is skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… syncs the URL (#537 A2) `4a80da2d` moved the address bar but left the card pinned, and the reason is one argument. Next patches `history.replaceState` and bails out of its own hook first (`app-router.js` in next@16.2.9): window.history.replaceState = function replaceState(data, _unused, url) { if (data?.__NA || data?._N) return originalReplaceState(data, _unused, url) data = copyNextJsInternalHistoryState(data) if (url) applyUrlFromHistoryPushReplace(url) return originalReplaceState(data, _unused, url) } That guard exists so Next's own navigations don't loop. Handing it `window.history.state` hands it the state Next itself wrote — `__NA: true` and all — so it took that branch: the URL changed via the original method and `applyUrlFromHistoryPushReplace` never ran, so the router never synced and `useSearchParams()` kept `?concept=`. Exactly the half-fixed symptom. Passing `null` loses nothing. `copyNextJsInternalHistoryState` starts from `{}` and copies `__NA` and `__PRIVATE_NEXTJS_INTERNALS_TREE` off the CURRENT entry — Next preserves the router state itself. Preserving it by hand is what broke it, and the comment that said otherwise is replaced with the quoted guard. The sync cannot be observed in jsdom, so the test pins the one input that decides whether it happens: the recorded `replaceState` state argument must be null. That assertion was vacuous at first — jsdom's `history.state` is null anyway, so both spellings looked identical — so the fixture now seeds a real App Router entry (`{__NA: true, __PRIVATE_NEXTJS_INTERNALS_TREE}`) and the test also asserts the entry being replaced carried `__NA`. Reverting the argument now fails it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`4048a793` [A2-done-fix-2] is the one that took. Verified on a full Chapter-1 lane built from it: the journey passes in 2.6s, and the lane is 75 passed / 1 failed / 1 skipped with no quiz `fixme` left — the only failure is landing-drag-field.spec.ts:332, red on main's own CI for the same assertion. The docblock now records all four attempts, because each failed on a different assertion in this test and the next reader deserves to know which: 1. router.push — URL unmoved; /quiz?concept=x to /quiz is route-tree-identical, so App Router no-ops it. 2. router.replace — same, same reason. 3. history.replaceState(window.history.state, …) — URL clean, CARD still pinned. Next patches the History API and early-returns on its own state (`if (data?.__NA || data?._N)`, app-router.js 16.2.9), so applyUrlFromHistoryPushReplace never ran and useSearchParams kept returning ?concept=. 4. history.replaceState(null, …) — green. copyNextJsInternalHistoryState preserves __NA and the internals tree itself. Both assertions are kept, one per failure mode (URL for 1-2, card for 3). The card assertion also had to be made sound, which took two tries of its own and is written into the docblock so it is not repeated: the ranking rule on both sides is "weakest STUDIED concept, else weakest overall", and a 3/3 run sets times_studied on the concept just quizzed. So reading the ranking before the quiz compares against an invalidated snapshot; "the card is not the concept just finished" is false by design; and reading it after the quiz without a reload compares fresh DB rows against a screen still rendering the graph copy it fetched at mount. The test reloads — which is also the harm the deep link actually did — and both sides then rank the same rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying with |
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs | frontend-staging | d966da6 | Commit Preview URL Branch Preview URL | Aug 23 2026, 06:11 AM |
Important Review skippedToo many files! This PR contains 115 files, which is 15 over the limit of 100. To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (115)
You can disable this status message by setting the Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| function isHrefAction(a: EmptyStateProps["action"]): a is { label: string; href: string } { | ||
| return ( | ||
| typeof a === "object" && | ||
| a !== null && |
Updates to Preview Branch (feat/537-quiz-frontend) ↗︎
Tasks are run on every commit but only new migration files are pushed.
View logs for this Workflow Run ↗︎. |
Uh oh!
There was an error while loading. Please reload this page.
Closes the frontend half of #537: the quiz UI replaced end to end from the approved Claude Design handoff, wired into every entry and exit point, the old
QuizPanelimplementation removed, and proven against the real local stack.Handoff report with screenshots, the backend gap list, rulings and seams: https://claude.ai/code/artifact/c94ef976-6af2-4bb5-838b-45c51cdd57bf
Contract every change was built against:
docs/superpowers/specs/2026-08-22-quiz-frontend-contract.mdWhat's in here
Three screens (
components/quiz/): quiz home (resume strip, ranked proposal with rationale + definition, neighbourhood, alternatives, "review everything due", picker + adjust/concept dialogs), the question + answered state (progress rail, radio rows with a reserved mark slot, keyboard-complete: letters/numbers/Enter/Escape/arrows, flag always present, "Ask about this" opens a sheet over the attempt and never orphans it), and results (node grows once — static under reduced motion — score, XP + streak line, missed items with a non-destructive ask, three exits).Data layer (
lib/quiz/): typed client for the six quiz endpoints (include_answer_key:false, every answer recorded via/attempts/{id}/answer,/submitalways at the end), the one error-code → copy module honouringRetry-After, a pure session state machine with persistence (no path out ofactivediscards the attempt;sourcetravels entry → exit), source/exit URL API, and the dashboard's ranking mirrored client-side over the loaded graph.Design system:
ui/SegmentedControl,AnswerOption,ProgressDots,InlineBanner,Sheet,EmptyState(promoted from Gradebook),Buttonlink variant + forwardRef + a disabled-primary rule;graph/ConceptNode+ConceptNeighbourhood. The tree's node math is extracted once intolib/graph/nodeStyle.tsand both renderers import it — pinned by a golden snapshot captured before the refactor. Zero hex, zero inline styles in the quiz code.Wiring: tree node panel + subject root →
buildQuizHrefwithfrom/return; tree gains?node=focus and a "Recent quizzes" list; dashboard suggest card and a "Review what's due" CTA (both layouts, incl. mobile); notetaker "Generate quiz" keeps its disabled-until-linked gate and returns to the note via?note=; legacy?concept=/?topic=links still resolve inside the active semester. Nothing lands on a blank/learnany more.Removed:
QuizPanel.tsx+ test,screens/Quiz.tsx, the old api helpers, the old testids (registry rewritten indocs/frontend-testids.md).Backend: untouched except
QUIZ_GENERATE_RATE_LIMIT/_WINDOW_SECbecoming env-overridable (defaults unchanged, tested) — raised to 1000 inscripts/e2e-up.shande2e.ymlonly, because the in-process limiter is shared by a whole Playwright run.Verification
tscclean ·eslint0 errors · 1032 unit tests (99 files), incl. every state-machine transition and the six invariantsquiz.spec.ts(the test(e2e): journey — quiz answer → mastery update (UI + DB) #393 monotonic-mastery journey preserved),quiz-journeys.spec.ts(resume, leave-and-return, ask-without-abandoning, missed review, every entry's arrival, every exit's destination, keyboard-only pass),quiz-integration.spec.ts(tree/XP/achievements/tutor handoff/notes/semester),quiz-errors.spec.ts(every mapped code + no-courses/no-concepts/502/partial/409). The one failure islanding-drag-field.spec.ts:332, red on main's own last six runs — e2e lane red on main since #524: a dropped landing node doesn't scroll with the page #566 / test(e2e): measure a dropped node against the copy it is welded to (#566) #568. The skip is the opt-in screenshot pass.Known seams (backend follow-ups, each a
TODO(#537-followup)in code)abandon/discard endpoint · feedback mode as a
/configoption · per-concept attempt filter · finished-attempt review payload · tutor session seed field · XP/streak in the submit response · questiontypeon the wire · flag persistence · enriched/recommendations· a focus/camera prop on the graph renderers ·/wikistill publishes pre-#557 tier ranges.Note:
quiz-product-flows.md/quiz-design-brief.mdnamed in the brief weren't in the repo; the session model and machine are derived from the mission text and the prototype.🤖 Generated with Claude Code